mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
Resolves the setup-hook harness conflict (keep _setup_ready_line, take main's gstack_canonical_claude_root slice) and treats main's new _gstack_bun_below status 2 (unparseable) as not satisfying a >= pin in the installer's jq fallback. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
4788 lines
241 KiB
Bash
Executable File
4788 lines
241 KiB
Bash
Executable File
#!/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 <name> 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 <id> Codex model profile override. Otherwise reads Codex config.
|
|
--claude-model <id>
|
|
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 `<path>/sections/<f>.md`",
|
|
# optionally "relative to the installed `<skill>` 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 <<EOF
|
|
$(awk '{
|
|
line = $0; stop = index(line, "**STOP.**") ? 1 : 0
|
|
while (match(line, /Read `[^` ]*sections\/[A-Za-z0-9._-]+\.md`( relative to the installed (`[^`]+`\/?)+)?/)) {
|
|
m = substr(line, RSTART + 6, RLENGTH - 6); line = substr(line, RSTART + RLENGTH)
|
|
tok = m; sub(/`.*/, "", tok); names = ""
|
|
if (index(m, " relative to the installed ")) { names = substr(m, index(m, " relative to the installed ") + 27); gsub(/[`\/]+/, " ", names) }
|
|
print stop "|" tok "|" names
|
|
}
|
|
}' "$md")
|
|
EOF
|
|
done
|
|
if [ "$broken" -eq 0 ]; then
|
|
printf ' %s %s: %s; section links: %s checked, all resolve\n' "$host" "$scope" "$router" "$checked"
|
|
else
|
|
any_broken=1
|
|
printf ' %s %s: %s; section links: %s of %s broken (first: %s). %s\n' "$host" "$scope" "$router" "$broken" "$checked" "$first" "$fix"
|
|
fi
|
|
done < "$reg"
|
|
[ "$any_broken" -eq 0 ]
|
|
}
|
|
for _arg in "$@"; do
|
|
case "$_arg" in
|
|
--status)
|
|
_STATUS_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
|
. "$_STATUS_DIR/bin/gstack-state-root.sh" && . "$_STATUS_DIR/bin/gstack-install-registry.sh" \
|
|
&& . "$_STATUS_DIR/bin/gstack-render-claude.sh" && . "$_STATUS_DIR/bin/gstack-codex-status.sh" || exit 1
|
|
gstack_state_root_select; GSTACK_STATE_ROOT="$_gstack_sr_root"
|
|
gstack_install_status
|
|
_status_rc=0
|
|
_status_install_checks || _status_rc=1
|
|
gstack_claude_overlay_status
|
|
gstack_codex_status_line "$_STATUS_DIR" "$GSTACK_STATE_ROOT"
|
|
echo "Readiness check: $(cd "$(dirname "$0")" && pwd)/bin/gstack-doctor"
|
|
[ "$_status_rc" -eq 0 ] || echo "gstack: an install has broken section links (see Install checks above); exit 1" >&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 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 (<host>, ...)` line from summary
|
|
# rows on stdin (host<TAB>...<TAB>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 <<EOF
|
|
$(gstack_install_registry_rows)
|
|
EOF
|
|
_RF_REGISTERED_ROOTS="|$(gstack_install_registry_rows | awk -F '\t' '{ printf "%s|", $5 }')"
|
|
while IFS=' ' read -r _rf_host _rf_scope _rf_dest _rf_root; do
|
|
[ -n "$_rf_host" ] || continue
|
|
{ [ -e "$_rf_root" ] || [ -L "$_rf_root" ]; } || continue
|
|
case "$_RF_REGISTERED_ROOTS" in *"|$_rf_root|"*) continue ;; esac
|
|
_rf_src="$(_gstack_install_source "$_rf_root")"
|
|
[ -n "$_rf_src" ] || continue
|
|
if [ "$_rf_src" = "$SOURCE_GSTACK_DIR" ]; then
|
|
_rf_add "$_rf_host" "$_rf_scope" "$_rf_root"
|
|
elif [ "$_rf_scope" = global ]; then
|
|
_RF_SKIPPED="$_RF_SKIPPED$_rf_host $_rf_scope $_rf_src $_rf_dest - $(cat "$_rf_src/VERSION" 2>/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 <<EOF
|
|
$(gstack_install_known_roots)
|
|
EOF
|
|
case "
|
|
$_RF_TARGETS" in
|
|
*"
|
|
$HOST "*) ;;
|
|
*) if [ "$HOST_FLAG_SET" -eq 1 ] || [ -z "$_RF_TARGETS" ]; then _rf_add "$HOST" global -; fi ;;
|
|
esac
|
|
# The children read claude_overlay_model, so the parent saves it first.
|
|
if [ "$CLAUDE_MODEL_SET" -eq 1 ]; then
|
|
case "
|
|
$_RF_TARGETS" in
|
|
*"
|
|
claude "*) _persist_claude_model ;;
|
|
*) echo "Error: --claude-model applies to Claude installs, and no Claude install is registered for this checkout. Nothing was installed or changed. Install Claude first: ./setup --claude-model $CLAUDE_MODEL_ARG" >&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[@]}" </dev/null; then
|
|
_RF_FAILED=1
|
|
if [ "$(wc -l < "$_RF_SUMMARY" | tr -d ' ')" = "$_rf_before" ]; then
|
|
printf '%s %s %s %s - %s failed setup exited non-zero before installing; its previous install was left in place. Retry: cd %s && ./setup --host %s
|
|
' \
|
|
"$_rf_host" "$_rf_scope" "$SOURCE_GSTACK_DIR" "${_rf_root:--}" "$SETUP_VERSION" "$SOURCE_GSTACK_DIR" "$_rf_host" >> "$_RF_SUMMARY"
|
|
fi
|
|
fi
|
|
done <<EOF
|
|
$_RF_TARGETS
|
|
EOF
|
|
grep -q ' failed ' "$_RF_SUMMARY" && _RF_FAILED=1
|
|
echo ""
|
|
if [ "$_RF_FAILED" -eq 1 ]; then
|
|
echo "Upgrade summary (NOT every install was refreshed; see failed rows):"
|
|
else
|
|
echo "Upgrade summary:"
|
|
fi
|
|
gstack_install_registry_render < "$_RF_SUMMARY"
|
|
_rf_disabled="$(gstack_disabled_normalize "$("$SOURCE_GSTACK_DIR/bin/gstack-config" get disabled_skills 2>/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/<path>" (#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>/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 (hash<TAB>path); 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/<ts>/
|
|
# 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-<branch>, 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
|
|
*'<!-- AUTO-GENERATED from '*'<!-- Regenerate: bun run gen:skill-docs -->'*) 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 '<!-- AUTO-GENERATED from' "$dst/SKILL.md" 2>/dev/null
|
|
}
|
|
|
|
# ─── Helper: prune renders of skills whose source is gone ────────────────────
|
|
# gen-skill-docs prunes its own render tree (.agents/.factory/.opencode/.cursor
|
|
# skills) at the end of every run; this helper covers what the generator cannot
|
|
# reach: the HOST skills dirs that link to or copy those renders, and a render
|
|
# tree left behind by an older generator. Candidates are gstack-* entries in the
|
|
# render tree AND in each host dir, so a host entry is cleaned even when the
|
|
# generator already removed its render.
|
|
# $1 = install root, $2 = render tree, $3.. = host skills dirs (optional).
|
|
# Ownership (#2119): a real render dir goes; a host symlink goes only when it
|
|
# RESOLVES into gstack; a bannered REAL host dir is cleaned through
|
|
# _cleanup_weak_dir (our SKILL.md, marker and links only) rather than deleted
|
|
# whole; anything else is a user's own entry and is left alone. Symlinks in the
|
|
# render tree are skipped: `rm -rf` on a slash-terminated link empties its
|
|
# TARGET. Pinned by test/setup-prune-stale-generated.test.ts.
|
|
_skill_source_exists() {
|
|
# $1 = install root, $2 = rendered name (gstack-<x>). gen-skill-docs names a
|
|
# render from the template's frontmatter `name:` when that differs from the
|
|
# directory, so both the directory and every frontmatter name count.
|
|
local g="$1" n="$2" base="${2#gstack-}"
|
|
[ -f "$g/$base/SKILL.md.tmpl" ] && return 0
|
|
[ -f "$g/$n/SKILL.md.tmpl" ] && return 0
|
|
grep -qsE "^name:[[:space:]]*(gstack-)?${base}[[:space:]]*$" "$g"/*/SKILL.md.tmpl 2>/dev/null && return 0
|
|
return 1
|
|
}
|
|
_prune_stale_generated() {
|
|
local gstack_dir="$1" gen_dir="$2" d n names="" host dest gen_real
|
|
shift 2
|
|
for d in "$gen_dir"/gstack-*; do
|
|
[ -d "$d" ] && [ ! -L "$d" ] || continue
|
|
names="$names ${d##*/}"
|
|
done
|
|
for host in "$@"; do
|
|
[ -n "$host" ] && [ -d "$host" ] || continue
|
|
for d in "$host"/gstack-*; do
|
|
{ [ -e "$d" ] || [ -L "$d" ]; } || continue
|
|
names="$names ${d##*/}"
|
|
done
|
|
done
|
|
[ -n "$names" ] || return 0
|
|
for n in $(printf '%s\n' $names | sort -u); do
|
|
# The rename helper owns replacement-before-retirement, including failures.
|
|
if [ "$n" = "gstack-claude" ] && [ "${GSTACK_DEFER_CLAUDE_RENAME_PRUNE:-0}" = "1" ]; then continue; fi
|
|
_skill_source_exists "$gstack_dir" "$n" && continue
|
|
if [ -d "$gen_dir/$n" ] && [ ! -L "$gen_dir/$n" ]; then rm -rf "$gen_dir/$n"; fi
|
|
for host in "$@"; do
|
|
[ -n "$host" ] && { [ -e "$host/$n" ] || [ -L "$host/$n" ]; } || continue
|
|
_owned_for_windows_refresh "$host/$n" || continue
|
|
if [ -L "$host/$n" ]; then
|
|
# Strong proof only when the link resolves into gstack: the render tree
|
|
# we were handed (a dangling link into it still names that path) or
|
|
# any gstack path per _gstack_target_is_ours.
|
|
dest="$(_gstack_link_target_abs "$host/$n")" || continue
|
|
gen_real="$(cd "$gen_dir" 2>/dev/null && pwd -P || printf '%s' "$gen_dir")"
|
|
case "$dest" in
|
|
"$gen_dir"/*|"$gen_real"/*) rm -f "$host/$n" ;;
|
|
*) _gstack_target_is_ours "$dest" "$gstack_dir" && rm -f "$host/$n" ;;
|
|
esac
|
|
elif [ ! -d "$host/$n" ]; then
|
|
rm -f "$host/$n"
|
|
else
|
|
_cleanup_weak_dir "$host/$n" "$gstack_dir"
|
|
fi
|
|
done
|
|
log " pruned retired skill: $n"
|
|
done
|
|
}
|
|
|
|
# A sidecar/runtime ROOT (…/skills/gstack) is provably USER-owned when it is
|
|
# a real dir whose SKILL.md exists but lacks the generated banner — a
|
|
# hand-written skill squatting on the canonical name. The sidecar installers
|
|
# skip it entirely rather than write into (or wipe) someone else's skill.
|
|
# A root with NO SKILL.md stays presumed ours: it is the documented gstack
|
|
# install location and old/partial installs legitimately look like that.
|
|
_sidecar_root_user_owned() {
|
|
local root="$1"
|
|
[ -d "$root" ] || return 1
|
|
[ -L "$root" ] && return 1
|
|
[ -f "$root/SKILL.md" ] || return 1
|
|
! grep -q '<!-- AUTO-GENERATED from' "$root/SKILL.md" 2>/dev/null
|
|
}
|
|
|
|
# Swap a freshly-rendered tmp dir into the live render location (#2569
|
|
# hardening). Installed skills SYMLINK into the live dir, so it is only ever
|
|
# replaced AFTER a successful render — a failed render leaves the previous
|
|
# render (and every link into it) fully intact. Keep in sync with
|
|
# bin/gstack-config's _swap_in_render (same contract, both pinned by
|
|
# test/user-render-out-dir-install.test.ts).
|
|
_swap_in_render() {
|
|
local render_dir="$1" render_tmp="$2"
|
|
local render_old="$render_dir.old.$$"
|
|
rm -rf "$render_old"
|
|
if [ -e "$render_dir" ] || [ -L "$render_dir" ]; then mv "$render_dir" "$render_old"; fi
|
|
mv "$render_tmp" "$render_dir"
|
|
rm -rf "$render_old"
|
|
}
|
|
|
|
# ─── Per-install render (install-context render contract, #1882) ────────────
|
|
# Committed skills call the host's default install (~/.claude/skills/gstack,
|
|
# ~/.codex/skills/gstack). An install anywhere else (a renamed checkout, a
|
|
# project-vendored Claude copy, CLAUDE_CONFIG_DIR, CODEX_HOME) serves skills
|
|
# rendered for ITS root, out of tree in $GSTACK_STATE_ROOT/render/installs/
|
|
# <host>-<id>/, so each skill's literal paths call this install and two
|
|
# installs never overwrite each other. Built in a temp dir, validated, swapped
|
|
# in; callers link to it only after success (a failed render keeps the previous
|
|
# one). Worktree-isolated Claude Code refuses a command path containing
|
|
# whitespace or shell metacharacters in any quoting, so such a root is named
|
|
# through a plain alias symlink beside the render.
|
|
# _render_install HOST ROOT — sets _INSTALL_RENDER (empty = committed render).
|
|
_INSTALL_RENDER=""
|
|
_render_install() {
|
|
local host="$1" root="$2" default render named tmp out rc want got
|
|
_INSTALL_RENDER=""
|
|
case "$host" in
|
|
claude) default="$HOME/.claude/skills/gstack" ;;
|
|
codex) default="$HOME/.codex/skills/gstack" ;;
|
|
*) return 0 ;;
|
|
esac
|
|
[ "$root" = "$default" ] && return 0
|
|
# The default reached through another spelling (a symlinked parent) is the default.
|
|
got="$(cd "$(dirname "$root")" 2>/dev/null && pwd -P)" || got=""
|
|
[ -n "$got" ] && [ "$(basename "$root")" = gstack ] \
|
|
&& [ "$got" = "$(cd "$(dirname "$default")" 2>/dev/null && pwd -P)" ] && return 0
|
|
render="$(gstack_install_render_dir "$host" "$root")"
|
|
named="$root"
|
|
if ! printf '%s' "$named" | grep -Eq '^/[A-Za-z0-9_.@+/-]*$'; then
|
|
named="$render.root"
|
|
if [ "$IS_WINDOWS" -eq 1 ] || ! printf '%s' "$named" | grep -Eq '^/[A-Za-z0-9_.@+/-]*$'; then
|
|
echo " warning: the $host install root ($root) is not a plain path and no plain alias is available here (state root: $GSTACK_STATE_ROOT); its skills keep the default-install paths" >&2
|
|
return 1
|
|
fi
|
|
mkdir -p "$(dirname "$named")" && _link_or_copy "$root" "$named" || return 1
|
|
fi
|
|
tmp="$render.tmp.$$"
|
|
rm -rf "$tmp"
|
|
rc=0
|
|
if [ "$host" = claude ]; then
|
|
out="$(cd "$SOURCE_GSTACK_DIR" && bun_cmd run gen:skill-docs:user --host claude --out-dir "$tmp" --link-root "$render" --install-root "$named" --model "$_GSTACK_OVERLAY" ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"} 2>&1)" || rc=$?
|
|
want="$(find "$SOURCE_GSTACK_DIR" -mindepth 2 -maxdepth 2 -name SKILL.md | wc -l)"
|
|
got="$(find "$tmp" -mindepth 2 -maxdepth 2 -name SKILL.md 2>/dev/null | wc -l)"
|
|
[ "$rc" -ne 0 ] || { [ "$want" -eq "$got" ] && grep -qF "$named/bin/gstack-skill-start --skill" "$tmp/SKILL.md" 2>/dev/null; } \
|
|
|| { rc=1; out="render incomplete: $got of $want skills, or a start line that does not name $named"; }
|
|
[ "$rc" -ne 0 ] || "$SOURCE_GSTACK_DIR/bin/gstack-patch-names" "$tmp" "$SKILL_PREFIX" >/dev/null || rc=$?
|
|
else
|
|
out="$(cd "$SOURCE_GSTACK_DIR" && bun_cmd run gen:skill-docs --host codex --model "$CODEX_GENERATION_MODEL" --install-root "$named" --out-dir "$tmp" --link-root "$render" ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"} 2>&1)" || rc=$?
|
|
want="$(find "$SOURCE_GSTACK_DIR/.agents/skills" -mindepth 2 -maxdepth 2 -name SKILL.md 2>/dev/null | wc -l)"
|
|
got="$(find "$tmp/.agents/skills" -mindepth 2 -maxdepth 2 -name SKILL.md 2>/dev/null | wc -l)"
|
|
[ "$rc" -ne 0 ] || { [ "$got" -gt 0 ] && { [ "$want" -eq 0 ] || [ "$want" -eq "$got" ]; } && grep -qF "GSTACK_ROOT=\"$named\"" "$tmp/.agents/skills/gstack-review/SKILL.md" 2>/dev/null; } \
|
|
|| { rc=1; out="render incomplete: $got of $want skills, or a runtime root that does not name $named"; }
|
|
fi
|
|
if [ "$rc" -ne 0 ]; then
|
|
rm -rf "$tmp"
|
|
echo " warning: could not render $host skills for the install at $root (exit $rc); $( [ -d "$render" ] && echo 'the previous render stays in use' || echo 'they keep the default-install paths')" >&2
|
|
printf '%s\n' "$out" | tail -3 | sed 's/^/ /' >&2
|
|
[ -d "$render" ] || return 1
|
|
else
|
|
gstack_render_lock "$render"
|
|
_swap_in_render "$render" "$tmp"
|
|
gstack_render_unlock "$render"
|
|
[ "$host" != claude ] || gstack_claude_render_settle "$SOURCE_GSTACK_DIR" "$render" "${_GBRAIN_RENDER_STATE:-unknown}" rendered 0
|
|
fi
|
|
_INSTALL_RENDER="$render"
|
|
}
|
|
# Claude: the arm serves the render through GSTACK_USER_RENDER_DIR, which
|
|
# linking, relink and the ownership checks read.
|
|
_CLAUDE_INSTALL_RENDER=""
|
|
_render_claude_install() {
|
|
_CLAUDE_INSTALL_RENDER=""
|
|
_render_install claude "$1" || return 1
|
|
[ -n "$_INSTALL_RENDER" ] || return 0
|
|
_CLAUDE_INSTALL_RENDER="$_INSTALL_RENDER"
|
|
export GSTACK_USER_RENDER_DIR="$_INSTALL_RENDER"
|
|
}
|
|
|
|
_WINDOWS_COPY_NOTE_PRINTED=0
|
|
_print_windows_copy_note_once() {
|
|
if [ "$IS_WINDOWS" -eq 1 ] && [ "$_WINDOWS_COPY_NOTE_PRINTED" -eq 0 ]; then
|
|
echo " note: Windows install uses file copies (no Developer Mode required). Re-run ./setup after every 'git pull' to refresh skill files."
|
|
_WINDOWS_COPY_NOTE_PRINTED=1
|
|
fi
|
|
}
|
|
|
|
|
|
# ─── Install rows: registry publication + one summary row per install ───────
|
|
# Rows publish to the registry only after a host arm finished activating
|
|
# (bin/gstack-install-registry.sh). The summary uses the same renderer as
|
|
# ./setup --status and /gstack-upgrade. A setup that dies inside a host arm
|
|
# reports that host as failed from the EXIT trap: "previous install was left
|
|
# in place" with the retry command only when the checkout, the skills
|
|
# directory and the runtime root still exist; a refusal (#3084) with the move
|
|
# commands; otherwise the fresh-clone reinstall.
|
|
_SETUP_ROWS=""
|
|
_SETUP_ARM=""
|
|
_SETUP_SUMMARY_DONE=0
|
|
_setup_row() {
|
|
_SETUP_ROWS="$_SETUP_ROWS$1 $2 $SOURCE_GSTACK_DIR $3 $4 $5 $6 ${7:-}
|
|
"
|
|
}
|
|
_setup_retry_command() {
|
|
printf 'cd %s && ./setup --host %s' "$SOURCE_GSTACK_DIR" "$1"
|
|
}
|
|
_setup_arm_begin() { _SETUP_ARM="$1 $2 $3"; _SETUP_ARM_ROOT=""; }
|
|
_setup_arm_publish() {
|
|
local host="$1" scope="$2" project="$3" dest="$4" root="$5" prefix="$6" render="${7:-committed}" old
|
|
old="$(gstack_install_registry_rows | awk -F '\t' -v h="$host" -v d="$dest" '$1 == h && $4 == d { v = $7 } END { print v }')"
|
|
_SETUP_ARM=""
|
|
if ! gstack_install_registry_upsert "$host" "$scope" "$project" "$dest" "$root" "$SOURCE_GSTACK_DIR" "$SETUP_VERSION" "$prefix" "$render"; then
|
|
_setup_row "$host" "$scope" "$dest" "${old:--}" "$SETUP_VERSION" failed "installed, but the registry write failed, so upgrades will not refresh it; retry: $(_setup_retry_command "$host")"
|
|
return 0
|
|
fi
|
|
if [ -z "$old" ]; then
|
|
_setup_row "$host" "$scope" "$dest" - "$SETUP_VERSION" installed
|
|
elif [ "$old" = "$SETUP_VERSION" ]; then
|
|
_setup_row "$host" "$scope" "$dest" "$old" "$SETUP_VERSION" unchanged
|
|
else
|
|
_setup_row "$host" "$scope" "$dest" "$old" "$SETUP_VERSION" updated
|
|
fi
|
|
}
|
|
_setup_print_summary() {
|
|
[ "$_SETUP_SUMMARY_DONE" -eq 0 ] || return 0
|
|
_SETUP_SUMMARY_DONE=1
|
|
[ -n "$_SETUP_ROWS" ] || return 0
|
|
if [ -n "${GSTACK_SETUP_SUMMARY_FILE:-}" ]; then
|
|
printf '%s' "$_SETUP_ROWS" >> "$GSTACK_SETUP_SUMMARY_FILE"
|
|
return 0
|
|
fi
|
|
if [ "$QUIET" -eq 1 ]; then
|
|
case "$_SETUP_ROWS" in *" failed "*) ;; *) return 0 ;; esac
|
|
fi
|
|
echo ""
|
|
echo "Install summary:"
|
|
printf '%s' "$_SETUP_ROWS" | gstack_install_registry_render
|
|
if [ "$_DISABLED_SKILLS" != " " ] && [ -n "${_DISABLED_SKILLS:-}" ]; then
|
|
echo " disabled skills (not registered on any host):$_DISABLED_SKILLS"
|
|
echo " re-enable: gstack-config set disabled_skills \"\""
|
|
fi
|
|
}
|
|
_setup_exit_summary() {
|
|
local rc="$1" host scope dest
|
|
[ "$_SETUP_SUMMARY_DONE" -eq 0 ] || return 0
|
|
if [ "$rc" -ne 0 ] && [ -n "$_SETUP_ARM" ]; then
|
|
IFS=' ' read -r host scope dest <<EOF
|
|
$_SETUP_ARM
|
|
EOF
|
|
if [ -n "$_SETUP_REFUSED" ]; then
|
|
_setup_row "$host" "$scope" "$dest" - "$SETUP_VERSION" failed "$_SETUP_REFUSED"
|
|
elif [ -f "$SOURCE_GSTACK_DIR/setup" ] && [ -d "$dest" ] \
|
|
&& { [ -z "${_SETUP_ARM_ROOT:-}" ] || [ -e "$_SETUP_ARM_ROOT" ] || [ -L "$_SETUP_ARM_ROOT" ]; }; then
|
|
_setup_row "$host" "$scope" "$dest" - "$SETUP_VERSION" failed "setup stopped (exit $rc) while installing $host; its previous install was left in place. Retry: $(_setup_retry_command "$host")"
|
|
else
|
|
_setup_row "$host" "$scope" "$dest" - "$SETUP_VERSION" failed "setup stopped (exit $rc) while installing $host, and its install could not be verified (checkout $SOURCE_GSTACK_DIR, runtime root ${_SETUP_ARM_ROOT:-unknown}). Reinstall from a fresh clone: git clone --depth 1 https://github.com/garrytan/gstack.git ~/gstack && cd ~/gstack && ./setup --host $host"
|
|
fi
|
|
fi
|
|
[ "$rc" -ne 0 ] || [ -n "$_SETUP_ROWS" ] || return 0
|
|
_setup_print_summary >&2
|
|
}
|
|
|
|
# Aside (aside.com, macOS 15+) is the primary browser; the compiled browse
|
|
# binary is the fallback. Best-effort hint only — no probe of a running app.
|
|
# Reads _PW_FAIL_REASON (the best-effort Chromium bootstrap in # 2 records why
|
|
# the bundled browser is unusable) so the line never promises a fallback
|
|
# browser that cannot launch. GSTACK_SKIP_ASIDE=1 (the library's and the
|
|
# skills' opt-out) counts as Aside absent. Pinned by test/setup-browser-hint.test.ts.
|
|
_browser_hint() {
|
|
if [ "${GSTACK_SKIP_ASIDE:-}" != "1" ] && command -v aside >/dev/null 2>&1; then
|
|
if [ -n "${_PW_FAIL_REASON:-}" ]; then
|
|
log " browser: Aside (primary) — gstack browser fallback unavailable (Chromium bootstrap: ${_PW_FAIL_REASON})"
|
|
else
|
|
log " browser: Aside (primary) — gstack browser is the fallback"
|
|
fi
|
|
elif [ "${_PW_FAIL_REASON:-}" = "skipped" ]; then
|
|
# An explicit opt-out (GSTACK_SKIP_PLAYWRIGHT=1) is a request, not a failure — same wording as the summary.
|
|
log " browser: none available — Chromium install skipped by request (GSTACK_SKIP_PLAYWRIGHT=1); install Aside (aside.com, macOS 15+) or re-run ./setup without the flag"
|
|
elif [ -n "${_PW_FAIL_REASON:-}" ]; then
|
|
log " browser: none available — Chromium bootstrap: ${_PW_FAIL_REASON}; install Aside (aside.com, macOS 15+) or fix the bootstrap and re-run ./setup"
|
|
else
|
|
log " browser: gstack browser (fallback). Install Aside for the primary path: aside.com (macOS 15+)"
|
|
fi
|
|
}
|
|
|
|
_CODEX_PREFLIGHT_SELECTED=0
|
|
if [ "$LOCAL_INSTALL" -eq 0 ]; then
|
|
if [ "$HOST" = codex ] || { [ "$HOST" = auto ] && command -v codex >/dev/null 2>&1; }; then
|
|
_CODEX_PREFLIGHT_SELECTED=1
|
|
fi
|
|
fi
|
|
bun "$SOURCE_GSTACK_DIR/scripts/preflight-codex-overlap.ts" \
|
|
--source "$SOURCE_GSTACK_DIR" --namespace "$CODEX_SKILLS" \
|
|
--selected "$_CODEX_PREFLIGHT_SELECTED" --local "$CODEX_REPO_LOCAL" \
|
|
--windows "$IS_WINDOWS" --relocation "$HOME/.gstack/repos/gstack" || exit 1
|
|
|
|
# ─── Resolve skill prefix preference ─────────────────────────
|
|
# Priority: CLI flag > saved config > interactive prompt (or flat default for non-TTY)
|
|
GSTACK_CONFIG="$SOURCE_GSTACK_DIR/bin/gstack-config"
|
|
export GSTACK_SETUP_RUNNING=1 # Prevent gstack-config post-set hook from triggering relink mid-setup
|
|
if [ "$SKILL_PREFIX_FLAG" -eq 0 ]; then
|
|
_saved_prefix="$("$GSTACK_CONFIG" get skill_prefix 2>/dev/null || true)"
|
|
if [ "$_saved_prefix" = "true" ]; then
|
|
SKILL_PREFIX=1
|
|
elif [ "$_saved_prefix" = "false" ]; then
|
|
SKILL_PREFIX=0
|
|
else
|
|
# No saved preference — prompt interactively (or default flat for non-TTY/quiet)
|
|
if [ "$QUIET" -eq 1 ]; then
|
|
SKILL_PREFIX=0
|
|
elif [ -t 0 ]; then
|
|
echo ""
|
|
echo "Skill naming: how should gstack skills appear?"
|
|
echo ""
|
|
echo " 1) Short names: /qa, /ship, /review"
|
|
echo " Recommended. Clean and fast to type."
|
|
echo ""
|
|
echo " 2) Namespaced: /gstack-qa, /gstack-ship, /gstack-review"
|
|
echo " Use this if you run other skill packs alongside gstack to avoid conflicts."
|
|
echo ""
|
|
printf "Choice [1/2] (default: 1, auto-selects in 10s): "
|
|
read -t 10 -r _prefix_choice </dev/tty 2>/dev/null || _prefix_choice=""
|
|
case "$_prefix_choice" in
|
|
2) SKILL_PREFIX=1 ;;
|
|
*) SKILL_PREFIX=0 ;;
|
|
esac
|
|
else
|
|
SKILL_PREFIX=0
|
|
fi
|
|
# Save the choice for future runs
|
|
"$GSTACK_CONFIG" set skill_prefix "$([ "$SKILL_PREFIX" -eq 1 ] && echo true || echo false)" 2>/dev/null || true
|
|
fi
|
|
else
|
|
# Flag was passed explicitly — persist the choice
|
|
"$GSTACK_CONFIG" set skill_prefix "$([ "$SKILL_PREFIX" -eq 1 ] && echo true || echo false)" 2>/dev/null || true
|
|
fi
|
|
|
|
# ─── disabled_skills (gstack-config set disabled_skills a,b) ─────────────────
|
|
# A disabled skill gets no discovery entry on any host (an existing gstack-owned
|
|
# entry is removed); its runtime files in the checkout stay, so skills that call
|
|
# it keep working. Missing or empty = every skill enabled (#1206). Linkers test
|
|
# membership inline with "${_DISABLED_SKILLS:- }", so a linker extracted on its
|
|
# own (tests) behaves as "nothing disabled".
|
|
_DISABLED_SKILLS="$(gstack_disabled_normalize "$("$GSTACK_CONFIG" get disabled_skills 2>/dev/null || true)")"
|
|
# Every render of the router (committed-path renders of env-var hosts and the
|
|
# per-install renders) omits disabled skills (C8); the default Claude router is
|
|
# rendered in place by gstack-relink.
|
|
_DISABLED_CSV="$(printf '%s' "$_DISABLED_SKILLS" | xargs | tr ' ' ',')"
|
|
# _remove_disabled_host_entry ENTRY — drop a non-Claude host's gstack-owned
|
|
# entry for a disabled skill (a link, or a generated copy); anything else stays.
|
|
_remove_disabled_host_entry() {
|
|
{ [ -e "$1" ] || [ -L "$1" ]; } || return 0
|
|
if [ -L "$1" ] || { [ -d "$1" ] && _owned_for_windows_refresh "$1"; }; then
|
|
rm -rf "$1"
|
|
else
|
|
echo " kept $1: disabled in gstack config, but not gstack-managed" >&2
|
|
fi
|
|
}
|
|
|
|
# --local: install to .claude/skills/ in the current working directory (deprecated)
|
|
if [ "$LOCAL_INSTALL" -eq 1 ]; then
|
|
echo "Warning: --local is deprecated. Use global install + --team instead." >&2
|
|
echo " See: https://github.com/garrytan/gstack#team-mode" >&2
|
|
if [ "$HOST" = "codex" ] || [ "$HOST" = "copilot" ]; then
|
|
echo "Error: --local is only supported for Claude Code (not $HOST)." >&2
|
|
exit 1
|
|
fi
|
|
INSTALL_SKILLS_DIR="$(pwd)/.claude/skills"
|
|
mkdir -p "$INSTALL_SKILLS_DIR"
|
|
HOST="claude"
|
|
INSTALL_CODEX=0
|
|
fi
|
|
|
|
# For auto: detect which agents are installed
|
|
INSTALL_CLAUDE=0
|
|
INSTALL_CODEX=0
|
|
INSTALL_KIRO=0
|
|
INSTALL_FACTORY=0
|
|
INSTALL_OPENCODE=0
|
|
INSTALL_CURSOR=0
|
|
INSTALL_COPILOT=0
|
|
INSTALL_AGY=0
|
|
if [ "$HOST" = "auto" ]; then
|
|
command -v claude >/dev/null 2>&1 && INSTALL_CLAUDE=1
|
|
command -v codex >/dev/null 2>&1 && INSTALL_CODEX=1
|
|
command -v kiro-cli >/dev/null 2>&1 && INSTALL_KIRO=1
|
|
command -v droid >/dev/null 2>&1 && INSTALL_FACTORY=1
|
|
command -v opencode >/dev/null 2>&1 && INSTALL_OPENCODE=1
|
|
# Cursor's `cursor` CLI shim isn't always on PATH; ~/.cursor is the
|
|
# reliable footprint of an installed Cursor IDE.
|
|
command -v cursor >/dev/null 2>&1 && INSTALL_CURSOR=1
|
|
[ -d "$HOME/.cursor" ] && INSTALL_CURSOR=1
|
|
command -v copilot >/dev/null 2>&1 && INSTALL_COPILOT=1
|
|
# agy's installer puts the binary in ~/.local/bin, which may not be on PATH;
|
|
# ~/.gemini/antigravity-cli is agy's own config dir. A bare ~/.gemini is
|
|
# Gemini CLI's and never selects Antigravity.
|
|
command -v agy >/dev/null 2>&1 && INSTALL_AGY=1
|
|
[ -d "$HOME/.gemini/antigravity-cli" ] && INSTALL_AGY=1
|
|
# If none found, default to claude
|
|
if [ "$INSTALL_CLAUDE" -eq 0 ] && [ "$INSTALL_CODEX" -eq 0 ] && [ "$INSTALL_KIRO" -eq 0 ] && [ "$INSTALL_FACTORY" -eq 0 ] && [ "$INSTALL_OPENCODE" -eq 0 ] && [ "$INSTALL_CURSOR" -eq 0 ] && [ "$INSTALL_COPILOT" -eq 0 ] && [ "$INSTALL_AGY" -eq 0 ]; then
|
|
INSTALL_CLAUDE=1
|
|
fi
|
|
elif [ "$HOST" = "claude" ]; then
|
|
INSTALL_CLAUDE=1
|
|
elif [ "$HOST" = "codex" ]; then
|
|
INSTALL_CODEX=1
|
|
elif [ "$HOST" = "kiro" ]; then
|
|
INSTALL_KIRO=1
|
|
elif [ "$HOST" = "factory" ]; then
|
|
INSTALL_FACTORY=1
|
|
elif [ "$HOST" = "opencode" ]; then
|
|
INSTALL_OPENCODE=1
|
|
elif [ "$HOST" = "cursor" ]; then
|
|
INSTALL_CURSOR=1
|
|
elif [ "$HOST" = "copilot" ]; then
|
|
INSTALL_COPILOT=1
|
|
elif [ "$HOST" = "agy" ]; then
|
|
INSTALL_AGY=1
|
|
fi
|
|
|
|
# A host that passes --host validation but sets no INSTALL_* flag would
|
|
# silently configure nothing and exit 0 (the #2361 slate failure class).
|
|
# Fail loudly if a future host lands in the accept-list without a dispatch arm.
|
|
if [ "$HOST" != "auto" ] && [ "$INSTALL_CLAUDE" -eq 0 ] && [ "$INSTALL_CODEX" -eq 0 ] && [ "$INSTALL_KIRO" -eq 0 ] && [ "$INSTALL_FACTORY" -eq 0 ] && [ "$INSTALL_OPENCODE" -eq 0 ] && [ "$INSTALL_CURSOR" -eq 0 ] && [ "$INSTALL_COPILOT" -eq 0 ] && [ "$INSTALL_AGY" -eq 0 ]; then
|
|
echo "Error: no install arm exists for host '$HOST' — it passed --host validation but sets no INSTALL_* flag, so setup would configure nothing and exit 0. This is a setup bug. Valid install targets: claude, codex, kiro, factory, opencode, cursor, copilot, agy (informational: slate, openclaw, hermes, gbrain)." >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Copilot reads personal skills from $COPILOT_HOME/skills when it is set, but
|
|
# rendered skills use literal ~/.copilot paths. Refuse rather than install
|
|
# skills Copilot will never discover.
|
|
if [ "$INSTALL_COPILOT" -eq 1 ] && [ -n "${COPILOT_HOME:-}" ] && [ "${COPILOT_HOME%/}" != "$HOME/.copilot" ]; then
|
|
echo "Error: COPILOT_HOME=$COPILOT_HOME is not supported yet — nothing was installed or changed. gstack installs Copilot skills to ~/.copilot/skills. Unset COPILOT_HOME for setup and Copilot sessions, or use ./setup --host claude." >&2
|
|
exit 1
|
|
fi
|
|
|
|
if [ "$MODEL_OVERRIDE_SET" -eq 1 ] && [ "$INSTALL_CODEX" -eq 0 ]; then
|
|
echo "Error: --model is supported only when Codex is selected (--host codex or --host auto with Codex installed). For the Claude overlay, use --claude-model <id>." >&2
|
|
exit 1
|
|
fi
|
|
if [ "$CLAUDE_MODEL_SET" -eq 1 ] && [ "$INSTALL_CLAUDE" -eq 0 ]; then
|
|
echo "Error: --claude-model is supported only when Claude is selected (the default host, --host claude, or --host auto with Claude Code installed). Nothing was installed or changed." >&2
|
|
exit 1
|
|
fi
|
|
# Before anything is written: refuse a host whose runtime root is, or
|
|
# contains, this checkout (#3084). _activate_runtime_root checks again; Kiro
|
|
# writes into its root in place, so it is refused here too.
|
|
for _rr in "$INSTALL_KIRO kiro $KIRO_SKILLS/gstack" "$INSTALL_FACTORY factory $FACTORY_GSTACK" "$INSTALL_OPENCODE opencode $OPENCODE_GSTACK" \
|
|
"$INSTALL_CURSOR cursor $CURSOR_GSTACK" "$INSTALL_COPILOT copilot $COPILOT_GSTACK" "$INSTALL_AGY agy $AGY_GSTACK"; do
|
|
[ "${_rr%% *}" -eq 1 ] || continue
|
|
_rr=${_rr#* }
|
|
_runtime_root_refused "${_rr%% *}" "${_rr#* }" && exit 1
|
|
done
|
|
_persist_claude_model
|
|
|
|
migrate_direct_codex_install() {
|
|
local gstack_dir="$1"
|
|
local codex_gstack="$2"
|
|
local migrated_dir="$HOME/.gstack/repos/gstack"
|
|
|
|
[ -L "$codex_gstack" ] && return 0
|
|
[ -d "$codex_gstack" ] && [ "$(cd "$codex_gstack" && pwd -P)" = "$gstack_dir" ] || return 0
|
|
|
|
mkdir -p "$(dirname "$migrated_dir")"
|
|
if [ -e "$migrated_dir" ] && [ "$migrated_dir" != "$gstack_dir" ]; then
|
|
echo "gstack setup failed: direct Codex install detected at $gstack_dir" >&2
|
|
echo "A migrated repo already exists at $migrated_dir; move one of them aside and rerun setup." >&2
|
|
exit 1
|
|
fi
|
|
|
|
log "Migrating direct Codex install to $migrated_dir to avoid duplicate skill discovery..."
|
|
mv "$gstack_dir" "$migrated_dir"
|
|
SOURCE_GSTACK_DIR="$migrated_dir"
|
|
INSTALL_GSTACK_DIR="$migrated_dir"
|
|
INSTALL_SKILLS_DIR="$(dirname "$INSTALL_GSTACK_DIR")"
|
|
BROWSE_BIN="$SOURCE_GSTACK_DIR/browse/dist/browse"
|
|
# Windows: binaries are compiled with .exe suffix (same as the top-level
|
|
# BROWSE_BIN assignment — this re-derivation must not drop the suffix).
|
|
if [ "$IS_WINDOWS" -eq 1 ]; then
|
|
BROWSE_BIN="$SOURCE_GSTACK_DIR/browse/dist/browse.exe"
|
|
fi
|
|
}
|
|
|
|
if [ "$INSTALL_CODEX" -eq 1 ] && [ "$CODEX_REPO_LOCAL" -eq 0 ]; then
|
|
migrate_direct_codex_install "$SOURCE_GSTACK_DIR" "$CODEX_GSTACK"
|
|
fi
|
|
|
|
SKILLS_BASENAME="$(basename "$INSTALL_SKILLS_DIR")"
|
|
SKILLS_PARENT_BASENAME="$(basename "$(dirname "$INSTALL_SKILLS_DIR")")"
|
|
|
|
# Kill an entire process tree rooted at $1, leaves first. Killing only the
|
|
# backgrounded subshell orphans the wedged node/bun -> Chromium probe
|
|
# processes underneath it — re-creating the #2136 stuck-process pile-up and
|
|
# potentially leaving Playwright cache locks held. macOS ships no setsid
|
|
# binary, so a portable group-kill isn't available; walk `pgrep -P` children
|
|
# depth-first instead (pgrep exists on macOS and Linux). Falls back to a
|
|
# /proc walk where available, otherwise a plain kill of the root pid.
|
|
_kill_tree() {
|
|
local pid="$1" child stat_file stat_line proc_state proc_parent proc_rest
|
|
if command -v pgrep >/dev/null 2>&1; then
|
|
for child in $(pgrep -P "$pid" 2>/dev/null); do
|
|
_kill_tree "$child"
|
|
done
|
|
elif [ -d /proc ]; then
|
|
# debian-slim and git-bash ship no pgrep: walk /proc for children. The
|
|
# comm field "(name)" may contain spaces and parens, so strip through the
|
|
# LAST closing paren (proc(5)) before reading the ppid (second field after it).
|
|
# A process may exit after glob expansion. One awk over every stat file
|
|
# aborts at that missing entry and never visits the remaining live children.
|
|
for stat_file in /proc/[0-9]*/stat; do
|
|
IFS= read -r stat_line 2>/dev/null < "$stat_file" || continue
|
|
child="${stat_line%% *}"
|
|
IFS=' ' read -r proc_state proc_parent proc_rest <<< "${stat_line##*) }"
|
|
if [ "$proc_parent" = "$pid" ]; then
|
|
_kill_tree "$child"
|
|
fi
|
|
done
|
|
fi
|
|
kill -9 "$pid" 2>/dev/null || true
|
|
}
|
|
|
|
# Deadline-bounded wait for a background probe. macOS ships no GNU timeout;
|
|
# poll the PID and SIGKILL the whole probe tree past the deadline. Returns
|
|
# the probe's exit code, or 124 on timeout.
|
|
_wait_with_deadline() {
|
|
local pid="$1" deadline_s="$2" waited=0
|
|
while kill -0 "$pid" 2>/dev/null; do
|
|
if [ "$waited" -ge "$deadline_s" ]; then
|
|
_kill_tree "$pid"
|
|
wait "$pid" 2>/dev/null || true
|
|
return 124
|
|
fi
|
|
sleep 1
|
|
waited=$((waited + 1))
|
|
done
|
|
wait "$pid"
|
|
}
|
|
|
|
ensure_playwright_browser() {
|
|
# #2136: fresh installs hung forever at this probe (macOS arm64) and
|
|
# re-runs stacked stuck process trees, so skills never got linked. Two
|
|
# fixes: prefer Node for the launch probe everywhere it exists (the
|
|
# bun --eval launch is the same pipe-bug family already worked around on
|
|
# Windows), and bound the probe with a 90s deadline — a wedged probe now
|
|
# reports failure (which routes to the install path) instead of hanging
|
|
# setup.
|
|
local probe_cmd
|
|
if command -v node >/dev/null 2>&1; then
|
|
probe_cmd='node -e "const { chromium } = require((process.cwd()) + \"/node_modules/playwright\"); (async () => { const b = await chromium.launch(); await b.close(); })().then(() => process.exit(0), error => { console.error(error.message); process.exit(1); })"'
|
|
elif [ "$IS_WINDOWS" -eq 1 ]; then
|
|
echo "gstack setup failed: Node.js is required on Windows" >&2
|
|
return 1
|
|
else
|
|
probe_cmd="bun --eval 'import { chromium } from \"playwright\"; const browser = await chromium.launch(); await browser.close();'"
|
|
fi
|
|
(
|
|
cd "$SOURCE_GSTACK_DIR"
|
|
eval "$probe_cmd"
|
|
) >/dev/null &
|
|
_wait_with_deadline $! 90
|
|
}
|
|
|
|
# P0 #2554: a macOS XProtect definition update can start SIGKILLing the
|
|
# Chromium revision the lockfile pins, which surfaces here as a failed launch
|
|
# probe. Clear com.apple.quarantine on the Playwright cache bundles ONLY —
|
|
# never a GSTACK_CHROMIUM_PATH bundle (that belongs to the wrapper/embedder;
|
|
# same scope contract as browse's probePoisonedChromiumBundle) — so the
|
|
# reinstall below produces a launchable browser. Best-effort and macOS-only.
|
|
_clear_playwright_quarantine() {
|
|
[ "$(uname -s)" = "Darwin" ] || return 0
|
|
local cache_root="${PLAYWRIGHT_BROWSERS_PATH:-$HOME/Library/Caches/ms-playwright}"
|
|
[ -d "$cache_root" ] || return 0
|
|
local d
|
|
for d in "$cache_root"/chromium-* "$cache_root"/chromium_headless_shell-*; do
|
|
[ -d "$d" ] || continue
|
|
echo " clearing com.apple.quarantine on $(basename "$d") (XProtect self-heal, #2554)" >&2
|
|
xattr -dr com.apple.quarantine "$d" 2>/dev/null || true
|
|
done
|
|
}
|
|
|
|
# Ensure a color-emoji font is installed (Linux only).
|
|
#
|
|
# Chromium renders emoji code points as .notdef "tofu" (▯) when no color-emoji
|
|
# font is installed. macOS ships "Apple Color Emoji" and Windows ships "Segoe UI
|
|
# Emoji", so they're fine out of the box. Most Linux distros and containers ship
|
|
# NO color-emoji font, which is why make-pdf output shows tofu in headers/tables
|
|
# that contain emoji. Install Noto Color Emoji to fix it.
|
|
#
|
|
# Best-effort: warn (don't fail) if we can't install — PDFs still generate, they
|
|
# just fall back to tofu for emoji as before. Skip entirely with
|
|
# GSTACK_SKIP_FONTS=1 (CI without sudo, managed machines, offline envs).
|
|
#
|
|
# Returns 0 and sets EMOJI_FONT_INSTALLED=1 when it actually installs a font.
|
|
EMOJI_FONT_INSTALLED=0
|
|
ensure_emoji_font() {
|
|
# macOS/Windows ship a color-emoji font; nothing to do.
|
|
[ "$(uname -s)" = "Linux" ] || return 0
|
|
[ "${GSTACK_SKIP_FONTS:-0}" = "1" ] && return 0
|
|
|
|
# Idempotency: a real COLOR emoji font that resolves for an actual emoji code
|
|
# point (U+1F600). `fc-list :lang=und-zsye` is too broad — it matches symbol
|
|
# and last-resort fallback fonts — so we use fc-match and require color=True.
|
|
if command -v fc-match >/dev/null 2>&1; then
|
|
if fc-match -f '%{family[0]}\t%{color}\n' ':lang=und-zsye:charset=1F600' 2>/dev/null | grep -qi 'True'; then
|
|
return 0
|
|
fi
|
|
fi
|
|
|
|
local sudo=""
|
|
if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then
|
|
# -n: never prompt. If a password is required we fail fast into the
|
|
# warn-not-fail path below instead of hanging a non-interactive setup.
|
|
sudo="sudo -n"
|
|
fi
|
|
|
|
# Every package-manager call is wrapped in `timeout` so a stuck dpkg/rpm lock
|
|
# or a wedged mirror fails fast into the warn path instead of hanging setup.
|
|
if command -v apt-get >/dev/null 2>&1; then
|
|
echo "Installing color-emoji font (fonts-noto-color-emoji) so make-pdf emoji render (set GSTACK_SKIP_FONTS=1 to skip)..."
|
|
DEBIAN_FRONTEND=noninteractive timeout 30 $sudo apt-get update -qq >/dev/null 2>&1 || true
|
|
DEBIAN_FRONTEND=noninteractive timeout 120 $sudo apt-get install -y -qq fonts-noto-color-emoji >/dev/null 2>&1 || return 1
|
|
elif command -v dnf >/dev/null 2>&1; then
|
|
echo "Installing color-emoji font (google-noto-color-emoji-fonts)..."
|
|
timeout 120 $sudo dnf install -y google-noto-color-emoji-fonts >/dev/null 2>&1 || return 1
|
|
elif command -v pacman >/dev/null 2>&1; then
|
|
echo "Installing color-emoji font (noto-fonts-emoji)..."
|
|
timeout 120 $sudo pacman -Sy --noconfirm noto-fonts-emoji >/dev/null 2>&1 || return 1
|
|
elif command -v apk >/dev/null 2>&1; then
|
|
echo "Installing color-emoji font (font-noto-emoji)..."
|
|
timeout 120 $sudo apk add --no-cache font-noto-emoji >/dev/null 2>&1 || return 1
|
|
else
|
|
return 1
|
|
fi
|
|
|
|
# Refresh fontconfig cache so Chromium picks up the new font. Run under sudo
|
|
# for the system cache dirs (unprivileged fc-cache fails on unwritable dirs).
|
|
if command -v fc-cache >/dev/null 2>&1; then
|
|
$sudo fc-cache -f >/dev/null 2>&1 || fc-cache -f >/dev/null 2>&1 || true
|
|
fi
|
|
EMOJI_FONT_INSTALLED=1
|
|
return 0
|
|
}
|
|
|
|
# After a fresh font install, stop any running browse render daemon so the next
|
|
# make-pdf render spawns a fresh Chromium that sees the new font. Chromium
|
|
# caches its font list at process start, so a daemon that was alive before the
|
|
# install would keep emitting tofu. `browse stop` is the graceful API; the
|
|
# daemon auto-respawns on the next render. Best-effort and per-project-root, so
|
|
# we also print a note for daemons in other roots.
|
|
refresh_browse_daemon_for_fonts() {
|
|
[ "$EMOJI_FONT_INSTALLED" -eq 1 ] || return 0
|
|
if [ -x "$BROWSE_BIN" ]; then
|
|
"$BROWSE_BIN" stop >/dev/null 2>&1 || true
|
|
fi
|
|
echo " Installed a color-emoji font. The next make-pdf render will show emoji."
|
|
echo " If a gstack browser is running in another project, restart it to pick up the font."
|
|
}
|
|
|
|
prepare_bun_for_windows_compile() {
|
|
BUN_CMD="bun"
|
|
BUN_CMD_WAS_COPIED=0
|
|
[ "$IS_WINDOWS" -eq 1 ] || return 0
|
|
|
|
local bun_path
|
|
bun_path="$(command -v bun 2>/dev/null || true)"
|
|
case "$bun_path" in
|
|
*[![:ascii:]]*)
|
|
local bun_copy_dir="$SOURCE_GSTACK_DIR/.tmp-bun-bin"
|
|
mkdir -p "$bun_copy_dir"
|
|
cp -f "$bun_path" "$bun_copy_dir/bun.exe"
|
|
BUN_CMD="$bun_copy_dir/bun.exe"
|
|
BUN_CMD_WAS_COPIED=1
|
|
;;
|
|
esac
|
|
}
|
|
|
|
bun_cmd() {
|
|
"$BUN_CMD" "$@"
|
|
}
|
|
|
|
cleanup_copied_bun() {
|
|
local rc=$?
|
|
if [ "${BUN_CMD_WAS_COPIED:-0}" -eq 1 ]; then
|
|
rm -rf "$SOURCE_GSTACK_DIR/.tmp-bun-bin"
|
|
fi
|
|
_setup_exit_summary "$rc"
|
|
}
|
|
|
|
prepare_bun_for_windows_compile
|
|
trap cleanup_copied_bun EXIT
|
|
|
|
_cso_unavailable() {
|
|
CSO_BUILD_AVAILABLE=0
|
|
CSO_FAIL_REASON="$1"
|
|
}
|
|
|
|
# CSO has a native startup boundary. Its extra toolchain is optional for setup:
|
|
# when unavailable, install every other skill and leave /cso visibly fail-closed.
|
|
# A successful probe is only a prerequisite check. A later build or publish
|
|
# failure is recorded by scripts/build-cso.sh and reported by stage, not as a
|
|
# missing tool; only GSTACK_STRICT_BUILD=1 makes it abort setup (#3071).
|
|
probe_cso_build_prerequisites() {
|
|
local _help _flag _platform _compiler _tmp _ps _ps_script _repo_root _git_path _probe_out _probe_rc
|
|
CSO_BUILD_AVAILABLE=1
|
|
CSO_FAIL_REASON=""
|
|
CSO_PROBE_DETAIL=""
|
|
if [ "${GSTACK_SETUP_SKIP_CSO_BUILD:-0}" = "1" ]; then
|
|
_cso_unavailable "skipped-by-request"
|
|
return 0
|
|
fi
|
|
|
|
_help="$(bun_cmd build --help 2>&1 || true)"
|
|
for _flag in --no-compile-autoload-dotenv --no-compile-autoload-bunfig --no-compile-autoload-tsconfig --no-compile-autoload-package-json; do
|
|
case "$_help" in
|
|
*"$_flag"*) ;;
|
|
*) _cso_unavailable "bun-compile-flags"; return 0 ;;
|
|
esac
|
|
done
|
|
|
|
_platform="$(uname -s)"
|
|
if [ "$IS_WINDOWS" -eq 1 ]; then
|
|
# #3071: PowerShell 7 first; Windows PowerShell 5.1 can crash outright.
|
|
_ps="$(command -v pwsh 2>/dev/null || command -v powershell.exe 2>/dev/null || true)"
|
|
if [ -z "$_ps" ] || ! command -v cygpath >/dev/null 2>&1; then
|
|
_cso_unavailable "windows-shell-toolchain"
|
|
return 0
|
|
fi
|
|
_ps_script="$(cygpath -w "$SOURCE_GSTACK_DIR/scripts/build-cso-windows.ps1")"
|
|
_repo_root="$(cygpath -w "$SOURCE_GSTACK_DIR")"
|
|
_git_path="$(type -P git 2>/dev/null || true)"
|
|
if [ -z "$_git_path" ] || [ ! -f "$_git_path" ]; then
|
|
_cso_unavailable "windows-git"
|
|
return 0
|
|
fi
|
|
# E2 (#3015): a probe that found MSVC but could not compile is not a
|
|
# missing toolchain; keep the probe's own first diagnostic for the summary.
|
|
# #3071: neither is a probe that failed for another reason (a crashed
|
|
# PowerShell); keep its exit status and first output line.
|
|
_probe_rc=0
|
|
_probe_out="$("$_ps" -NoProfile -NonInteractive -ExecutionPolicy Bypass \
|
|
-File "$_ps_script" -RepoRoot "$_repo_root" -GitExePath "$(cygpath -aw "$_git_path")" -CheckOnly 2>&1)" || _probe_rc=$?
|
|
if [ "$_probe_rc" -ne 0 ]; then
|
|
case "$_probe_out" in
|
|
*"compiler probe failed"*|*"compiler probe was not produced"*)
|
|
_cso_unavailable "windows-msvc-compile"
|
|
CSO_PROBE_DETAIL="$(printf '%s\n' "$_probe_out" | grep -m1 -E 'error [A-Z]*[0-9]+|probe (failed|was not produced)' | tr -d '\r' | head -c 200)" ;;
|
|
*"requires Visual Studio 2022"*) _cso_unavailable "windows-msvc-toolchain" ;;
|
|
*)
|
|
_cso_unavailable "windows-toolchain-probe"
|
|
CSO_PROBE_DETAIL="${_ps##*/} exited $_probe_rc: $(printf '%s\n' "$_probe_out" | tr -d '\r' | grep -m1 -v '^[[:space:]]*$' | head -c 200)" ;;
|
|
esac
|
|
fi
|
|
return 0
|
|
fi
|
|
|
|
_compiler="${CSO_CC:-cc}"
|
|
if ! command -v "$_compiler" >/dev/null 2>&1; then
|
|
_cso_unavailable "c-compiler"
|
|
return 0
|
|
fi
|
|
_tmp="$(mktemp -d "${TMPDIR:-/tmp}/gstack-cso-probe.XXXXXX" 2>/dev/null || true)"
|
|
if [ -z "$_tmp" ]; then
|
|
_cso_unavailable "native-toolchain-probe"
|
|
return 0
|
|
fi
|
|
printf '%s\n' 'int main(void) { return 0; }' > "$_tmp/probe.c"
|
|
case "$_platform" in
|
|
Linux)
|
|
if ! "$_compiler" -std=c11 -static "$_tmp/probe.c" -o "$_tmp/probe" >/dev/null 2>&1; then
|
|
_cso_unavailable "static-c-toolchain"
|
|
fi
|
|
;;
|
|
Darwin)
|
|
if ! command -v codesign >/dev/null 2>&1; then
|
|
_cso_unavailable "macos-codesign"
|
|
elif ! "$_compiler" -std=c11 "$_tmp/probe.c" -o "$_tmp/probe" >/dev/null 2>&1 || \
|
|
! codesign --force --sign - --options runtime "$_tmp/probe" >/dev/null 2>&1 || \
|
|
! codesign --verify --strict "$_tmp/probe" >/dev/null 2>&1 || \
|
|
! codesign -d --verbose=4 "$_tmp/probe" 2>&1 | grep -q 'runtime'; then
|
|
_cso_unavailable "macos-native-toolchain"
|
|
fi
|
|
;;
|
|
*) _cso_unavailable "unsupported-platform" ;;
|
|
esac
|
|
rm -rf "$_tmp"
|
|
}
|
|
|
|
# Resolve the model overlay used for generated Codex skills. Setup auto-detects
|
|
# only Codex because it has one canonical TOML config surface; direct generator
|
|
# calls remain deterministic and use the host default unless --model is explicit.
|
|
# The resolver runs on EVERY setup, not just codex installs: step 1b regenerates
|
|
# .agents/ unconditionally, and existing ~/.codex/skills symlinks point into it —
|
|
# a plain `./setup` on a Sol user's machine must not clobber their profile with
|
|
# the hardcoded fallback. The resolver is a read-only TOML lookup that falls
|
|
# back to the current frontier Codex profile when no Codex config exists.
|
|
CODEX_GENERATION_MODEL="gpt-6-astra"
|
|
CODEX_GENERATION_MODEL_SOURCE="default (gpt-6-astra)"
|
|
_CODEX_MODEL_ARGS=(run scripts/resolve-codex-generation-model.ts)
|
|
if [ "$MODEL_OVERRIDE_SET" -eq 1 ]; then
|
|
_CODEX_MODEL_ARGS+=(--explicit "$MODEL_OVERRIDE")
|
|
fi
|
|
_CODEX_MODEL_OUTPUT="$(cd "$SOURCE_GSTACK_DIR" && bun_cmd "${_CODEX_MODEL_ARGS[@]}")"
|
|
IFS=$'\t' read -r CODEX_GENERATION_MODEL CODEX_GENERATION_MODEL_SOURCE <<< "$_CODEX_MODEL_OUTPUT"
|
|
if [ -z "$CODEX_GENERATION_MODEL" ]; then
|
|
echo "gstack setup failed: Codex model resolver returned no model" >&2
|
|
exit 1
|
|
fi
|
|
if [ "$INSTALL_CODEX" -eq 1 ] || [ "$CODEX_GENERATION_MODEL" != "gpt-6-astra" ]; then
|
|
log "Codex skill profile: $CODEX_GENERATION_MODEL"
|
|
log "Source: $CODEX_GENERATION_MODEL_SOURCE"
|
|
fi
|
|
# Claude skill overlay: claude_overlay_model when present (only ./setup
|
|
# --claude-model writes it), else the generic claude overlay
|
|
# (bin/gstack-render-claude.sh).
|
|
gstack_claude_overlay "$SOURCE_GSTACK_DIR" "$SOURCE_GSTACK_DIR/bin/gstack-config"
|
|
|
|
# Installed-hosts record (#1694): the build renders skills only for these
|
|
# hosts, and step 7b prunes every other host's render from this checkout. The
|
|
# record only grows; installs made before it existed are discovered first.
|
|
gstack_render_hosts_seed "$SOURCE_GSTACK_DIR"
|
|
_RENDER_HOSTS_SELECTED=""
|
|
[ "$INSTALL_CLAUDE" -eq 1 ] && _RENDER_HOSTS_SELECTED="$_RENDER_HOSTS_SELECTED claude"
|
|
[ "$INSTALL_CODEX" -eq 1 ] && _RENDER_HOSTS_SELECTED="$_RENDER_HOSTS_SELECTED codex"
|
|
[ "$INSTALL_KIRO" -eq 1 ] && _RENDER_HOSTS_SELECTED="$_RENDER_HOSTS_SELECTED kiro"
|
|
[ "$INSTALL_FACTORY" -eq 1 ] && _RENDER_HOSTS_SELECTED="$_RENDER_HOSTS_SELECTED factory"
|
|
[ "$INSTALL_OPENCODE" -eq 1 ] && _RENDER_HOSTS_SELECTED="$_RENDER_HOSTS_SELECTED opencode"
|
|
[ "$INSTALL_CURSOR" -eq 1 ] && _RENDER_HOSTS_SELECTED="$_RENDER_HOSTS_SELECTED cursor"
|
|
[ "$INSTALL_COPILOT" -eq 1 ] && _RENDER_HOSTS_SELECTED="$_RENDER_HOSTS_SELECTED copilot"
|
|
[ "$INSTALL_AGY" -eq 1 ] && _RENDER_HOSTS_SELECTED="$_RENDER_HOSTS_SELECTED agy"
|
|
# shellcheck disable=SC2086 # the host list splits on purpose
|
|
if ! gstack_render_hosts_add "$SOURCE_GSTACK_DIR" $_RENDER_HOSTS_SELECTED; then
|
|
echo " warning: could not write $(gstack_render_hosts_file "$SOURCE_GSTACK_DIR"); this build renders every host and nothing is pruned" >&2
|
|
fi
|
|
|
|
# Migrate existing wrappers BEFORE build can regenerate/prune shared host trees.
|
|
# This also repairs existing Codex/Kiro installs during a Claude-only setup.
|
|
# A failed/foreign replacement retains its old render even when build runs
|
|
# --host all later. On success, normal generation may prune orphan old renders.
|
|
export GSTACK_DEFER_CLAUDE_RENAME_PRUNE=1
|
|
export GSTACK_CODEX_GENERATION_MODEL="$CODEX_GENERATION_MODEL"
|
|
if CODEX_HOME="$(dirname "$CODEX_SKILLS")" GSTACK_RENAME_COPY="$IS_WINDOWS" bun_cmd "$SOURCE_GSTACK_DIR/bin/gstack-migrate-claude-code" \
|
|
--install-dir "$SOURCE_GSTACK_DIR" --skills-dir "$INSTALL_SKILLS_DIR"; then
|
|
if [ "$CODEX_REPO_LOCAL" -eq 0 ]; then unset GSTACK_DEFER_CLAUDE_RENAME_PRUNE; fi
|
|
fi
|
|
|
|
# 1. Build browse binary if needed (smart rebuild: stale sources, package.json, lock).
|
|
probe_cso_build_prerequisites
|
|
_EXE=""
|
|
if [ "$IS_WINDOWS" -eq 1 ]; then _EXE=".exe"; fi
|
|
# Never leave a trusted helper from an earlier toolchain/version available when
|
|
# this setup run cannot reproduce it. The /cso skill then reports not assessed.
|
|
# An earlier build's result record no longer describes the removed helper;
|
|
# setup never writes into the source checkout when nothing is built.
|
|
if [ "$CSO_BUILD_AVAILABLE" -eq 0 ]; then
|
|
rm -f "$SOURCE_GSTACK_DIR/bin/.gstack-cso-build-result" \
|
|
"$SOURCE_GSTACK_DIR/bin/gstack-cso-launcher" \
|
|
"$SOURCE_GSTACK_DIR/bin/gstack-cso-launcher.exe" \
|
|
"$SOURCE_GSTACK_DIR/bin/gstack-cso-core" \
|
|
"$SOURCE_GSTACK_DIR/bin/gstack-cso-core.exe" \
|
|
"$SOURCE_GSTACK_DIR/bin/gstack-cso-watchdog" \
|
|
"$SOURCE_GSTACK_DIR/bin/.gstack-cso-generation" \
|
|
"$SOURCE_GSTACK_DIR/bin/.gstack-cso-generation.lock"
|
|
fi
|
|
BUILD_STAMP="$SOURCE_GSTACK_DIR/browse/dist/.build-complete"
|
|
NEEDS_BUILD=0
|
|
if [ ! -f "$BUILD_STAMP" ] || [ ! -x "$BROWSE_BIN" ] || [ ! -x "$SOURCE_GSTACK_DIR/design/dist/design$_EXE" ] || [ ! -x "$SOURCE_GSTACK_DIR/make-pdf/dist/pdf$_EXE" ]; then
|
|
NEEDS_BUILD=1
|
|
fi
|
|
if [ "$CSO_BUILD_AVAILABLE" -eq 1 ]; then
|
|
if [ ! -x "$SOURCE_GSTACK_DIR/bin/gstack-cso-launcher$_EXE" ] || [ ! -x "$SOURCE_GSTACK_DIR/bin/gstack-cso-core$_EXE" ] || [ ! -f "$SOURCE_GSTACK_DIR/bin/.gstack-cso-generation" ]; then
|
|
NEEDS_BUILD=1
|
|
elif [ "$IS_WINDOWS" -eq 0 ] && [ ! -x "$SOURCE_GSTACK_DIR/bin/gstack-cso-watchdog" ]; then
|
|
NEEDS_BUILD=1
|
|
elif [ "$IS_WINDOWS" -eq 1 ] && [ ! -f "$SOURCE_GSTACK_DIR/bin/.gstack-cso-generation.lock" ];then
|
|
NEEDS_BUILD=1
|
|
fi
|
|
fi
|
|
# lib/ holds the canonical claude-bin, error-handling and aside-render sources
|
|
# the binaries embed (browse/src re-exports them), so it is part of the set.
|
|
if [ "$NEEDS_BUILD" -eq 0 ]; then
|
|
if [ -n "$(find "$SOURCE_GSTACK_DIR/browse/src" "$SOURCE_GSTACK_DIR/make-pdf/src" "$SOURCE_GSTACK_DIR/design/src" "$SOURCE_GSTACK_DIR/lib" -type f -newer "$BUILD_STAMP" -print -quit 2>/dev/null)" ]; then
|
|
NEEDS_BUILD=1
|
|
elif [ "$SOURCE_GSTACK_DIR/package.json" -nt "$BUILD_STAMP" ]; then
|
|
NEEDS_BUILD=1
|
|
elif [ -f "$SOURCE_GSTACK_DIR/bun.lock" ] && [ "$SOURCE_GSTACK_DIR/bun.lock" -nt "$BUILD_STAMP" ]; then
|
|
NEEDS_BUILD=1
|
|
elif [ "$SOURCE_GSTACK_DIR/scripts/build.sh" -nt "$BUILD_STAMP" ]; then
|
|
NEEDS_BUILD=1
|
|
elif [ "$CSO_BUILD_AVAILABLE" -eq 1 ] && { [ "$SOURCE_GSTACK_DIR/scripts/build-cso.sh" -nt "$BUILD_STAMP" ] || [ "$SOURCE_GSTACK_DIR/scripts/build-cso-windows.ps1" -nt "$BUILD_STAMP" ]; }; then
|
|
NEEDS_BUILD=1
|
|
fi
|
|
fi
|
|
|
|
if [ "$NEEDS_BUILD" -eq 1 ]; then
|
|
log "Building browse binary..."
|
|
(
|
|
cd "$SOURCE_GSTACK_DIR"
|
|
bun_cmd install --frozen-lockfile 2>/dev/null || bun_cmd install
|
|
if [ "$CSO_BUILD_AVAILABLE" -eq 0 ]; then
|
|
export GSTACK_SETUP_SKIP_CSO_BUILD=1
|
|
fi
|
|
bun_cmd run build
|
|
)
|
|
# Safety net: write .version if build script didn't (e.g., git not available during build)
|
|
if [ ! -f "$SOURCE_GSTACK_DIR/browse/dist/.version" ]; then
|
|
git -C "$SOURCE_GSTACK_DIR" rev-parse HEAD > "$SOURCE_GSTACK_DIR/browse/dist/.version" 2>/dev/null || true
|
|
fi
|
|
|
|
# macOS Apple Silicon: ad-hoc codesign compiled binaries.
|
|
# Bun's --compile can produce a corrupt or linker-only code signature that
|
|
# macOS kills with SIGKILL (exit 137). The two-step remove+re-sign is
|
|
# required because a naive `codesign -s - -f` fails when the existing
|
|
# signature block is corrupt. This is idempotent and costs <1s.
|
|
#
|
|
# Some binaries (observed: find-browse, gstack-global-discover) also carry
|
|
# trailing zero-padding AFTER the Mach-O LC_CODE_SIGNATURE region. macOS
|
|
# codesign requires the signature to be the last content and extend to EOF,
|
|
# so the padding triggers "main executable failed strict validation" on
|
|
# re-sign (and "internal error in Code Signing subsystem" on remove). We
|
|
# truncate that trailing slack to the end of LC_CODE_SIGNATURE first, which
|
|
# lets the identical re-sign succeed. The binary runs either way: Bun's
|
|
# adhoc code-page signature satisfies the kernel's exec check even when
|
|
# `codesign --verify` is unhappy, so a re-sign failure only warns when the
|
|
# binary is genuinely SIGKILL'd on exec (exit 137).
|
|
# See: https://github.com/garrytan/gstack/issues/997
|
|
if [ "$(uname -s)" = "Darwin" ] && [ "$(uname -m)" = "arm64" ]; then
|
|
# CSO artifacts are signed and verified inside build-cso's staged generation;
|
|
# mutating them here would break its launcher-last publication guarantee.
|
|
# B4 (#1254): every binary is backed up first and restored as built when
|
|
# re-signing fails, so a failed codesign never leaves a half-signed file.
|
|
for _bin in browse/dist/browse browse/dist/find-browse design/dist/design make-pdf/dist/pdf bin/gstack-global-discover; do
|
|
_bin_path="$SOURCE_GSTACK_DIR/$_bin"
|
|
[ -f "$_bin_path" ] && [ -x "$_bin_path" ] || continue
|
|
_bin_bak="$_bin_path.gstack-presign.$$"
|
|
if ! cp -p "$_bin_path" "$_bin_bak" 2>/dev/null; then
|
|
rm -f "$_bin_bak"
|
|
log "note: could not back up $_bin before re-signing; left it as built"
|
|
continue
|
|
fi
|
|
# Strip any trailing bytes past LC_CODE_SIGNATURE so codesign can re-sign.
|
|
# otool prints the signature's dataoff+datasize; if the file is larger,
|
|
# the extra bytes are Bun padding that breaks strict validation.
|
|
_sig_end=$(otool -l "$_bin_path" 2>/dev/null | awk '/LC_CODE_SIGNATURE/{f=1} f&&/dataoff/{o=$2} f&&/datasize/{print o+$2; exit}')
|
|
_fsize=$(stat -f%z "$_bin_path" 2>/dev/null)
|
|
if [ -n "$_sig_end" ] && [ -n "$_fsize" ] && [ "$_sig_end" -gt 0 ] 2>/dev/null && [ "$_sig_end" -lt "$_fsize" ] 2>/dev/null; then
|
|
_trunc_tmp=$(mktemp 2>/dev/null) || _trunc_tmp=""
|
|
if [ -n "$_trunc_tmp" ] && head -c "$_sig_end" "$_bin_path" > "$_trunc_tmp" 2>/dev/null; then
|
|
cat "$_trunc_tmp" > "$_bin_path" && chmod +x "$_bin_path"
|
|
fi
|
|
[ -n "$_trunc_tmp" ] && rm -f "$_trunc_tmp"
|
|
fi
|
|
codesign --remove-signature "$_bin_path" 2>/dev/null || true
|
|
_signed=1
|
|
if codesign -s - -f "$_bin_path" 2>/dev/null; then
|
|
rm -f "$_bin_bak"
|
|
else
|
|
_signed=0
|
|
mv -f "$_bin_bak" "$_bin_path"
|
|
fi
|
|
# Exec probe: after a failed re-sign (the restored binary as built), and
|
|
# always for design, whose skills trust a binary that starts. Only
|
|
# SIGKILL (exit 137) means it cannot run; set -e safe.
|
|
[ "$_signed" -eq 0 ] || [ "$_bin" = design/dist/design ] || continue
|
|
_probe_rc=0
|
|
"$_bin_path" --help >/dev/null 2>&1 || _probe_rc=$?
|
|
if [ "$_probe_rc" -eq 137 ] && [ "$_bin" = design/dist/design ]; then
|
|
echo " design unavailable: $_bin_path is killed at launch (exit 137) after re-signing, usually an invalid code signature. Design skills will report DESIGN_NOT_AVAILABLE. Fix: cd $SOURCE_GSTACK_DIR && ./setup" >&2
|
|
elif [ "$_probe_rc" -eq 137 ]; then
|
|
log "warning: codesign failed for $_bin and it is SIGKILL'd on exec (exit 137) — it may not run on Apple Silicon"
|
|
elif [ "$_signed" -eq 0 ]; then
|
|
log "note: codesign could not re-sign $_bin; restored it as built, and it executes fine (Bun adhoc signature); continuing"
|
|
fi
|
|
done
|
|
fi
|
|
|
|
# macOS: install coreutils for `gtimeout` (Codex hang protection in /codex + /autoplan).
|
|
# macOS ships BSD `timeout`-less; Homebrew's coreutils installs GNU timeout as
|
|
# `gtimeout` to avoid shadowing BSD utilities. The /codex and /autoplan skills
|
|
# fall back to unwrapped codex invocations when neither is available — this
|
|
# auto-install upgrades them to hang-protected where possible.
|
|
# Skip entirely with GSTACK_SKIP_COREUTILS=1 (CI, managed machines, offline envs).
|
|
if [ "$(uname -s)" = "Darwin" ] && [ "${GSTACK_SKIP_COREUTILS:-0}" != "1" ]; then
|
|
if ! command -v gtimeout >/dev/null 2>&1 && ! command -v timeout >/dev/null 2>&1; then
|
|
if command -v brew >/dev/null 2>&1; then
|
|
log "Installing coreutils for Codex hang protection (set GSTACK_SKIP_COREUTILS=1 to skip)..."
|
|
brew install coreutils >/dev/null 2>&1 || log "warning: brew install coreutils failed; /codex will run without hang protection"
|
|
else
|
|
log "warning: Homebrew not found. /codex will run without hang protection. Install coreutils manually or set GSTACK_SKIP_COREUTILS=1."
|
|
fi
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
# A failed CSO build or publish leaves an incomplete set; the summary reports
|
|
# it from the build-result record. Fatal only under GSTACK_STRICT_BUILD=1 (#3071).
|
|
_CSO_READY=0
|
|
if [ "$CSO_BUILD_AVAILABLE" -eq 1 ]; then
|
|
if [ ! -x "$SOURCE_GSTACK_DIR/bin/gstack-cso-launcher$_EXE" ] || [ ! -x "$SOURCE_GSTACK_DIR/bin/gstack-cso-core$_EXE" ] || [ ! -f "$SOURCE_GSTACK_DIR/bin/.gstack-cso-generation" ] || { [ "$IS_WINDOWS" -eq 0 ] && [ ! -x "$SOURCE_GSTACK_DIR/bin/gstack-cso-watchdog" ]; } || { [ "$IS_WINDOWS" -eq 1 ] && [ ! -f "$SOURCE_GSTACK_DIR/bin/.gstack-cso-generation.lock" ]; }; then
|
|
if [ "${GSTACK_STRICT_BUILD:-0}" = "1" ]; then
|
|
echo "gstack setup failed: CSO build completed without its required native artifact set (GSTACK_STRICT_BUILD=1)" >&2
|
|
exit 1
|
|
fi
|
|
else
|
|
_CSO_READY=1
|
|
fi
|
|
fi
|
|
if [ "$_CSO_READY" -eq 1 ]; then
|
|
# Evaluation-only helpers embed an unqualified cso-eval-* catalog for the
|
|
# private evaluator. Setup never installs one, whether marked or copied bare.
|
|
if [ -e "$SOURCE_GSTACK_DIR/bin/.gstack-cso-evaluation" ] || "$SOURCE_GSTACK_DIR/bin/gstack-cso-launcher$_EXE" --version 2>/dev/null | grep -q '"evaluationOnly": *true'; then
|
|
echo "gstack setup failed: bin/ holds an evaluation-only CSO helper; remove the bin/gstack-cso-* artifacts and re-run setup" >&2
|
|
exit 1
|
|
fi
|
|
# Runtime/scanner execution never pulls. Setup is the sole automatic
|
|
# acquisition path: the trusted launcher validates committed catalogs,
|
|
# rejects remote Docker contexts, and uses an empty Docker config for
|
|
# anonymous exact-digest pulls. Each image gets a bounded pull window and the
|
|
# whole catalog remains under a hard aggregate deadline; Docker or network
|
|
# gaps stay nonfatal so a later setup can continue from local exact digests.
|
|
_CSO_IMAGE_SUMMARY=""
|
|
_CSO_IMAGE_PULL_TIMEOUT_SECONDS="${GSTACK_CSO_IMAGE_PULL_TIMEOUT_SECONDS:-30}"
|
|
if _CSO_IMAGE_SUMMARY="$("$SOURCE_GSTACK_DIR/bin/gstack-cso-launcher$_EXE" provision-images --setup-summary --per-image-seconds "$_CSO_IMAGE_PULL_TIMEOUT_SECONDS" 2>/dev/null)"; then
|
|
[ -n "$_CSO_IMAGE_SUMMARY" ] && log "$_CSO_IMAGE_SUMMARY"
|
|
else
|
|
log "warning: qualified CSO images could not be checked or preloaded; static audits remain available. Re-run setup after local Docker and public registry access are available."
|
|
fi
|
|
fi
|
|
|
|
if [ ! -x "$BROWSE_BIN" ]; then
|
|
echo "gstack setup failed: browse binary missing at $BROWSE_BIN" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# 1b. Generate .agents/ Codex skill docs whenever this checkout serves Codex.
|
|
# .agents/ is no longer committed — generated at setup time from .tmpl templates.
|
|
# bun run build generates the host-default artifact. Always render Codex again
|
|
# with the resolved user profile so a build cannot overwrite a Sol-specific render.
|
|
# Always regenerate: generation is fast (<2s) and mtime-based staleness checks are fragile
|
|
# (miss stale files when timestamps match after clone/checkout/upgrade). A
|
|
# checkout with no Codex install renders none (#1694).
|
|
AGENTS_DIR="$SOURCE_GSTACK_DIR/.agents/skills"
|
|
NEEDS_AGENTS_GEN=0
|
|
if [ "$INSTALL_CODEX" -eq 1 ] || gstack_render_hosts_has "$SOURCE_GSTACK_DIR" codex; then NEEDS_AGENTS_GEN=1; fi
|
|
|
|
if [ "$NEEDS_AGENTS_GEN" -eq 1 ]; then
|
|
log "Generating .agents/ skill docs..."
|
|
(
|
|
cd "$SOURCE_GSTACK_DIR"
|
|
bun_cmd install --frozen-lockfile 2>/dev/null || bun_cmd install
|
|
bun_cmd run gen:skill-docs --host codex --model "$CODEX_GENERATION_MODEL" ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"}
|
|
)
|
|
_prune_stale_generated "$SOURCE_GSTACK_DIR" "$AGENTS_DIR" "$CODEX_SKILLS"
|
|
fi
|
|
|
|
# 1c. Generate .factory/ Factory Droid skill docs
|
|
if [ "$INSTALL_FACTORY" -eq 1 ] && [ "$NEEDS_BUILD" -eq 0 ]; then
|
|
log "Generating .factory/ skill docs..."
|
|
(
|
|
cd "$SOURCE_GSTACK_DIR"
|
|
bun_cmd install --frozen-lockfile 2>/dev/null || bun_cmd install
|
|
bun_cmd run gen:skill-docs --host factory ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"}
|
|
)
|
|
fi
|
|
|
|
# 1d. Generate .opencode/ OpenCode skill docs
|
|
if [ "$INSTALL_OPENCODE" -eq 1 ] && [ "$NEEDS_BUILD" -eq 0 ]; then
|
|
log "Generating .opencode/ skill docs..."
|
|
(
|
|
cd "$SOURCE_GSTACK_DIR"
|
|
bun_cmd install --frozen-lockfile 2>/dev/null || bun_cmd install
|
|
bun_cmd run gen:skill-docs --host opencode ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"}
|
|
)
|
|
fi
|
|
|
|
# 1e. Generate .cursor/ Cursor skill docs
|
|
if [ "$INSTALL_CURSOR" -eq 1 ] && [ "$NEEDS_BUILD" -eq 0 ]; then
|
|
log "Generating .cursor/ skill docs..."
|
|
(
|
|
cd "$SOURCE_GSTACK_DIR"
|
|
bun_cmd install --frozen-lockfile 2>/dev/null || bun_cmd install
|
|
bun_cmd run gen:skill-docs --host cursor ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"}
|
|
)
|
|
fi
|
|
|
|
# 1f. Generate .copilot/ GitHub Copilot skill docs
|
|
if [ "$INSTALL_COPILOT" -eq 1 ] && [ "$NEEDS_BUILD" -eq 0 ]; then
|
|
log "Generating .copilot/ skill docs..."
|
|
(
|
|
cd "$SOURCE_GSTACK_DIR"
|
|
bun_cmd install --frozen-lockfile 2>/dev/null || bun_cmd install
|
|
bun_cmd run gen:skill-docs --host copilot ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"}
|
|
)
|
|
fi
|
|
|
|
# 1g. Generate .agy/ Antigravity CLI skill docs
|
|
if [ "$INSTALL_AGY" -eq 1 ] && [ "$NEEDS_BUILD" -eq 0 ]; then
|
|
log "Generating .agy/ skill docs..."
|
|
(
|
|
cd "$SOURCE_GSTACK_DIR"
|
|
bun_cmd install --frozen-lockfile 2>/dev/null || bun_cmd install
|
|
bun_cmd run gen:skill-docs --host agy ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"}
|
|
)
|
|
fi
|
|
|
|
# 2. Ensure Playwright's Chromium is available
|
|
_PLAYWRIGHT_PLATFORM_OVERRIDE=""
|
|
_PLAYWRIGHT_UNSUPPORTED_ARCH=""
|
|
if [ -f /etc/os-release ]; then
|
|
_os_id=$(grep '^ID=' /etc/os-release | cut -d= -f2 | tr -d '"')
|
|
_os_ver=$(grep '^VERSION_ID=' /etc/os-release | cut -d= -f2 | tr -d '"')
|
|
if [ "$_os_id" = "ubuntu" ] && [ "$_os_ver" = "26.04" ]; then
|
|
_pw_arch="$(uname -m)"
|
|
case "$_pw_arch" in
|
|
x86_64) _PLAYWRIGHT_PLATFORM_OVERRIDE="ubuntu24.04-x64" ;;
|
|
aarch64|arm64) _PLAYWRIGHT_PLATFORM_OVERRIDE="ubuntu24.04-arm64" ;;
|
|
*) _PLAYWRIGHT_UNSUPPORTED_ARCH="$_pw_arch" ;;
|
|
esac
|
|
if [ -n "$_PLAYWRIGHT_PLATFORM_OVERRIDE" ]; then
|
|
echo "Ubuntu 26.04 detected — using PLAYWRIGHT_HOST_PLATFORM_OVERRIDE=$_PLAYWRIGHT_PLATFORM_OVERRIDE"
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
# Chromium is BEST-EFFORT (#1900, #1901, #1902, #913, #2233). Every later step
|
|
# — skill registration (# 4), Codex/Kiro installs, migrations, hooks — is
|
|
# independent of the browser, so a failed or wedged download must never abort
|
|
# setup under `set -e`. Each failure records a reason code in _PW_FAIL_REASON;
|
|
# the skills that need Chromium (/qa, /qa-only, /design-review, /browse,
|
|
# make-pdf, /pair-agent) are named in the final summary instead of the user
|
|
# discovering a half-installed gstack. Lock contention is a reason too: another
|
|
# setup is installing Chromium right now, so this run registers skills and
|
|
# re-probes next time. The download is bounded (default 600s, env
|
|
# GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT) because Playwright's own retries cover a
|
|
# flaky socket but not a wedged `bun x`; a wedged installer is killed with its
|
|
# child tree (_kill_tree: pgrep-walked, /proc-walked where pgrep is missing).
|
|
# Reason codes: skipped, chromium-install,
|
|
# chromium-install-timeout, chromium-install-locked, windows-no-node,
|
|
# windows-node-modules, post-install-launch.
|
|
# test/setup-playwright-best-effort.test.ts pins this block.
|
|
_PW_FAIL_REASON=""
|
|
_pw_fail() {
|
|
local code="$1"; shift
|
|
_PW_FAIL_REASON="${_PW_FAIL_REASON:+$_PW_FAIL_REASON,}$code"
|
|
echo " Chromium bootstrap: $code — $*" >&2
|
|
}
|
|
_PW_INSTALL_TIMEOUT="${GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT:-600}"
|
|
# Normalize to a plain positive integer or fall back to the default: empty and
|
|
# non-numeric are garbage; "0"/"000" would kill the install on the first poll;
|
|
# "0600" is 600; anything past nine digits is not a deadline (a value bash
|
|
# cannot compare would leave the install unbounded — the exact failure the
|
|
# bound exists to prevent).
|
|
case "$_PW_INSTALL_TIMEOUT" in ''|*[!0-9]*) _PW_INSTALL_TIMEOUT=600 ;; esac
|
|
[ "${#_PW_INSTALL_TIMEOUT}" -le 9 ] || _PW_INSTALL_TIMEOUT=600
|
|
_PW_INSTALL_TIMEOUT=$((10#$_PW_INSTALL_TIMEOUT))
|
|
[ "$_PW_INSTALL_TIMEOUT" -gt 0 ] || _PW_INSTALL_TIMEOUT=600
|
|
|
|
if [ "${GSTACK_SKIP_PLAYWRIGHT:-0}" = "1" ]; then
|
|
_pw_fail skipped "GSTACK_SKIP_PLAYWRIGHT=1 — Chromium install skipped by request (#913)"
|
|
elif [ -n "${_PLAYWRIGHT_UNSUPPORTED_ARCH:-}" ]; then
|
|
_pw_fail unsupported-platform "Ubuntu 26.04 architecture $_PLAYWRIGHT_UNSUPPORTED_ARCH has no supported Playwright Chromium fallback; no browser install attempted"
|
|
elif ! ensure_playwright_browser; then
|
|
echo "Installing Playwright Chromium..."
|
|
# XProtect self-heal (#2554): the probe failure may be the OS killing the
|
|
# cached Chromium, not a missing install. Clear quarantine on the Playwright
|
|
# cache bundles before reinstalling so the fresh fetch launches clean.
|
|
_clear_playwright_quarantine
|
|
_PW_LOCK="${TMPDIR:-/tmp}/gstack-playwright-install.lock"
|
|
# Stale-lock self-heal: a SIGKILL'd prior setup leaves the lock dir behind
|
|
# forever (mkdir mutexes have no owner). If the recorded holder PID is dead,
|
|
# reclaim instead of telling the user to rmdir by hand.
|
|
if [ -d "$_PW_LOCK" ]; then
|
|
_PW_STALE=0; _PW_HOLDER=0
|
|
_PW_LOCK_OLD=""
|
|
[ -n "$(find "$_PW_LOCK" -maxdepth 0 -mmin +$(( _PW_INSTALL_TIMEOUT / 60 + 1 )) 2>/dev/null)" ] && _PW_LOCK_OLD=1
|
|
if [ ! -f "$_PW_LOCK/pid" ]; then
|
|
# No holder recorded (killed between mkdir and echo, or mid-write by a
|
|
# live setup): nothing to probe, so only age can prove abandonment.
|
|
[ -n "$_PW_LOCK_OLD" ] && _PW_STALE=1
|
|
else
|
|
_PW_HOLDER=$(cat "$_PW_LOCK/pid" 2>/dev/null || true)
|
|
# A pid must be a positive integer: "", "-1" (kill -0 -1 signals every
|
|
# process and "succeeds") or "0" (the process group) are stale, not live.
|
|
case "$_PW_HOLDER" in ''|*[!0-9]*) _PW_HOLDER=0 ;; esac
|
|
if [ "$_PW_HOLDER" -eq 0 ] || ! kill -0 "$_PW_HOLDER" 2>/dev/null; then
|
|
_PW_STALE=1
|
|
elif [ -n "$_PW_LOCK_OLD" ]; then
|
|
# The holder is alive but the lock is older than the install bound: the
|
|
# holder is past its own deadline, or its pid was recycled to an
|
|
# unrelated long-lived process. Either way nobody is installing.
|
|
_PW_STALE=1
|
|
fi
|
|
fi
|
|
if [ "$_PW_STALE" -eq 1 ]; then
|
|
echo " reclaiming stale Chromium-install lock (holder pid ${_PW_HOLDER:-?} is gone or past the install bound)" >&2
|
|
# Rename first: two setups judging the same lock stale race on rm -rf +
|
|
# mkdir, and the loser would delete the winner's fresh lock. mv of a
|
|
# directory is atomic, so exactly one of them reclaims — and if the dir
|
|
# we moved already belongs to a NEW live holder (it re-created the lock
|
|
# between our judgment and our mv), hand it straight back.
|
|
if mv "$_PW_LOCK" "$_PW_LOCK.stale.$$" 2>/dev/null; then
|
|
_PW_MOVED_PID=$(cat "$_PW_LOCK.stale.$$/pid" 2>/dev/null || true)
|
|
case "$_PW_MOVED_PID" in ''|*[!0-9]*) _PW_MOVED_PID=0 ;; esac
|
|
if { [ "$_PW_MOVED_PID" -gt 0 ] && [ "$_PW_MOVED_PID" != "$_PW_HOLDER" ] && kill -0 "$_PW_MOVED_PID" 2>/dev/null; } \
|
|
|| { [ ! -f "$_PW_LOCK.stale.$$/pid" ] && [ -z "$_PW_LOCK_OLD" ]; }; then
|
|
# A new live holder, or a fresh lock whose holder has not written its
|
|
# pid yet (mkdir done, echo pending): not ours to reclaim.
|
|
mv "$_PW_LOCK.stale.$$" "$_PW_LOCK" 2>/dev/null || true
|
|
else
|
|
rm -rf "$_PW_LOCK.stale.$$" 2>/dev/null || true
|
|
fi
|
|
fi
|
|
fi
|
|
fi
|
|
if mkdir "$_PW_LOCK" 2>/dev/null; then
|
|
echo "$$" > "$_PW_LOCK/pid" 2>/dev/null || true
|
|
# Chain the earlier cleanup_copied_bun EXIT trap: `trap ... EXIT` REPLACES
|
|
# the previous handler, so the lock trap must run both or any run taking
|
|
# this path leaves .tmp-bun-bin behind.
|
|
trap 'rm -rf "$_PW_LOCK" 2>/dev/null || true; cleanup_copied_bun' EXIT
|
|
# `bun x`, not `bunx`: a winget Bun ships no bunx (#3131).
|
|
_PW_BUN="$(command -v "$BUN_CMD" 2>/dev/null || printf '%s' "$BUN_CMD")"
|
|
(
|
|
cd "$SOURCE_GSTACK_DIR"
|
|
if [ -n "$_PLAYWRIGHT_PLATFORM_OVERRIDE" ]; then
|
|
PLAYWRIGHT_HOST_PLATFORM_OVERRIDE="$_PLAYWRIGHT_PLATFORM_OVERRIDE" bun_cmd x playwright install chromium
|
|
else
|
|
bun_cmd x playwright install chromium
|
|
fi
|
|
) &
|
|
_PW_PID=$!
|
|
# Ctrl-C during the download: a backgrounded child ignores SIGINT, so
|
|
# without this the installer would keep running as an orphan while the
|
|
# EXIT trap frees the lock — the #2136 pile-up the lock exists to prevent.
|
|
trap '_kill_tree "$_PW_PID" 2>/dev/null; rm -rf "$_PW_LOCK" 2>/dev/null || true; cleanup_copied_bun; exit 130' INT TERM
|
|
_PW_RC=0
|
|
_wait_with_deadline "$_PW_PID" "$_PW_INSTALL_TIMEOUT" || _PW_RC=$?
|
|
trap - INT TERM
|
|
if [ "$_PW_RC" -eq 124 ]; then
|
|
_pw_fail chromium-install-timeout "$_PW_BUN x playwright install chromium exceeded ${_PW_INSTALL_TIMEOUT}s and was killed (raise with GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT=<seconds>)"
|
|
elif [ "$_PW_RC" -eq 127 ]; then
|
|
_pw_fail chromium-install "$_PW_BUN x playwright install chromium exited 127 (command not found); install Bun from bun.sh (winget Bun ships no bunx), or skip with ./setup --no-browser (Git Bash) or .\\setup.ps1 --no-browser (PowerShell)"
|
|
elif [ "$_PW_RC" -ne 0 ]; then
|
|
_pw_fail chromium-install "$_PW_BUN x playwright install chromium exited $_PW_RC (offline, proxy, or blocked download?)"
|
|
fi
|
|
rm -rf "$_PW_LOCK" 2>/dev/null || true
|
|
# Restore the original handler (never `trap - EXIT`, which would clear
|
|
# cleanup_copied_bun for the rest of the script).
|
|
trap cleanup_copied_bun EXIT
|
|
else
|
|
_pw_fail chromium-install-locked "another gstack setup is installing Chromium (lock: $_PW_LOCK) — re-run ./setup after it finishes, or remove a stale lock: rm -rf \"$_PW_LOCK\""
|
|
fi
|
|
|
|
if [ -z "$_PW_FAIL_REASON" ] && [ "$IS_WINDOWS" -eq 1 ]; then
|
|
# On Windows, Node.js launches Chromium (not Bun — see oven-sh/bun#4253).
|
|
# Ensure playwright is importable by Node from the gstack directory.
|
|
if ! command -v node >/dev/null 2>&1; then
|
|
_pw_fail windows-no-node "Node.js is required on Windows to launch Chromium (Bun cannot: oven-sh/bun#4253) — install from https://nodejs.org/ and re-run ./setup"
|
|
else
|
|
echo "Windows detected — verifying Node.js can load Playwright..."
|
|
if ! (
|
|
cd "$SOURCE_GSTACK_DIR"
|
|
# Bun's node_modules already has playwright; verify Node can require it.
|
|
# @ngrok/ngrok is externalized in server-node.mjs and resolved at runtime;
|
|
# verify the platform-specific native binary is installed so /pair-agent
|
|
# tunnels don't fail later with a cryptic module-not-found error.
|
|
# &&-chained: errexit is off inside an `if` condition, so a failed npm
|
|
# install on the first line must not be masked by the second.
|
|
{ node -e "require('playwright')" 2>/dev/null || npm install --no-save playwright; } &&
|
|
{ node -e "require('@ngrok/ngrok')" 2>/dev/null || npm install --no-save @ngrok/ngrok; }
|
|
); then
|
|
_pw_fail windows-node-modules "npm could not install playwright / @ngrok/ngrok for Node.js"
|
|
fi
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
if [ -z "$_PW_FAIL_REASON" ] && ! ensure_playwright_browser; then
|
|
if [ "$IS_WINDOWS" -eq 1 ]; then
|
|
_pw_fail post-install-launch "Playwright Chromium could not be launched via Node.js (oven-sh/bun#4253) — ensure 'node -e \"require('playwright')\"' works, then re-run ./setup"
|
|
else
|
|
_pw_fail post-install-launch "Playwright Chromium installed but could not be launched — see the launch error above. For AppArmor/user-namespace errors on Ubuntu 24.04+, try GSTACK_CHROMIUM_NO_SANDBOX=1 (#2157)"
|
|
fi
|
|
fi
|
|
|
|
# 2b. Ensure a color-emoji font is installed so make-pdf emoji render (Linux).
|
|
# Best-effort: warn instead of failing if it can't install.
|
|
if ! ensure_emoji_font; then
|
|
echo " Note: could not auto-install a color-emoji font. Emoji in make-pdf" >&2
|
|
echo " output may render as boxes (▯). Install one manually, e.g.:" >&2
|
|
echo " Debian/Ubuntu: sudo apt-get install fonts-noto-color-emoji" >&2
|
|
echo " Fedora: sudo dnf install google-noto-color-emoji-fonts" >&2
|
|
echo " Arch: sudo pacman -S noto-fonts-emoji" >&2
|
|
echo " Alpine: sudo apk add font-noto-emoji" >&2
|
|
elif [ -z "$_PW_FAIL_REASON" ]; then
|
|
# Only when Chromium is actually usable — restarting a daemon that cannot
|
|
# launch its browser just produces a second failure line.
|
|
refresh_browse_daemon_for_fonts
|
|
fi
|
|
|
|
# 3. Ensure ~/.gstack global state directory exists
|
|
mkdir -p "$HOME/.gstack/projects"
|
|
|
|
# ─── Helper: link a skill's runtime assets into its installed dir ────────────
|
|
# Installs EVERY runtime asset a skill ships next to its SKILL.md (#2317,
|
|
# #2454): review/checklist.md + specialists/, qa/templates + references,
|
|
# gstack-upgrade/migrations, careful/bin, freeze/bin, sections/, etc.
|
|
# Exclusion list rather than inclusion list (F7) so a new asset file is
|
|
# installed by default instead of silently dropped:
|
|
# - SKILL.md linked separately by the caller (name-aware)
|
|
# - node_modules dependency trees, never a runtime read
|
|
# - dist compiled binaries; skills reference them repo-anchored
|
|
# (~/.claude/skills/gstack/browse/dist/...), never
|
|
# alias-relative, and fresh clones haven't built them
|
|
# - test test fixtures
|
|
# - *.tmpl generator sources; the generated file is the asset
|
|
# - hidden files excluded by the glob (no dotglob)
|
|
# Shared so any flattened-skill installer can reuse it (the Claude path is
|
|
# the first consumer; codex/factory/opencode install from generated trees).
|
|
_link_skill_runtime_assets() {
|
|
local src_dir="$1"
|
|
local dst_dir="$2"
|
|
# $3 = 0 when the destination directory is not provably ours (pre-existed
|
|
# unclaimed, or only weakly proven where gstack never wrote real assets): its
|
|
# real files are the user's, so a same-named real asset is kept and reported
|
|
# instead of replaced (#2119). Symlinks are never content and are always
|
|
# refreshed. Default 1 = the directory is ours.
|
|
local replace_real="${3:-1}"
|
|
# $4 = this skill's directory in the gbrain render, or empty (F5, #2706).
|
|
# The rendered SKILL.md is served from there, and its section index names
|
|
# sections relative to the installed directory, so a rendered counterpart of
|
|
# an asset is served per file over the checkout's (render dirs carry only
|
|
# generated *.md; manifest.json and other assets still come from the checkout).
|
|
local render_dir="${4:-}"
|
|
local asset asset_name child
|
|
for asset in "$src_dir"/*; do
|
|
[ -e "$asset" ] || continue # empty-glob guard
|
|
asset_name="$(basename "$asset")"
|
|
case "$asset_name" in
|
|
SKILL.md|node_modules|dist|test|*.tmpl) continue ;;
|
|
esac
|
|
if [ -e "$dst_dir/$asset_name" ] && [ ! -L "$dst_dir/$asset_name" ] && [ "$replace_real" != "1" ]; then
|
|
echo " kept ${dst_dir##*/}/$asset_name: a file you own already uses that name — left untouched" >&2
|
|
continue
|
|
fi
|
|
# Refresh: rm the old entry (symlink OR real copy — the Windows install
|
|
# pattern) so re-runs after `git pull` pick up changes.
|
|
if [ -e "$dst_dir/$asset_name" ] || [ -L "$dst_dir/$asset_name" ]; then
|
|
rm -rf "$dst_dir/$asset_name"
|
|
fi
|
|
if [ -n "$render_dir" ] && [ -d "$asset" ] && [ -d "$render_dir/$asset_name" ]; then
|
|
mkdir -p "$dst_dir/$asset_name"
|
|
for child in "$asset"/*; do
|
|
[ -e "$child" ] || continue
|
|
if [ -f "$render_dir/$asset_name/${child##*/}" ]; then
|
|
_link_or_copy "$render_dir/$asset_name/${child##*/}" "$dst_dir/$asset_name/${child##*/}"
|
|
else
|
|
_link_or_copy "$child" "$dst_dir/$asset_name/${child##*/}"
|
|
fi
|
|
done
|
|
elif [ -n "$render_dir" ] && [ -f "$asset" ] && [ -f "$render_dir/$asset_name" ]; then
|
|
_link_or_copy "$render_dir/$asset_name" "$dst_dir/$asset_name"
|
|
else
|
|
_link_or_copy "$asset" "$dst_dir/$asset_name"
|
|
fi
|
|
# P5: the exclusion list above filters DIRECT children only, but the
|
|
# Windows cp -R copy sweeps NESTED gitignored build output too (concrete:
|
|
# ios-qa/scripts/gen-accessors-tool/.build is 252MB). Prune post-copy —
|
|
# a rendered skill install is never a build root, so nested
|
|
# node_modules/.build/dist are dead weight. ONLY here: the generic
|
|
# _link_or_copy stays untouched because runtime roots (browse/, design/)
|
|
# intentionally copy their dist/ binaries.
|
|
if [ "$IS_WINDOWS" -eq 1 ] && [ -d "$dst_dir/$asset_name" ] && [ ! -L "$dst_dir/$asset_name" ]; then
|
|
find "$dst_dir/$asset_name" -type d \( -name node_modules -o -name .build -o -name dist \) -prune -exec rm -rf {} + 2>/dev/null || true
|
|
fi
|
|
done
|
|
}
|
|
|
|
# ─── Helper: link Claude skill subdirectories into a skills parent directory ──
|
|
# Creates real directories (not symlinks) at the top level with a SKILL.md symlink
|
|
# inside. This ensures Claude discovers them as top-level skills, not nested under
|
|
# gstack/ (which would auto-prefix them as gstack-*).
|
|
# When SKILL_PREFIX=1, directories are prefixed with "gstack-".
|
|
# Use --no-prefix to restore flat names.
|
|
# Run gstack-relink and surface only what the user must see: foreign entries it
|
|
# skipped (deduped against the ones this setup already reported, same wording)
|
|
# and any pre-existing SKILL.md it moved to the backup root.
|
|
_run_relink_quiet() {
|
|
local out line name
|
|
local rc=0
|
|
out="$(GSTACK_SKILLS_DIR="$INSTALL_SKILLS_DIR" GSTACK_INSTALL_DIR="$SOURCE_GSTACK_DIR" "$GSTACK_RELINK" 2>&1)" || rc=$?
|
|
if [ "$rc" -ne 0 ]; then
|
|
echo " warning: gstack-relink failed (exit $rc) for $INSTALL_SKILLS_DIR; skill names may not match skill_prefix. Output:" >&2
|
|
printf '%s\n' "$out" | tail -5 | sed 's/^/ /' >&2
|
|
fi
|
|
while IFS= read -r line; do
|
|
case "$line" in
|
|
' skipped '*)
|
|
name="${line# skipped }"; name="${name%%:*}"
|
|
case " ${_FOREIGN_SKIPPED_ENTRIES[*]:-} " in
|
|
*" $name "*) ;;
|
|
*) echo "$line" >&2; _FOREIGN_SKIPPED_ENTRIES+=("$name") ;;
|
|
esac ;;
|
|
'Moved '*|' cleaned '*) echo " ${line# }" >&2 ;;
|
|
esac
|
|
done <<EOF
|
|
$out
|
|
EOF
|
|
}
|
|
|
|
link_claude_skill_dirs() {
|
|
local gstack_dir="$1"
|
|
local skills_dir="$2"
|
|
local linked=()
|
|
for skill_dir in "$gstack_dir"/*/; do
|
|
if [ -f "$skill_dir/SKILL.md" ]; then
|
|
dir_name="$(basename "$skill_dir")"
|
|
# Skip node_modules
|
|
[ "$dir_name" = "node_modules" ] && continue
|
|
# Use frontmatter name: if present (e.g., run-tests/ with name: test → symlink as "test")
|
|
skill_name=$(grep -m1 '^name:' "$skill_dir/SKILL.md" 2>/dev/null | sed 's/^name:[[:space:]]*//' | tr -d '[:space:]')
|
|
[ -z "$skill_name" ] && skill_name="$dir_name"
|
|
case "${_DISABLED_SKILLS:- }" in *" $skill_name "*|*" $dir_name "*) continue ;; esac
|
|
# Apply gstack- prefix unless --no-prefix or already prefixed
|
|
if [ "$SKILL_PREFIX" -eq 1 ]; then
|
|
case "$skill_name" in
|
|
gstack-*) link_name="$skill_name" ;;
|
|
*) link_name="gstack-$skill_name" ;;
|
|
esac
|
|
else
|
|
link_name="$skill_name"
|
|
fi
|
|
target="$skills_dir/$link_name"
|
|
# #2119: a destination that exists and is NOT ours is a user's skill that
|
|
# shares our name. Never rm/mkdir/ln into it — skip and report.
|
|
if { [ -e "$target" ] || [ -L "$target" ]; } && ! _claude_entry_is_ours "$target" "$gstack_dir/$dir_name/SKILL.md" "$gstack_dir"; then
|
|
echo " skipped $link_name: existing entry is not gstack-managed (foreign skill with the same name) — left untouched" >&2
|
|
_FOREIGN_SKIPPED_ENTRIES+=("$link_name")
|
|
continue
|
|
fi
|
|
# Remember whether WE are creating this directory: only then may the
|
|
# provenance marker below make it deletable whole. A directory we merely
|
|
# link into (unclaimed, or a legacy install) never gets one — legacy
|
|
# all-links dirs are removed by the only-links rule instead.
|
|
# _assets_replace: may _link_skill_runtime_assets replace a REAL file or
|
|
# dir already present under the target? Yes for a directory we create or
|
|
# strongly own (marker, or SKILL.md symlink into gstack). On Windows also
|
|
# for a weakly-proven real-file copy install (the legacy pre-marker shape,
|
|
# whose asset copies are ours). Otherwise (unclaimed, or a weak copy on a
|
|
# platform where gstack never wrote real assets) real files are the
|
|
# user's and are kept.
|
|
_pre_exists=0; _assets_replace=1
|
|
if [ -e "$target" ] || [ -L "$target" ]; then
|
|
_pre_exists=1
|
|
if _claude_entry_owned_strongly "$target" "$gstack_dir"; then _assets_replace=1
|
|
elif [ "$IS_WINDOWS" -eq 1 ] && [ -f "$target/SKILL.md" ] && [ ! -L "$target/SKILL.md" ]; then _assets_replace=1
|
|
else _assets_replace=0
|
|
fi
|
|
fi
|
|
# Upgrade old directory symlinks to real directories
|
|
if [ -L "$target" ]; then
|
|
rm -f "$target"
|
|
fi
|
|
# Create real directory with symlinked SKILL.md (absolute path)
|
|
# Use mkdir -p unconditionally (idempotent) to avoid TOCTOU race
|
|
mkdir -p "$target"
|
|
# Validate target isn't a symlink before creating the link
|
|
if [ -L "$target/SKILL.md" ]; then rm "$target/SKILL.md"; fi
|
|
# #2569: prefer a rendered :user variant when present. gbrain installs
|
|
# render brain-aware SKILL.md into ${GSTACK_HOME}/render/claude via
|
|
# gen:skill-docs --out-dir instead of dirtying the tracked source
|
|
# checkout; when a render exists for this skill, serve it. Its STOP
|
|
# pointers are absolute render-dir paths, but its section index names
|
|
# `sections/<f>.md` relative to this directory, so the runtime assets
|
|
# below prefer the render too (F5, #2706).
|
|
_skill_md_src="$gstack_dir/$dir_name/SKILL.md"
|
|
_render_dir="${GSTACK_USER_RENDER_DIR:-$GSTACK_STATE_ROOT/render/claude}"
|
|
_skill_render=""
|
|
if [ -f "$_render_dir/$dir_name/SKILL.md" ]; then
|
|
_skill_md_src="$_render_dir/$dir_name/SKILL.md"
|
|
_skill_render="$_render_dir/$dir_name"
|
|
fi
|
|
# A real-file SKILL.md we can only WEAKLY prove ours and whose content
|
|
# differs from what we are about to serve is moved aside, not overwritten.
|
|
if [ -f "$target/SKILL.md" ] && [ ! -L "$target/SKILL.md" ] && ! _claude_entry_owned_strongly "$target" "$gstack_dir" \
|
|
&& ! cmp -s "$target/SKILL.md" "$_skill_md_src"; then
|
|
if ! _backup_skill_md "$target/SKILL.md" "$link_name"; then
|
|
echo " skipped $link_name: could not back up its customized SKILL.md — left untouched" >&2
|
|
_FOREIGN_SKIPPED_ENTRIES+=("$link_name")
|
|
continue
|
|
fi
|
|
fi
|
|
_link_or_copy "$_skill_md_src" "$target/SKILL.md"
|
|
# Provenance marker (#2119) on every platform — path-independent proof
|
|
# for Windows copies and for checkouts whose path carries no `gstack`
|
|
# segment — but only for a directory we created or already owned: a
|
|
# pre-existing unclaimed or weakly-proven directory must not become
|
|
# deletable whole because we linked one file into it.
|
|
if [ "$_pre_exists" -eq 0 ] || [ -f "$target/.gstack-owned" ]; then _write_owned_marker "$target" "$gstack_dir"; fi
|
|
# Link every runtime asset the skill ships next to its SKILL.md (#2317,
|
|
# #2454): sections/ for carved skills, review's checklist.md +
|
|
# specialists/, qa's templates/ + references/, gstack-upgrade's
|
|
# migrations/, careful/freeze's bin/, ... Without this, only SKILL.md
|
|
# landed and /review 404'd at "Read .claude/skills/review/checklist.md"
|
|
# on every fresh Claude install. Routes through _link_or_copy so Windows
|
|
# gets real copies refreshed on every ./setup.
|
|
_link_skill_runtime_assets "$gstack_dir/$dir_name" "$target" "$_assets_replace" "$_skill_render"
|
|
linked+=("$link_name")
|
|
fi
|
|
done
|
|
if [ ${#linked[@]} -gt 0 ]; then
|
|
echo " linked skills: ${linked[*]}"
|
|
_print_windows_copy_note_once
|
|
fi
|
|
}
|
|
|
|
# ─── Helper: install an alias SKILL.md as a rewritten COPY ───────────────────
|
|
# Alias dirs (_gstack-command, connect-chrome) must NOT symlink the canonical
|
|
# SKILL.md: the alias then carries the canonical frontmatter name:, Claude Code
|
|
# sees two skills with the same name, and drops the ENTIRE personal-skills set
|
|
# (#2511, #2201). Copy-then-rewrite instead: sed reads the SOURCE and writes a
|
|
# fresh copy with name: set to the alias. It must never edit through an
|
|
# existing symlink — that would rewrite the generated source file itself.
|
|
# NOTE: every alias name passed to this helper (_gstack-command,
|
|
# connect-chrome, gstack-connect-chrome) is hardcoded in the _INVENTORY seed
|
|
# list in bin/gstack-uninstall — keep the two sites in sync when adding or
|
|
# renaming an alias, or uninstall will refuse to delete the new alias dir.
|
|
_install_alias_skill_md() {
|
|
case "${_DISABLED_SKILLS:- }" in *" $(basename "$(dirname "$1")") "*) return 0 ;; esac
|
|
local src_skill_md="$1"
|
|
local dst_dir="$2"
|
|
local alias_name="$3"
|
|
[ -f "$src_skill_md" ] || return 0
|
|
# #2119: an existing alias-named entry that is not ours is a user's skill.
|
|
# (Ours: a whole-dir symlink into gstack, or a copy carrying the generated
|
|
# header — every alias copy does.)
|
|
if { [ -e "$dst_dir" ] || [ -L "$dst_dir" ]; } && ! _claude_entry_is_ours "$dst_dir" "$src_skill_md" "$SOURCE_GSTACK_DIR"; then
|
|
echo " skipped $alias_name: existing entry is not gstack-managed (foreign skill with the same name) — left untouched" >&2
|
|
_FOREIGN_SKIPPED_ENTRIES+=("$alias_name")
|
|
return 0
|
|
fi
|
|
# Old installs left the alias as a whole-dir symlink — replace it.
|
|
_alias_pre=0
|
|
if [ -e "$dst_dir" ] || [ -L "$dst_dir" ]; then _alias_pre=1; fi
|
|
if [ -L "$dst_dir" ]; then rm -f "$dst_dir"; fi
|
|
mkdir -p "$dst_dir"
|
|
# Remove any prior symlinked SKILL.md so the redirect below cannot write
|
|
# through it into the generated source.
|
|
rm -f "$dst_dir/SKILL.md"
|
|
sed "1,/^---\$/ s/^name:[[:space:]].*/name: $alias_name/" "$src_skill_md" > "$dst_dir/SKILL.md"
|
|
# A rewritten copy is a real file on every platform; the marker proves it
|
|
# ours on the next run without leaning on the banner — but only for a
|
|
# directory we created (or already marked), never one we merely wrote into.
|
|
if [ "$_alias_pre" -eq 0 ] || [ -f "$dst_dir/.gstack-owned" ]; then _write_owned_marker "$dst_dir" "$SOURCE_GSTACK_DIR"; fi
|
|
}
|
|
|
|
# Claude Code skips the repo-shaped ~/.claude/skills/gstack directory when
|
|
# building the user-facing slash-command list. Keep the repo path for runtime
|
|
# assets, and add a separate thin wrapper. Its frontmatter name is rewritten to
|
|
# `_gstack-command` (the dir name) so it never collides with the canonical
|
|
# `gstack` name (#2511).
|
|
link_claude_root_skill_alias() {
|
|
local gstack_dir="$1"
|
|
local skills_dir="$2"
|
|
local target="$skills_dir/_gstack-command"
|
|
|
|
[ -f "$gstack_dir/SKILL.md" ] || return 0
|
|
_install_alias_skill_md "$gstack_dir/SKILL.md" "$target" "_gstack-command"
|
|
echo " linked root skill alias: gstack"
|
|
}
|
|
|
|
# ─── Helper: remove old unprefixed Claude skill entries ───────────────────────
|
|
# Migration: when switching from flat names to gstack- prefixed names,
|
|
# clean up stale symlinks or directories that point into the gstack directory.
|
|
# Scan $skills_dir (not $gstack_dir): orphans live next to the payload, so a
|
|
# missing payload must still be able to reap leftover flat names (#2204).
|
|
cleanup_old_claude_symlinks() {
|
|
local gstack_dir="$1"
|
|
local skills_dir="$2"
|
|
local removed=()
|
|
local old_target skill_name link_dest skill_dir
|
|
# Destination scan. The glob already yields dangling dir symlinks; [ -e ]
|
|
# alone would skip them, so [ -L ] keeps those entries. An unmatched `*`
|
|
# literal (empty skills_dir) is rejected by the same guard.
|
|
for old_target in "$skills_dir"/*; do
|
|
[ -e "$old_target" ] || [ -L "$old_target" ] || continue
|
|
skill_name="$(basename "$old_target")"
|
|
[ "$skill_name" = "node_modules" ] && continue
|
|
[ "$skill_name" = "gstack" ] && continue
|
|
# Skip already-prefixed dirs (gstack-upgrade) — no old symlink to clean
|
|
case "$skill_name" in gstack-*) continue ;; esac
|
|
# Remove directory symlinks pointing into gstack/
|
|
if [ -L "$old_target" ]; then
|
|
link_dest="$(readlink "$old_target" 2>/dev/null || true)"
|
|
case "$link_dest" in
|
|
gstack/*|*/gstack/*)
|
|
rm -f "$old_target"
|
|
removed+=("$skill_name")
|
|
;;
|
|
esac
|
|
# Remove real directories with symlinked SKILL.md pointing into gstack/
|
|
elif [ -d "$old_target" ] && [ -L "$old_target/SKILL.md" ]; then
|
|
link_dest="$(readlink "$old_target/SKILL.md" 2>/dev/null || true)"
|
|
# Anchored path segments (same as the dir-symlink arm and
|
|
# gstack-uninstall #2563). A bare *gstack* substring would wipe a
|
|
# user skill under e.g. ~/tools/gstack-fork/. Also accept the #2569
|
|
# render prefix (~/.gstack/render/claude/...), which is not `/gstack/`.
|
|
case "$link_dest" in
|
|
gstack/*|*/gstack/*|*/.gstack/render/claude/*)
|
|
_cleanup_linked_dir "$old_target" "$gstack_dir"
|
|
removed+=("$skill_name")
|
|
;;
|
|
esac
|
|
fi
|
|
done
|
|
# Windows install pattern: real dir with real-file SKILL.md (no symlink
|
|
# available, so we can't readlink to verify provenance). A bare name match
|
|
# deleted a user's own same-name skill (#2119); ownership is now proven by
|
|
# the .gstack-owned marker link_claude_skill_dirs writes, or — for copies
|
|
# made before the marker existed — by the copy being byte-identical to the
|
|
# gstack source SKILL.md or carrying gen-skill-docs' AUTO-GENERATED header
|
|
# (every generated SKILL.md does; a hand-written skill does not). Anything
|
|
# else is foreign and is left alone.
|
|
if [ "${IS_WINDOWS:-0}" -eq 1 ] && [ -d "$gstack_dir" ]; then
|
|
for skill_dir in "$gstack_dir"/*/; do
|
|
if [ -f "$skill_dir/SKILL.md" ]; then
|
|
skill_name="$(basename "$skill_dir")"
|
|
[ "$skill_name" = "node_modules" ] && continue
|
|
case "$skill_name" in gstack-*) continue ;; esac
|
|
old_target="$skills_dir/$skill_name"
|
|
if [ -d "$old_target" ] && [ ! -L "$old_target" ] \
|
|
&& [ -f "$old_target/SKILL.md" ] && [ ! -L "$old_target/SKILL.md" ] \
|
|
&& { [ -f "$old_target/.gstack-owned" ] \
|
|
|| cmp -s "$old_target/SKILL.md" "$skill_dir/SKILL.md" \
|
|
|| _gstack_generated_header "$old_target/SKILL.md"; }; then
|
|
# Only the marker proves we created the directory; weak proof covers
|
|
# the SKILL.md alone (a user's files next to it survive).
|
|
if [ -f "$old_target/.gstack-owned" ]; then rm -rf "$old_target"; else _cleanup_weak_dir "$old_target" "$gstack_dir" "$skill_dir/SKILL.md" "$skill_name"; fi
|
|
removed+=("$skill_name")
|
|
fi
|
|
fi
|
|
done
|
|
fi
|
|
if [ ${#removed[@]} -gt 0 ]; then
|
|
echo " cleaned up old entries: ${removed[*]} (removed from $skills_dir)"
|
|
fi
|
|
}
|
|
|
|
# ─── Helper: remove old prefixed Claude skill entries ─────────────────────────
|
|
# Reverse migration: when switching from gstack- prefixed names to flat names,
|
|
# clean up stale gstack-* symlinks or directories that point into the gstack directory.
|
|
cleanup_prefixed_claude_symlinks() {
|
|
local gstack_dir="$1"
|
|
local skills_dir="$2"
|
|
local removed=()
|
|
for skill_dir in "$gstack_dir"/*/; do
|
|
if [ -f "$skill_dir/SKILL.md" ]; then
|
|
skill_name="$(basename "$skill_dir")"
|
|
[ "$skill_name" = "node_modules" ] && continue
|
|
# Only clean up prefixed entries for dirs that AREN'T already prefixed
|
|
# (e.g., remove gstack-qa but NOT gstack-upgrade which is the real dir name)
|
|
case "$skill_name" in gstack-*) continue ;; esac
|
|
prefixed_target="$skills_dir/gstack-$skill_name"
|
|
# Remove directory symlinks pointing into gstack/ — anchored path
|
|
# segments, same as cleanup_old_claude_symlinks and gstack-uninstall: a
|
|
# bare *gstack* substring would wipe a user skill under ~/tools/gstack-fork/.
|
|
if [ -L "$prefixed_target" ]; then
|
|
link_dest="$(readlink "$prefixed_target" 2>/dev/null || true)"
|
|
case "$link_dest" in
|
|
gstack/*|*/gstack/*|*/.gstack/render/claude/*)
|
|
rm -f "$prefixed_target"
|
|
removed+=("gstack-$skill_name")
|
|
;;
|
|
esac
|
|
# Remove real directories with symlinked SKILL.md pointing into gstack/
|
|
elif [ -d "$prefixed_target" ] && [ -L "$prefixed_target/SKILL.md" ]; then
|
|
link_dest="$(readlink "$prefixed_target/SKILL.md" 2>/dev/null || true)"
|
|
case "$link_dest" in
|
|
gstack/*|*/gstack/*|*/.gstack/render/claude/*)
|
|
_cleanup_linked_dir "$prefixed_target" "$gstack_dir"
|
|
removed+=("gstack-$skill_name")
|
|
;;
|
|
esac
|
|
# Windows install pattern: real dir with real-file SKILL.md. Provenance
|
|
# must be PROVEN (#2119), never assumed from the name: the marker
|
|
# link_claude_skill_dirs writes, a byte-identical copy of the source, or
|
|
# gen-skill-docs' generated header (legacy copies made before the marker).
|
|
elif [ "$IS_WINDOWS" -eq 1 ] && [ -d "$prefixed_target" ] && [ -f "$prefixed_target/SKILL.md" ] && [ ! -L "$prefixed_target/SKILL.md" ] \
|
|
&& { [ -f "$prefixed_target/.gstack-owned" ] \
|
|
|| cmp -s "$prefixed_target/SKILL.md" "$skill_dir/SKILL.md" \
|
|
|| _gstack_generated_header "$prefixed_target/SKILL.md"; }; then
|
|
if [ -f "$prefixed_target/.gstack-owned" ]; then rm -rf "$prefixed_target"; else _cleanup_weak_dir "$prefixed_target" "$gstack_dir" "$skill_dir/SKILL.md" "gstack-$skill_name"; fi
|
|
removed+=("gstack-$skill_name")
|
|
fi
|
|
fi
|
|
done
|
|
if [ ${#removed[@]} -gt 0 ]; then
|
|
echo " cleaned up prefixed entries: ${removed[*]}"
|
|
fi
|
|
}
|
|
|
|
# ─── Helper: link generated Codex skills into a skills parent directory ──
|
|
# Installs from .agents/skills/gstack-* (the generated Codex-format skills)
|
|
# instead of source dirs (which have Claude paths).
|
|
link_codex_skill_dirs() {
|
|
local gstack_dir="$1"
|
|
local skills_dir="$2"
|
|
local agents_dir="$gstack_dir/.agents/skills"
|
|
local linked=()
|
|
|
|
if [ ! -d "$agents_dir" ]; then
|
|
echo " Generating .agents/ skill docs..."
|
|
( cd "$gstack_dir" && bun_cmd run gen:skill-docs --host codex --model "$CODEX_GENERATION_MODEL" ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"} )
|
|
fi
|
|
|
|
if [ ! -d "$agents_dir" ]; then
|
|
echo " warning: .agents/skills/ generation failed — run 'bun run gen:skill-docs --host codex --model $CODEX_GENERATION_MODEL' manually" >&2
|
|
return 1
|
|
fi
|
|
|
|
for skill_dir in "$agents_dir"/gstack*/; do
|
|
if [ -f "$skill_dir/SKILL.md" ]; then
|
|
skill_name="$(basename "$skill_dir")"
|
|
case "${_DISABLED_SKILLS:- }" in *" ${skill_name#gstack-} "*) _remove_disabled_host_entry "$skills_dir/$skill_name"; continue ;; esac
|
|
# Skip the sidecar directory — it contains runtime asset symlinks (bin/,
|
|
# browse/), not a skill. Linking it would overwrite the root gstack
|
|
# symlink that Step 5 already pointed at the repo root.
|
|
[ "$skill_name" = "gstack" ] && continue
|
|
if [ "${CODEX_REPO_LOCAL:-0}" -eq 1 ] && [ "$skill_name" = "gstack-claude" ]; then continue; fi
|
|
target="$skills_dir/$skill_name"
|
|
# Create or update symlink
|
|
# #2444: on Windows the installed target is a REAL directory copy, so
|
|
# the symlink-or-missing guard skipped every re-run and SKILL.md never
|
|
# refreshed after `git pull`. IS_WINDOWS bypasses the guard —
|
|
# _link_or_copy rm -rf's the destination first, refreshing the copy.
|
|
# #2142: a real dir may only be replaced when it is provably ours
|
|
# (_owned_for_windows_refresh), never a user's own colliding dir.
|
|
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: create .agents/skills/gstack/ sidecar symlinks ──────────
|
|
# Codex/Gemini/Cursor read skills from .agents/skills/. We link runtime
|
|
# assets (bin/, browse/dist/, review/, qa/, etc.) so skill templates can
|
|
# resolve paths like $SKILL_ROOT/review/design-checklist.md.
|
|
create_agents_sidecar() {
|
|
local repo_root="$1"
|
|
local agents_gstack="$repo_root/.agents/skills/gstack"
|
|
# #2142: a hand-written skill squatting on the canonical name is the
|
|
# user's — never write into it (the Windows branch would rm -rf its
|
|
# subdirs on every re-run).
|
|
if _sidecar_root_user_owned "$agents_gstack"; then
|
|
echo " left in place (existing dir not gstack-managed — no generated banner): $agents_gstack" >&2
|
|
return 0
|
|
fi
|
|
mkdir -p "$agents_gstack"
|
|
|
|
# Sidecar directories that skills reference at runtime. bin scripts import
|
|
# shared modules via ../lib, so bin and lib must always travel together.
|
|
for asset in bin lib browse review qa; do
|
|
local src="$SOURCE_GSTACK_DIR/$asset"
|
|
local dst="$agents_gstack/$asset"
|
|
if [ -d "$src" ] || [ -f "$src" ]; then
|
|
# #2444: IS_WINDOWS bypass — real-dir copies never match -L, so re-runs
|
|
# skipped the refresh. _link_or_copy rm -rf's the destination first.
|
|
if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$dst" ] || [ ! -e "$dst" ]; then
|
|
_link_or_copy "$src" "$dst"
|
|
fi
|
|
fi
|
|
done
|
|
|
|
# Sidecar files that skills reference at runtime
|
|
for file in ETHOS.md; do
|
|
local src="$SOURCE_GSTACK_DIR/$file"
|
|
local dst="$agents_gstack/$file"
|
|
if [ -f "$src" ]; then
|
|
# #2444: IS_WINDOWS bypass — real-dir copies never match -L, so re-runs
|
|
# skipped the refresh. _link_or_copy rm -rf's the destination first.
|
|
if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$dst" ] || [ ! -e "$dst" ]; then
|
|
_link_or_copy "$src" "$dst"
|
|
fi
|
|
fi
|
|
done
|
|
|
|
_link_runtime_dists "$SOURCE_GSTACK_DIR" "$agents_gstack"
|
|
|
|
# supabase/config.sh — required by gstack-telemetry-sync to resolve GSTACK_SUPABASE_URL
|
|
# (file-level on purpose: migrations/ and functions/ are dev-only)
|
|
if [ -f "$SOURCE_GSTACK_DIR/supabase/config.sh" ]; then
|
|
mkdir -p "$agents_gstack/supabase"
|
|
_link_or_copy "$SOURCE_GSTACK_DIR/supabase/config.sh" "$agents_gstack/supabase/config.sh"
|
|
fi
|
|
}
|
|
|
|
# ─── Helper: staged activation of a host runtime root (G0a) ──────────────────
|
|
# _activate_runtime_root CREATE_FN SOURCE ROOT — build the runtime root beside
|
|
# the live one ("$ROOT.gstack-new.<pid>") with errexit on, and swap it in only
|
|
# when the build succeeded. A failed refresh leaves the previous runtime root,
|
|
# and so the host's last working install, in place. The swap is two renames;
|
|
# a run killed between them leaves "$ROOT.gstack-old.<pid>", which the next
|
|
# run restores when ROOT is missing. A ROOT that is, or contains, the running
|
|
# checkout is refused before anything moves (_runtime_root_refused, #3084).
|
|
_activate_runtime_root() {
|
|
local fn="$1" src="$2" root="$3" new="$3.gstack-new.$$" old="$3.gstack-old.$$" rc leftover
|
|
_SETUP_ARM_ROOT="$root"
|
|
_runtime_root_refused "${_SETUP_ARM%% *}" "$root" && return 1
|
|
for leftover in "$root".gstack-old.*; do
|
|
[ -e "$leftover" ] || [ -L "$leftover" ] || continue
|
|
if [ ! -e "$root" ] && [ ! -L "$root" ]; then mv "$leftover" "$root"; else rm -rf "$leftover"; fi
|
|
done
|
|
rm -rf "$root".gstack-new.* 2>/dev/null || true
|
|
set +e
|
|
( set -e; "$fn" "$src" "$new" )
|
|
rc=$?
|
|
set -e
|
|
if [ "$rc" -ne 0 ]; then
|
|
rm -rf "$new"
|
|
echo " error: could not build the new runtime root for $root (exit $rc); the previous one is unchanged" >&2
|
|
return "$rc"
|
|
fi
|
|
if ! _preserve_skill_copy_edits "$root"; then
|
|
rm -rf "$new"
|
|
echo " error: could not back up an edited SKILL.md copy under $root; the previous runtime root is unchanged" >&2
|
|
return 1
|
|
fi
|
|
if [ -e "$root" ] || [ -L "$root" ]; then
|
|
mv "$root" "$old" || { rm -rf "$new"; return 1; }
|
|
fi
|
|
if ! mv "$new" "$root"; then
|
|
{ [ -e "$old" ] || [ -L "$old" ]; } && mv "$old" "$root"
|
|
rm -rf "$new"
|
|
return 1
|
|
fi
|
|
rm -rf "$old"
|
|
_record_skill_copies "$root" || echo " warning: could not record SKILL.md copy hashes in $_SKILL_COPIES_FILE; a later hand edit there may not be backed up" >&2
|
|
[ -n "$_COPIES_REFRESHED" ] && log " refreshed SKILL.md copies in $root: $_COPIES_REFRESHED"
|
|
[ -n "$_COPIES_REMOVED" ] && log " removed SKILL.md copies from $root: $_COPIES_REMOVED"
|
|
return 0
|
|
}
|
|
|
|
# ─── Helper: create a minimal ~/.codex/skills/gstack runtime root ───────────
|
|
# Codex scans ~/.codex/skills recursively. Exposing the whole repo here causes
|
|
# duplicate skills because source SKILL.md files and generated Codex skills are
|
|
# both discoverable. Keep this directory limited to runtime assets + root skill.
|
|
create_codex_runtime_root() {
|
|
local gstack_dir="$1"
|
|
local codex_gstack="$2"
|
|
local agents_dir="${_CODEX_RENDER_ROOT:-$gstack_dir}/.agents/skills"
|
|
|
|
if [ -L "$codex_gstack" ]; then
|
|
rm -f "$codex_gstack"
|
|
elif [ -d "$codex_gstack" ] && [ "$codex_gstack" != "$gstack_dir" ]; then
|
|
# Old direct installs left a real directory here with stale source skills.
|
|
# Remove it so we start fresh with only the minimal runtime assets.
|
|
rm -rf "$codex_gstack"
|
|
fi
|
|
|
|
mkdir -p "$codex_gstack" "$codex_gstack/browse" "$codex_gstack/gstack-upgrade" "$codex_gstack/review"
|
|
|
|
if [ -f "$agents_dir/gstack/SKILL.md" ]; then
|
|
_copy_skill_md "$agents_dir/gstack/SKILL.md" "$codex_gstack/SKILL.md"
|
|
fi
|
|
if [ -d "$gstack_dir/bin" ]; then
|
|
_link_or_copy "$gstack_dir/bin" "$codex_gstack/bin"
|
|
fi
|
|
if [ -d "$gstack_dir/lib" ]; then
|
|
_link_or_copy "$gstack_dir/lib" "$codex_gstack/lib"
|
|
fi
|
|
if [ -d "$gstack_dir/browse/dist" ]; then
|
|
_link_or_copy "$gstack_dir/browse/dist" "$codex_gstack/browse/dist"
|
|
fi
|
|
if [ -d "$gstack_dir/browse/bin" ]; then
|
|
_link_or_copy "$gstack_dir/browse/bin" "$codex_gstack/browse/bin"
|
|
fi
|
|
_link_runtime_dists "$gstack_dir" "$codex_gstack"
|
|
if [ -f "$agents_dir/gstack-upgrade/SKILL.md" ]; then
|
|
_copy_skill_md "$agents_dir/gstack-upgrade/SKILL.md" "$codex_gstack/gstack-upgrade/SKILL.md"
|
|
fi
|
|
_copy_runtime_skill_refs "$agents_dir" "$codex_gstack"
|
|
# ETHOS.md — referenced by "Search Before Building" in all skill preambles
|
|
if [ -f "$gstack_dir/ETHOS.md" ]; then
|
|
_link_or_copy "$gstack_dir/ETHOS.md" "$codex_gstack/ETHOS.md"
|
|
fi
|
|
# supabase/config.sh — required by gstack-telemetry-sync to resolve GSTACK_SUPABASE_URL
|
|
if [ -f "$gstack_dir/supabase/config.sh" ]; then
|
|
mkdir -p "$codex_gstack/supabase"
|
|
_link_or_copy "$gstack_dir/supabase/config.sh" "$codex_gstack/supabase/config.sh"
|
|
fi
|
|
}
|
|
|
|
create_factory_runtime_root() {
|
|
local gstack_dir="$1"
|
|
local factory_gstack="$2"
|
|
local factory_dir="$gstack_dir/.factory/skills"
|
|
|
|
if [ -L "$factory_gstack" ]; then
|
|
rm -f "$factory_gstack"
|
|
elif [ -d "$factory_gstack" ] && [ "$factory_gstack" != "$gstack_dir" ]; then
|
|
rm -rf "$factory_gstack"
|
|
fi
|
|
|
|
mkdir -p "$factory_gstack" "$factory_gstack/browse" "$factory_gstack/gstack-upgrade" "$factory_gstack/review"
|
|
|
|
if [ -f "$factory_dir/gstack/SKILL.md" ]; then
|
|
_copy_skill_md "$factory_dir/gstack/SKILL.md" "$factory_gstack/SKILL.md"
|
|
fi
|
|
if [ -d "$gstack_dir/bin" ]; then
|
|
_link_or_copy "$gstack_dir/bin" "$factory_gstack/bin"
|
|
fi
|
|
if [ -d "$gstack_dir/lib" ]; then
|
|
_link_or_copy "$gstack_dir/lib" "$factory_gstack/lib"
|
|
fi
|
|
if [ -d "$gstack_dir/browse/dist" ]; then
|
|
_link_or_copy "$gstack_dir/browse/dist" "$factory_gstack/browse/dist"
|
|
fi
|
|
if [ -d "$gstack_dir/browse/bin" ]; then
|
|
_link_or_copy "$gstack_dir/browse/bin" "$factory_gstack/browse/bin"
|
|
fi
|
|
_link_runtime_dists "$gstack_dir" "$factory_gstack"
|
|
if [ -f "$factory_dir/gstack-upgrade/SKILL.md" ]; then
|
|
_copy_skill_md "$factory_dir/gstack-upgrade/SKILL.md" "$factory_gstack/gstack-upgrade/SKILL.md"
|
|
fi
|
|
_copy_runtime_skill_refs "$factory_dir" "$factory_gstack"
|
|
if [ -f "$gstack_dir/ETHOS.md" ]; then
|
|
_link_or_copy "$gstack_dir/ETHOS.md" "$factory_gstack/ETHOS.md"
|
|
fi
|
|
# supabase/config.sh — required by gstack-telemetry-sync to resolve GSTACK_SUPABASE_URL
|
|
if [ -f "$gstack_dir/supabase/config.sh" ]; then
|
|
mkdir -p "$factory_gstack/supabase"
|
|
_link_or_copy "$gstack_dir/supabase/config.sh" "$factory_gstack/supabase/config.sh"
|
|
fi
|
|
}
|
|
|
|
create_opencode_runtime_root() {
|
|
local gstack_dir="$1"
|
|
local opencode_gstack="$2"
|
|
local opencode_dir="$gstack_dir/.opencode/skills"
|
|
|
|
if [ -L "$opencode_gstack" ]; then
|
|
rm -f "$opencode_gstack"
|
|
elif [ -d "$opencode_gstack" ] && [ "$opencode_gstack" != "$gstack_dir" ]; then
|
|
rm -rf "$opencode_gstack"
|
|
fi
|
|
|
|
mkdir -p "$opencode_gstack" "$opencode_gstack/browse" "$opencode_gstack/gstack-upgrade" "$opencode_gstack/review" "$opencode_gstack/qa"
|
|
|
|
if [ -f "$opencode_dir/gstack/SKILL.md" ]; then
|
|
_copy_skill_md "$opencode_dir/gstack/SKILL.md" "$opencode_gstack/SKILL.md"
|
|
fi
|
|
if [ -d "$gstack_dir/bin" ]; then
|
|
_link_or_copy "$gstack_dir/bin" "$opencode_gstack/bin"
|
|
fi
|
|
if [ -d "$gstack_dir/lib" ]; then
|
|
_link_or_copy "$gstack_dir/lib" "$opencode_gstack/lib"
|
|
fi
|
|
if [ -d "$gstack_dir/browse/dist" ]; then
|
|
_link_or_copy "$gstack_dir/browse/dist" "$opencode_gstack/browse/dist"
|
|
fi
|
|
if [ -d "$gstack_dir/browse/bin" ]; then
|
|
_link_or_copy "$gstack_dir/browse/bin" "$opencode_gstack/browse/bin"
|
|
fi
|
|
_link_runtime_dists "$gstack_dir" "$opencode_gstack"
|
|
if [ -f "$opencode_dir/gstack-upgrade/SKILL.md" ]; then
|
|
_copy_skill_md "$opencode_dir/gstack-upgrade/SKILL.md" "$opencode_gstack/gstack-upgrade/SKILL.md"
|
|
fi
|
|
_copy_runtime_skill_refs "$opencode_dir" "$opencode_gstack"
|
|
if [ -d "$gstack_dir/qa/templates" ]; then
|
|
_link_or_copy "$gstack_dir/qa/templates" "$opencode_gstack/qa/templates"
|
|
fi
|
|
if [ -d "$gstack_dir/qa/references" ]; then
|
|
_link_or_copy "$gstack_dir/qa/references" "$opencode_gstack/qa/references"
|
|
fi
|
|
if [ -f "$gstack_dir/ETHOS.md" ]; then
|
|
_link_or_copy "$gstack_dir/ETHOS.md" "$opencode_gstack/ETHOS.md"
|
|
fi
|
|
# supabase/config.sh — required by gstack-telemetry-sync to resolve GSTACK_SUPABASE_URL
|
|
if [ -f "$gstack_dir/supabase/config.sh" ]; then
|
|
mkdir -p "$opencode_gstack/supabase"
|
|
_link_or_copy "$gstack_dir/supabase/config.sh" "$opencode_gstack/supabase/config.sh"
|
|
fi
|
|
}
|
|
|
|
link_factory_skill_dirs() {
|
|
local gstack_dir="$1"
|
|
local skills_dir="$2"
|
|
local factory_dir="$gstack_dir/.factory/skills"
|
|
local linked=()
|
|
|
|
if [ ! -d "$factory_dir" ]; then
|
|
echo " Generating .factory/ skill docs..."
|
|
( cd "$gstack_dir" && bun_cmd run gen:skill-docs --host factory ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"} )
|
|
fi
|
|
|
|
if [ ! -d "$factory_dir" ]; then
|
|
echo " warning: .factory/skills/ generation failed — run 'bun run gen:skill-docs --host factory' manually" >&2
|
|
return 1
|
|
fi
|
|
|
|
for skill_dir in "$factory_dir"/gstack*/; do
|
|
if [ -f "$skill_dir/SKILL.md" ]; then
|
|
skill_name="$(basename "$skill_dir")"
|
|
case "${_DISABLED_SKILLS:- }" in *" ${skill_name#gstack-} "*) _remove_disabled_host_entry "$skills_dir/$skill_name"; continue ;; esac
|
|
[ "$skill_name" = "gstack" ] && continue
|
|
target="$skills_dir/$skill_name"
|
|
# #2444: on Windows the installed target is a REAL directory copy, so
|
|
# the symlink-or-missing guard skipped every re-run and SKILL.md never
|
|
# refreshed after `git pull`. IS_WINDOWS bypasses the guard —
|
|
# _link_or_copy rm -rf's the destination first, refreshing the copy.
|
|
# #2142: a real dir may only be replaced when it is provably ours
|
|
# (_owned_for_windows_refresh), never a user's own colliding dir.
|
|
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
|
|
}
|
|
|
|
link_opencode_skill_dirs() {
|
|
local gstack_dir="$1"
|
|
local skills_dir="$2"
|
|
local opencode_dir="$gstack_dir/.opencode/skills"
|
|
local linked=()
|
|
|
|
if [ ! -d "$opencode_dir" ]; then
|
|
echo " Generating .opencode/ skill docs..."
|
|
( cd "$gstack_dir" && bun_cmd run gen:skill-docs --host opencode ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"} )
|
|
fi
|
|
|
|
if [ ! -d "$opencode_dir" ]; then
|
|
echo " warning: .opencode/skills/ generation failed — run 'bun run gen:skill-docs --host opencode' manually" >&2
|
|
return 1
|
|
fi
|
|
|
|
for skill_dir in "$opencode_dir"/gstack*/; do
|
|
if [ -f "$skill_dir/SKILL.md" ]; then
|
|
skill_name="$(basename "$skill_dir")"
|
|
case "${_DISABLED_SKILLS:- }" in *" ${skill_name#gstack-} "*) _remove_disabled_host_entry "$skills_dir/$skill_name"; continue ;; esac
|
|
[ "$skill_name" = "gstack" ] && continue
|
|
target="$skills_dir/$skill_name"
|
|
# #2444: on Windows the installed target is a REAL directory copy, so
|
|
# the symlink-or-missing guard skipped every re-run and SKILL.md never
|
|
# refreshed after `git pull`. IS_WINDOWS bypasses the guard —
|
|
# _link_or_copy rm -rf's the destination first, refreshing the copy.
|
|
# #2142: a real dir may only be replaced when it is provably ours
|
|
# (_owned_for_windows_refresh), never a user's own colliding dir.
|
|
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: create a minimal ~/.cursor/skills/gstack runtime root ──────────
|
|
# Cursor scans ~/.cursor/skills. Same shape as the Codex/Factory/OpenCode
|
|
# runtime roots: root SKILL.md from the generated tree + runtime assets only.
|
|
# Contributed by @szsunyuan (PR #2547), re-derived onto the current installers.
|
|
create_cursor_runtime_root() {
|
|
local gstack_dir="$1"
|
|
local cursor_gstack="$2"
|
|
local cursor_dir="$gstack_dir/.cursor/skills"
|
|
local generated_root="$cursor_dir/gstack"
|
|
|
|
if [ -L "$cursor_gstack" ]; then
|
|
rm -f "$cursor_gstack"
|
|
elif _sidecar_root_user_owned "$cursor_gstack"; then
|
|
# #2142: a hand-written skill squatting on the canonical name is the
|
|
# user's — never wipe it to make room for the runtime root.
|
|
echo " left in place (existing dir not gstack-managed — no generated banner): $cursor_gstack" >&2
|
|
return 0
|
|
elif [ -d "$cursor_gstack" ] && [ "$cursor_gstack" != "$gstack_dir" ] && [ "$cursor_gstack" != "$generated_root" ]; then
|
|
rm -rf "$cursor_gstack"
|
|
fi
|
|
|
|
mkdir -p "$cursor_gstack" "$cursor_gstack/browse" "$cursor_gstack/gstack-upgrade" "$cursor_gstack/review"
|
|
|
|
if [ -f "$cursor_dir/gstack/SKILL.md" ]; then
|
|
_copy_skill_md "$cursor_dir/gstack/SKILL.md" "$cursor_gstack/SKILL.md"
|
|
fi
|
|
# bin scripts import shared modules via ../lib — bin and lib travel together.
|
|
if [ -d "$gstack_dir/bin" ]; then
|
|
_link_or_copy "$gstack_dir/bin" "$cursor_gstack/bin"
|
|
fi
|
|
if [ -d "$gstack_dir/lib" ]; then
|
|
_link_or_copy "$gstack_dir/lib" "$cursor_gstack/lib"
|
|
fi
|
|
if [ -d "$gstack_dir/browse/dist" ]; then
|
|
_link_or_copy "$gstack_dir/browse/dist" "$cursor_gstack/browse/dist"
|
|
fi
|
|
if [ -d "$gstack_dir/browse/bin" ]; then
|
|
_link_or_copy "$gstack_dir/browse/bin" "$cursor_gstack/browse/bin"
|
|
fi
|
|
_link_runtime_dists "$gstack_dir" "$cursor_gstack"
|
|
if [ -f "$cursor_dir/gstack-upgrade/SKILL.md" ]; then
|
|
_copy_skill_md "$cursor_dir/gstack-upgrade/SKILL.md" "$cursor_gstack/gstack-upgrade/SKILL.md"
|
|
fi
|
|
_copy_runtime_skill_refs "$cursor_dir" "$cursor_gstack"
|
|
if [ -f "$gstack_dir/ETHOS.md" ]; then
|
|
_link_or_copy "$gstack_dir/ETHOS.md" "$cursor_gstack/ETHOS.md"
|
|
fi
|
|
# supabase/config.sh — required by gstack-telemetry-sync to resolve GSTACK_SUPABASE_URL
|
|
if [ -f "$gstack_dir/supabase/config.sh" ]; then
|
|
mkdir -p "$cursor_gstack/supabase"
|
|
_link_or_copy "$gstack_dir/supabase/config.sh" "$cursor_gstack/supabase/config.sh"
|
|
fi
|
|
}
|
|
|
|
# Plant runtime assets into the repo-local generated skill dir so in-repo
|
|
# GSTACK_ROOT (preamble prefers $_ROOT/.cursor/skills/gstack) has bin/.
|
|
# NEVER wipe this directory — it holds the generated SKILL.md files.
|
|
create_cursor_sidecar() {
|
|
local repo_root="$1"
|
|
local cursor_gstack="$repo_root/.cursor/skills/gstack"
|
|
local cursor_dir="$repo_root/.cursor/skills"
|
|
|
|
# #2142: same user-ownership gate as create_agents_sidecar — but the
|
|
# generated tree's own root (cursor_dir/gstack carries the banner) always
|
|
# passes, so normal installs refresh as before.
|
|
if _sidecar_root_user_owned "$cursor_gstack"; then
|
|
echo " left in place (existing dir not gstack-managed — no generated banner): $cursor_gstack" >&2
|
|
return 0
|
|
fi
|
|
|
|
mkdir -p "$cursor_gstack" "$cursor_gstack/browse" "$cursor_gstack/gstack-upgrade" "$cursor_gstack/review"
|
|
|
|
if [ -d "$repo_root/bin" ]; then
|
|
if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$cursor_gstack/bin" ] || [ ! -e "$cursor_gstack/bin" ]; then
|
|
_link_or_copy "$repo_root/bin" "$cursor_gstack/bin"
|
|
fi
|
|
fi
|
|
if [ -d "$repo_root/lib" ]; then
|
|
if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$cursor_gstack/lib" ] || [ ! -e "$cursor_gstack/lib" ]; then
|
|
_link_or_copy "$repo_root/lib" "$cursor_gstack/lib"
|
|
fi
|
|
fi
|
|
if [ -d "$repo_root/browse/dist" ]; then
|
|
if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$cursor_gstack/browse/dist" ] || [ ! -e "$cursor_gstack/browse/dist" ]; then
|
|
_link_or_copy "$repo_root/browse/dist" "$cursor_gstack/browse/dist"
|
|
fi
|
|
fi
|
|
if [ -d "$repo_root/browse/bin" ]; then
|
|
if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$cursor_gstack/browse/bin" ] || [ ! -e "$cursor_gstack/browse/bin" ]; then
|
|
_link_or_copy "$repo_root/browse/bin" "$cursor_gstack/browse/bin"
|
|
fi
|
|
fi
|
|
_link_runtime_dists "$repo_root" "$cursor_gstack"
|
|
if [ -f "$cursor_dir/gstack-upgrade/SKILL.md" ]; then
|
|
if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$cursor_gstack/gstack-upgrade/SKILL.md" ] || [ ! -e "$cursor_gstack/gstack-upgrade/SKILL.md" ]; then
|
|
_link_or_copy "$cursor_dir/gstack-upgrade/SKILL.md" "$cursor_gstack/gstack-upgrade/SKILL.md"
|
|
fi
|
|
fi
|
|
if [ -f "$repo_root/ETHOS.md" ]; then
|
|
if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$cursor_gstack/ETHOS.md" ] || [ ! -e "$cursor_gstack/ETHOS.md" ]; then
|
|
_link_or_copy "$repo_root/ETHOS.md" "$cursor_gstack/ETHOS.md"
|
|
fi
|
|
fi
|
|
}
|
|
|
|
link_cursor_skill_dirs() {
|
|
local gstack_dir="$1"
|
|
local skills_dir="$2"
|
|
local cursor_dir="$gstack_dir/.cursor/skills"
|
|
local linked=()
|
|
|
|
if [ ! -d "$cursor_dir" ]; then
|
|
echo " Generating .cursor/ skill docs..."
|
|
( cd "$gstack_dir" && bun_cmd run gen:skill-docs --host cursor ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"} )
|
|
fi
|
|
|
|
if [ ! -d "$cursor_dir" ]; then
|
|
echo " warning: .cursor/skills/ generation failed — run 'bun run gen:skill-docs --host cursor' manually" >&2
|
|
return 1
|
|
fi
|
|
|
|
for skill_dir in "$cursor_dir"/gstack*/; do
|
|
if [ -f "$skill_dir/SKILL.md" ]; then
|
|
skill_name="$(basename "$skill_dir")"
|
|
case "${_DISABLED_SKILLS:- }" in *" ${skill_name#gstack-} "*) _remove_disabled_host_entry "$skills_dir/$skill_name"; continue ;; esac
|
|
[ "$skill_name" = "gstack" ] && continue
|
|
target="$skills_dir/$skill_name"
|
|
# #2444: IS_WINDOWS bypass — real-dir copies never match -L, so re-runs
|
|
# skipped the refresh. Only replace a symlink, a missing path, or a
|
|
# PROVABLY gstack-managed real dir; never a user's own Cursor skill
|
|
# dir that merely starts with gstack (#2142).
|
|
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: OpenCode slash commands for gstack skills (#2629) ───────────────
|
|
# Without a command file OpenCode's /<skill> routes to the `plan` primary agent
|
|
# and fails ("Task cancelled"). One managed command per skill runs it on the
|
|
# `build` agent in the current session. A command file without the managed
|
|
# marker is the user's and is never touched; managed files whose skill is gone
|
|
# or disabled are removed.
|
|
write_opencode_commands() {
|
|
local skills_dir="$1/.opencode/skills" cmd_dir="$2" d name file desc marker skill
|
|
marker='<!-- gstack-managed command (./setup --host opencode): edits are overwritten -->'
|
|
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"} )
|
|
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-<skill> 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"} )
|
|
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 <root>".
|
|
_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 <<EOF
|
|
$(_claude_scope "$INSTALL_SKILLS_DIR")
|
|
EOF
|
|
_setup_arm_begin claude "$_CL_SCOPE" "$INSTALL_SKILLS_DIR"
|
|
# 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
|
|
# Patch name: fields BEFORE creating symlinks so link_claude_skill_dirs
|
|
# reads the correct (patched) name: values for symlink naming
|
|
"$SOURCE_GSTACK_DIR/bin/gstack-patch-names" "$SOURCE_GSTACK_DIR" "$SKILL_PREFIX"
|
|
_render_claude_install "$INSTALL_GSTACK_DIR" || true
|
|
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
|
|
# Self-healing: re-run gstack-relink to ensure name: fields and directory
|
|
# names are consistent with the config. This catches cases where an interrupted
|
|
# setup, stale git state, or gen:skill-docs left name: fields out of sync.
|
|
GSTACK_RELINK="$SOURCE_GSTACK_DIR/bin/gstack-relink"
|
|
if [ -x "$GSTACK_RELINK" ]; then
|
|
_run_relink_quiet
|
|
fi
|
|
# Backwards-compat alias: /connect-chrome → /open-gstack-browser
|
|
# 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 "$_CL_SCOPE" "$_CL_PROJECT" "$INSTALL_SKILLS_DIR" "$INSTALL_GSTACK_DIR" "$(_claude_prefix_setting)" "${_CLAUDE_INSTALL_RENDER:-committed}"
|
|
if [ "$LOCAL_INSTALL" -eq 1 ]; then
|
|
log "gstack ready (project-local)."
|
|
log " skills: $INSTALL_SKILLS_DIR"
|
|
else
|
|
log "gstack ready (claude)."
|
|
fi
|
|
log " browse: $BROWSE_BIN"
|
|
_browser_hint
|
|
else
|
|
# Not inside a skills/ directory — would symlink the source into
|
|
# ~/.claude/skills/gstack/ and register from there.
|
|
CLAUDE_SKILLS_DIR="$HOME/.claude/skills"
|
|
CLAUDE_GSTACK_LINK="$CLAUDE_SKILLS_DIR/gstack"
|
|
|
|
# Conductor worktree guard: if ~/.claude/skills/gstack is already a real
|
|
# (non-symlink) directory pointing to a *different* install, refuse to plant
|
|
# a symlink there. On macOS/BSD, `ln -snf SRC DST` won't replace a real DST;
|
|
# it creates DST/$(basename SRC) → SRC inside it. The result is per-worktree
|
|
# symlinks leaking into the global install that Claude Code picks up as
|
|
# separate top-level skills (dublin-v1, lincoln-v2, ...). Typical trigger:
|
|
# running ./setup from a Conductor worktree of the gstack repo itself.
|
|
_SKIP_CLAUDE_REGISTER=0
|
|
if [ -d "$CLAUDE_GSTACK_LINK" ] && [ ! -L "$CLAUDE_GSTACK_LINK" ]; then
|
|
_EXISTING_REAL=$(cd "$CLAUDE_GSTACK_LINK" 2>/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"} )
|
|
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 <skill>/ 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-<skill>)"
|
|
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-<skill>)"
|
|
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 <<EOF
|
|
$(find "$MIGRATIONS_DIR" -maxdepth 1 -name 'v*.sh' -type f 2>/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.<ts>; 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 <your-repo> && $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"} 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 ]; 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.<ts> 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/tty 2>/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.<ts>; 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/tty 2>/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 <<EOF
|
|
$_glp_blocked
|
|
EOF
|
|
log " This is code integrity, not file permissions: the binaries are compiled on this machine and unsigned."
|
|
log " Everything else is installed. You ran setup from $shell, where the block applies; $wsl, and gstack inside WSL is not affected."
|
|
log " Workarounds: install gstack inside WSL, or turn Smart App Control off in Windows Security > 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 <<EOF
|
|
$_glp_broken
|
|
EOF
|
|
log " Rebuild: cd $SOURCE_GSTACK_DIR && bun run build"
|
|
fi
|
|
}
|
|
if [ "$IS_WINDOWS" -eq 1 ]; then
|
|
_windows_launch_summary
|
|
fi
|
|
|
|
# ─── Chromium bootstrap summary (best-effort browser, see # 2) ───────────────
|
|
# Printed LAST so it is the thing the user sees, after every skill registered.
|
|
# The skills that drive Aside first and use the bundled browser only as fallback
|
|
# (/pair-agent is not among them: it always runs on gstack's own browser). The
|
|
# Aside-absent list is derived from it so the two never drift.
|
|
_PW_ASIDE_SKILLS="/qa, /qa-only, /design-review, /browse, /scrape, /benchmark, /canary, make-pdf, /diagram"
|
|
_PW_BROWSER_SKILLS="$_PW_ASIDE_SKILLS, /pair-agent, and any other skill that drives the browser"
|
|
if [ "${_PW_FAIL_REASON:-}" = "skipped" ]; then
|
|
# An explicit opt-out is not a failure: say what is unavailable and stop.
|
|
log ""
|
|
log "Chromium install skipped by request (GSTACK_SKIP_PLAYWRIGHT=1)."
|
|
log " Browser skills ($_PW_BROWSER_SKILLS) need it; re-run ./setup without the flag when you want them."
|
|
elif [ -n "${_PW_FAIL_REASON:-}" ]; then
|
|
log ""
|
|
log "Browser unavailable: Chromium bootstrap did not complete ($_PW_FAIL_REASON)."
|
|
if [ "${GSTACK_SKIP_ASIDE:-}" != "1" ] && command -v aside >/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
|