mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-08 17:58:13 +01:00
W7 R2 R5 T6: ./setup --claude-model opt-in with a shared Claude render library
- bin/gstack-render-claude.sh: overlay resolution from claude_overlay_model (presence via gstack-config has), the pinned render without brain blocks, activation records beside each render, the change-only banner, the default-install committed fallback after a failed upgrade render, a mkdir lock around swaps, and the bun-free --status lines. - setup: --claude-model <id> / --claude-model=<id> (validated before any write, persisted only when Claude is selected, persisted by the --refresh-registered parent before children run), Model selection help, the --model error points at --claude-model, the overlay renders after gbrain detection whatever it found, and per-install renders pass --model. - gstack-config: claude_overlay_model key docs and default; gbrain-refresh renders the overlay in both branches and uses a PID-unique detection tmp. - test/claude-overlay-setup.test.ts drives real setup and gbrain-refresh.
This commit is contained in:
1 parent
50d896fa66
commit
52efb61a7f
6 files changed
+672
-22
No files matched your search
+47
-4
@@ -135,6 +135,14 @@ CONFIG_HEADER='# gstack configuration — edit freely, changes take effect on ne
|
||||
# # The vendor capture consent (`memorable enable`)
|
||||
# # is separate; gstack never sets it.
|
||||
#
|
||||
# ─── Claude skill overlay ────────────────────────────────────────────
|
||||
# claude_overlay_model: claude # Claude model ID whose skill overlay ./setup
|
||||
# # renders (e.g. claude-opus-4-8). Absent = the
|
||||
# # generic claude overlay. Set it with
|
||||
# # ./setup --claude-model <id>, which validates the
|
||||
# # ID and re-renders; ./setup --claude-model claude
|
||||
# # returns to the generic overlay.
|
||||
#
|
||||
# ─── Advanced ────────────────────────────────────────────────────────
|
||||
# codex_reviews: enabled # Workflow outside review (Codex or Claude Code by harness).
|
||||
# # enabled: /review, /ship, /document-release, plan reviews,
|
||||
@@ -195,6 +203,7 @@ lookup_default() {
|
||||
pair_agent) echo "off" ;; # remote tunnel consent — fail-closed until /pair-agent asks
|
||||
memorable_recall) echo "off" ;; # on | off — Memorable bridge gate, fail-closed until `gstack-memorable enable`
|
||||
founder_resources) echo "true" ;; # office-hours resource pitch — #538 permanent opt-out sets false
|
||||
claude_overlay_model) echo "claude" ;; # set only by ./setup --claude-model; presence via `has`
|
||||
# Brain-aware planning (v1.48 / T5+T10+T16). Defaults documented inline:
|
||||
# brain_trust_policy@<endpoint-id> — unset on fresh install; setup-gbrain
|
||||
# writes 'personal' for local engines,
|
||||
@@ -660,6 +669,7 @@ case "${1:-}" in
|
||||
# Run this after installing or uninstalling gbrain so your locally
|
||||
# generated SKILL.md files match your installation state.
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
. "$SCRIPT_DIR/gstack-render-claude.sh" 2>/dev/null || { echo "$0: $SCRIPT_DIR/gstack-render-claude.sh is missing. fix: reinstall with ./setup or /gstack-upgrade" >&2; exit 1; }
|
||||
DETECT_BIN="$SCRIPT_DIR/gstack-gbrain-detect"
|
||||
DETECTION_FILE="$STATE_DIR/gbrain-detection.json"
|
||||
mkdir -p "$STATE_DIR"
|
||||
@@ -667,10 +677,10 @@ case "${1:-}" in
|
||||
echo "gstack-gbrain-detect not found at $DETECT_BIN" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! "$DETECT_BIN" > "$DETECTION_FILE.tmp" 2>/dev/null; then
|
||||
printf '{"gbrain_on_path":false,"gbrain_local_status":"no-cli"}\n' > "$DETECTION_FILE.tmp"
|
||||
if ! "$DETECT_BIN" > "$DETECTION_FILE.tmp.$$" 2>/dev/null; then
|
||||
printf '{"gbrain_on_path":false,"gbrain_local_status":"no-cli"}\n' > "$DETECTION_FILE.tmp.$$"
|
||||
fi
|
||||
mv "$DETECTION_FILE.tmp" "$DETECTION_FILE"
|
||||
mv "$DETECTION_FILE.tmp.$$" "$DETECTION_FILE"
|
||||
|
||||
# Summarize for the user. Use python (already required elsewhere) to
|
||||
# parse the JSON portably; fall back to grep if python is unavailable.
|
||||
@@ -721,10 +731,14 @@ case "${1:-}" in
|
||||
# SKILL.md link dangling — the whole skill set vanished from
|
||||
# Claude Code until a successful re-render. A failed render now
|
||||
# leaves the previous render fully intact.
|
||||
gstack_claude_overlay "$INSTALL_DIR" "$0"
|
||||
RENDER_TMP="$RENDER_DIR.tmp.$$"
|
||||
rm -rf "$RENDER_TMP"
|
||||
if ( cd "$INSTALL_DIR" && bun run gen:skill-docs:user --host claude --out-dir "$RENDER_TMP" --link-root "$RENDER_DIR" >/dev/null 2>&1 ); then
|
||||
if ( cd "$INSTALL_DIR" && bun run gen:skill-docs:user --host claude --out-dir "$RENDER_TMP" --link-root "$RENDER_DIR" --model "$_GSTACK_OVERLAY" >/dev/null 2>&1 ); then
|
||||
gstack_render_lock "$RENDER_DIR"
|
||||
_swap_in_render "$RENDER_DIR" "$RENDER_TMP"
|
||||
gstack_render_unlock "$RENDER_DIR"
|
||||
gstack_claude_render_settle "$INSTALL_DIR" "$RENDER_DIR" ok rendered
|
||||
# Repoint installed skills at the render — gstack-relink prefers
|
||||
# the render dir when present.
|
||||
"$INSTALL_DIR/bin/gstack-relink" >/dev/null 2>&1 || true
|
||||
@@ -734,12 +748,41 @@ case "${1:-}" in
|
||||
rm -rf "$RENDER_TMP"
|
||||
echo "Warning: render failed — previous render (if any) left in place, links stay valid."
|
||||
echo "Run 'cd $INSTALL_DIR && bun run gen:skill-docs:user --host claude --out-dir $RENDER_DIR' manually to see the error."
|
||||
gstack_claude_render_settle "$INSTALL_DIR" "$RENDER_DIR" ok failed
|
||||
"$INSTALL_DIR/bin/gstack-relink" >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "gbrain not detected (local-status: $STATUS) → brain-aware blocks will be suppressed in planning-skill SKILL.md files."
|
||||
echo "Install gbrain (see /setup-gbrain) and re-run 'gstack-config gbrain-refresh' once it's configured."
|
||||
# Drop a brain-aware render, or re-render a pinned overlay without
|
||||
# brain blocks, so removing gbrain keeps the chosen overlay.
|
||||
INSTALL_DIR="$HOME/.claude/skills/gstack"
|
||||
RENDER_DIR="${GSTACK_USER_RENDER_DIR:-$STATE_DIR/render/claude}"
|
||||
if { [ -d "$RENDER_DIR" ] || "$0" has claude_overlay_model; } \
|
||||
&& [ -d "$INSTALL_DIR" ] && [ ! -L "$INSTALL_DIR" ] && [ -f "$INSTALL_DIR/VERSION" ] && [ -f "$INSTALL_DIR/package.json" ]; then
|
||||
if ! command -v bun >/dev/null 2>&1; then
|
||||
echo "Skip: bun not on PATH — the Claude render at $RENDER_DIR was not refreshed. Install bun, then re-run 'gstack-config gbrain-refresh'."
|
||||
else
|
||||
gstack_claude_overlay "$INSTALL_DIR" "$0"
|
||||
gstack_claude_render_action "$RENDER_DIR" absent
|
||||
if [ "$_GSTACK_RENDER_ACTION" = plain ]; then
|
||||
if gstack_claude_render_plain "$INSTALL_DIR" "$RENDER_DIR"; then
|
||||
gstack_claude_render_settle "$INSTALL_DIR" "$RENDER_DIR" absent rendered
|
||||
else
|
||||
gstack_claude_render_settle "$INSTALL_DIR" "$RENDER_DIR" absent failed
|
||||
fi
|
||||
else
|
||||
if [ -d "$RENDER_DIR" ]; then
|
||||
rm -rf "$RENDER_DIR"
|
||||
echo "Removed the brain-aware render at $RENDER_DIR; Claude skills serve the committed files."
|
||||
fi
|
||||
gstack_claude_render_settle "$INSTALL_DIR" "$RENDER_DIR" absent removed
|
||||
fi
|
||||
"$INSTALL_DIR/bin/gstack-relink" >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
# gstack-render-claude.sh — the Claude skill render shared by ./setup and
|
||||
# `gstack-config gbrain-refresh`. Source it; it only defines functions.
|
||||
# bash-3.2-clean and safe under `set -euo pipefail`.
|
||||
#
|
||||
# Overlay selection is opt-in: the overlay is the family of the persisted
|
||||
# `claude_overlay_model` (written only by `./setup --claude-model <id>`), or
|
||||
# `claude` when that key is absent. The ID is re-resolved through
|
||||
# scripts/models.ts at every render, so an ID that stops resolving after an
|
||||
# upgrade falls back to `claude` with the reason printed.
|
||||
#
|
||||
# Each render keeps a plain-text activation record beside it
|
||||
# (<render dir>.overlay: overlay, source, model, version, gbrain, render), so
|
||||
# `./setup --status` and the change-only banner never need bun. No record
|
||||
# means the default `claude` overlay on the committed render.
|
||||
#
|
||||
# Callers define `_swap_in_render` (setup and bin/gstack-config each keep
|
||||
# their tested copy) and may define `log` and `bun_cmd`; plain echo and bun
|
||||
# are used otherwise.
|
||||
|
||||
_gstack_render_say() {
|
||||
if declare -F log >/dev/null 2>&1; then log "$@"; else echo "$@"; fi
|
||||
}
|
||||
|
||||
_gstack_render_bun() {
|
||||
if declare -F bun_cmd >/dev/null 2>&1; then bun_cmd "$@"; else bun "$@"; fi
|
||||
}
|
||||
|
||||
# gstack_claude_overlay SOURCE_DIR CONFIG_BIN — resolve the requested overlay.
|
||||
# Sets _GSTACK_OVERLAY (family), _GSTACK_OVERLAY_SOURCE (explicit|default),
|
||||
# _GSTACK_OVERLAY_ID (the configured ID, or claude), _GSTACK_OVERLAY_GENERIC
|
||||
# (1 when a specific ID uses the generic overlay) and _GSTACK_OVERLAY_NOTE
|
||||
# (why a configured ID fell back to claude, else empty; printed here).
|
||||
gstack_claude_overlay() {
|
||||
local src="$1" CONFIG_BIN="$2" out rc=0
|
||||
_GSTACK_OVERLAY=claude
|
||||
_GSTACK_OVERLAY_SOURCE=default
|
||||
_GSTACK_OVERLAY_ID=claude
|
||||
_GSTACK_OVERLAY_GENERIC=0
|
||||
_GSTACK_OVERLAY_NOTE=""
|
||||
"$CONFIG_BIN" has claude_overlay_model >/dev/null 2>&1 || return 0
|
||||
_GSTACK_OVERLAY_SOURCE=explicit
|
||||
_GSTACK_OVERLAY_ID="$("$CONFIG_BIN" get claude_overlay_model 2>/dev/null || true)"
|
||||
out="$(cd "$src" && _gstack_render_bun run scripts/models.ts claude-overlay "$_GSTACK_OVERLAY_ID" 2>&1)" || rc=$?
|
||||
if [ "$rc" -eq 0 ]; then
|
||||
_GSTACK_OVERLAY="$(printf '%s' "$out" | tail -1 | cut -f1)"
|
||||
_GSTACK_OVERLAY_GENERIC="$(printf '%s' "$out" | tail -1 | cut -f2)"
|
||||
return 0
|
||||
fi
|
||||
_GSTACK_OVERLAY=claude
|
||||
_GSTACK_OVERLAY_NOTE="claude_overlay_model '$_GSTACK_OVERLAY_ID' no longer resolves to a Claude overlay ($(printf '%s\n' "$out" | sed -n 2p)); using the generic claude overlay. Fix: ./setup --claude-model <claude-model-id>, or ./setup --claude-model claude."
|
||||
_gstack_render_say " note: $_GSTACK_OVERLAY_NOTE"
|
||||
}
|
||||
|
||||
# gstack_claude_overlay_field RECORD KEY — one field of an activation record.
|
||||
gstack_claude_overlay_field() {
|
||||
[ -f "$1" ] || return 0
|
||||
sed -n "s/^$2=//p" "$1" 2>/dev/null | head -1
|
||||
}
|
||||
|
||||
# gstack_render_lock DIR / gstack_render_unlock DIR — serialize the swap and
|
||||
# record write of concurrent renders (mkdir lock, no flock). A lock whose
|
||||
# holder is gone is broken; after 30 s the swap proceeds unlocked.
|
||||
gstack_render_lock() {
|
||||
local lock="$1.lock" i=0 pid
|
||||
mkdir -p "$(dirname "$1")" 2>/dev/null || true
|
||||
while ! mkdir "$lock" 2>/dev/null; do
|
||||
pid="$(cat "$lock/pid" 2>/dev/null || true)"
|
||||
if [ -n "$pid" ] && ! kill -0 "$pid" 2>/dev/null; then
|
||||
rm -rf "$lock"
|
||||
continue
|
||||
fi
|
||||
i=$((i + 1))
|
||||
[ "$i" -lt 300 ] || return 0
|
||||
sleep 0.1
|
||||
done
|
||||
echo "$$" > "$lock/pid"
|
||||
}
|
||||
gstack_render_unlock() {
|
||||
rm -rf "$1.lock"
|
||||
}
|
||||
|
||||
# gstack_claude_render_action RENDER_DIR GBRAIN — what the default install's
|
||||
# render dir needs: `user` (brain-aware :user render), `plain` (pinned
|
||||
# overlay, no brain blocks), `remove` (serve the committed files) or `keep`.
|
||||
# GBRAIN is ok, absent or unknown (detector missing or failed: no brain
|
||||
# blocks, and a render is left alone unless an overlay change needs it).
|
||||
gstack_claude_render_action() {
|
||||
local render_dir="$1" gbrain="$2" previous
|
||||
if [ "$gbrain" = ok ]; then _GSTACK_RENDER_ACTION=user; return 0; fi
|
||||
if [ "$_GSTACK_OVERLAY" != claude ]; then _GSTACK_RENDER_ACTION=plain; return 0; fi
|
||||
previous="$(gstack_claude_overlay_field "$render_dir.overlay" overlay)"
|
||||
if [ "$gbrain" = unknown ] && [ "${previous:-claude}" = claude ]; then
|
||||
_GSTACK_RENDER_ACTION=keep
|
||||
else
|
||||
_GSTACK_RENDER_ACTION=remove
|
||||
fi
|
||||
}
|
||||
|
||||
# gstack_claude_render_plain SOURCE_DIR RENDER_DIR — render the pinned overlay
|
||||
# without brain blocks into a temp dir and swap it in only on success.
|
||||
gstack_claude_render_plain() {
|
||||
local src="$1" RENDER_DIR="$2" RENDER_TMP out rc=0
|
||||
RENDER_TMP="$RENDER_DIR.tmp.$$"
|
||||
rm -rf "$RENDER_TMP"
|
||||
_gstack_render_say "Rendering Claude skills with the $_GSTACK_OVERLAY overlay into $RENDER_DIR (source checkout stays clean)..."
|
||||
out="$(cd "$src" && _gstack_render_bun run gen:skill-docs --host claude --out-dir "$RENDER_TMP" --link-root "$RENDER_DIR" --model "$_GSTACK_OVERLAY" 2>&1)" || rc=$?
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
rm -rf "$RENDER_TMP"
|
||||
printf '%s\n' "$out" | tail -3
|
||||
return "$rc"
|
||||
fi
|
||||
gstack_render_lock "$RENDER_DIR"
|
||||
_swap_in_render "$RENDER_DIR" "$RENDER_TMP"
|
||||
gstack_render_unlock "$RENDER_DIR"
|
||||
}
|
||||
|
||||
# gstack_claude_render_settle SOURCE_DIR RENDER_DIR GBRAIN OUTCOME [DEFAULT]
|
||||
# Record what a render step left in place and report it. OUTCOME is rendered,
|
||||
# removed or failed. DEFAULT is 1 (default install: a failed pinned render
|
||||
# from an older gstack version falls back to the committed files) or 0 (a
|
||||
# per-install render keeps its previous render). Prints the overlay banner
|
||||
# only when the served overlay differs from the previous record's.
|
||||
gstack_claude_render_settle() {
|
||||
local src="$1" render_dir="$2" gbrain="$3" outcome="$4" default="${5:-1}"
|
||||
local record="$2.overlay" version previous previous_version served render_kind
|
||||
version="$(cat "$src/VERSION" 2>/dev/null | tr -d '[:space:]' || true)"
|
||||
previous="$(gstack_claude_overlay_field "$record" overlay)"
|
||||
previous_version="$(gstack_claude_overlay_field "$record" version)"
|
||||
case "$outcome" in
|
||||
rendered) served="$_GSTACK_OVERLAY"; render_kind=user ;;
|
||||
removed) served=claude; render_kind=committed ;;
|
||||
failed)
|
||||
if [ "$default" = 1 ] && [ "$_GSTACK_OVERLAY" != claude ] && [ -d "$render_dir" ] \
|
||||
&& [ "$previous_version" != "$version" ]; then
|
||||
rm -rf "$render_dir"
|
||||
_gstack_render_say " The $_GSTACK_OVERLAY overlay render failed and the previous render came from gstack ${previous_version:-unknown}, so the committed claude render is served. Rerun ./setup to retry."
|
||||
served=claude; render_kind=committed
|
||||
elif [ -d "$render_dir" ]; then
|
||||
[ "$_GSTACK_OVERLAY" = claude ] \
|
||||
|| _gstack_render_say " The $_GSTACK_OVERLAY overlay render failed; the previous render (${previous:-claude} overlay) stays in use. Rerun ./setup to retry."
|
||||
return 0
|
||||
else
|
||||
_gstack_render_say " The $_GSTACK_OVERLAY overlay render failed; the committed claude render is served. Rerun ./setup to retry."
|
||||
served=claude; render_kind=committed
|
||||
fi
|
||||
;;
|
||||
*) return 0 ;;
|
||||
esac
|
||||
gstack_render_lock "$render_dir"
|
||||
if [ "$render_kind" = committed ] && [ "$_GSTACK_OVERLAY_SOURCE" = default ]; then
|
||||
rm -f "$record"
|
||||
else
|
||||
printf 'overlay=%s\nsource=%s\nmodel=%s\nversion=%s\ngbrain=%s\nrender=%s\n' \
|
||||
"$served" "$_GSTACK_OVERLAY_SOURCE" "$_GSTACK_OVERLAY_ID" "${version:-unknown}" "$gbrain" "$render_kind" \
|
||||
> "$record.tmp.$$" && mv "$record.tmp.$$" "$record"
|
||||
fi
|
||||
gstack_render_unlock "$render_dir"
|
||||
if [ "$served" != "${previous:-claude}" ]; then
|
||||
_gstack_render_say ""
|
||||
_gstack_render_say "Claude skill overlay: $served (was ${previous:-claude})."
|
||||
if [ "$_GSTACK_OVERLAY_SOURCE" = explicit ]; then
|
||||
_gstack_render_say " Source: claude_overlay_model = $_GSTACK_OVERLAY_ID, set by ./setup --claude-model."
|
||||
[ "$_GSTACK_OVERLAY_GENERIC" = 1 ] && _gstack_render_say " $_GSTACK_OVERLAY_ID has no overlay of its own, so it uses the generic claude overlay."
|
||||
else
|
||||
_gstack_render_say " Source: default (no claude_overlay_model set)."
|
||||
fi
|
||||
_gstack_render_say " Return to the generic overlay: ./setup --claude-model claude"
|
||||
fi
|
||||
}
|
||||
|
||||
# gstack_claude_overlay_status — `./setup --status` overlay lines. Reads only
|
||||
# activation records and the registry: no bun, no writes.
|
||||
gstack_claude_overlay_status() {
|
||||
local default_render rows dest render record overlay source model version kind
|
||||
default_render="${GSTACK_USER_RENDER_DIR:-$GSTACK_STATE_ROOT/render/claude}"
|
||||
rows="$(gstack_install_registry_rows 2>/dev/null | awk -F '\t' '$1 == "claude" { print $4 "\t" $9 }')"
|
||||
[ -n "$rows" ] || return 0
|
||||
echo "Claude skill overlay:"
|
||||
while IFS=' ' read -r dest render; do
|
||||
[ -n "$dest" ] || continue
|
||||
case "$render" in
|
||||
''|-|committed) record="$default_render.overlay"; render="$default_render"; kind="user render" ;;
|
||||
*) record="$render.overlay"; kind="per-install render" ;;
|
||||
esac
|
||||
overlay="$(gstack_claude_overlay_field "$record" overlay)"
|
||||
[ -d "$render" ] || kind="committed render"
|
||||
if [ -z "$overlay" ]; then
|
||||
echo " $(_gstack_tilde "$dest"): claude (default, $kind)"
|
||||
continue
|
||||
fi
|
||||
source="$(gstack_claude_overlay_field "$record" source)"
|
||||
model="$(gstack_claude_overlay_field "$record" model)"
|
||||
version="$(gstack_claude_overlay_field "$record" version)"
|
||||
[ "$(gstack_claude_overlay_field "$record" render)" = committed ] && kind="committed render"
|
||||
if [ "$source" = explicit ]; then source="explicit: $model"; fi
|
||||
echo " $(_gstack_tilde "$dest"): $overlay ($source, $kind, gstack $version)"
|
||||
done <<EOF
|
||||
$rows
|
||||
EOF
|
||||
}
|
||||
@@ -26,7 +26,6 @@ Options:
|
||||
instruction-only: slate, openclaw, hermes, gbrain
|
||||
auto: every installed agent that setup detects
|
||||
An explicit --host never changes another host's install.
|
||||
--model <id> Codex model profile override. Otherwise reads Codex config.
|
||||
--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).
|
||||
@@ -40,11 +39,21 @@ Options:
|
||||
-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
|
||||
@@ -66,9 +75,11 @@ 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" || exit 1
|
||||
. "$_STATUS_DIR/bin/gstack-state-root.sh" && . "$_STATUS_DIR/bin/gstack-install-registry.sh" \
|
||||
&& . "$_STATUS_DIR/bin/gstack-render-claude.sh" || exit 1
|
||||
gstack_state_root_select; GSTACK_STATE_ROOT="$_gstack_sr_root"
|
||||
gstack_install_status
|
||||
gstack_claude_overlay_status
|
||||
exit 0 ;;
|
||||
esac
|
||||
done
|
||||
@@ -88,12 +99,14 @@ PLAN_TUNE_HOOKS_MODE="" # "" = resolve from env/config/prompt; "yes"/"no" = ex
|
||||
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 --global --local --prefix --no-prefix --team --no-team \
|
||||
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 --quiet --help; do
|
||||
d="$(awk -v a="${bad%%=*}" -v b="$flag" 'BEGIN {
|
||||
@@ -109,7 +122,7 @@ _setup_unknown_option() {
|
||||
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) case "$next" in ''|-*) ;; *) value=" $next" ;; esac ;; esac
|
||||
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
|
||||
@@ -121,6 +134,8 @@ while [ $# -gt 0 ]; do
|
||||
--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 ;;
|
||||
@@ -225,8 +240,35 @@ SOURCE_GSTACK_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||
. "$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-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; }
|
||||
SETUP_VERSION="$(cat "$SOURCE_GSTACK_DIR/VERSION" 2>/dev/null || echo unknown)"
|
||||
|
||||
# --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; }
|
||||
|
||||
@@ -292,6 +334,16 @@ $_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
|
||||
@@ -821,7 +873,7 @@ _render_install() {
|
||||
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" 2>&1)" || rc=$?
|
||||
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" 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; } \
|
||||
@@ -840,7 +892,10 @@ _render_install() {
|
||||
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"
|
||||
}
|
||||
@@ -1092,9 +1147,14 @@ if [ "$INSTALL_COPILOT" -eq 1 ] && [ -n "${COPILOT_HOME:-}" ] && [ "${COPILOT_HO
|
||||
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)." >&2
|
||||
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
|
||||
_persist_claude_model
|
||||
|
||||
migrate_direct_codex_install() {
|
||||
local gstack_dir="$1"
|
||||
@@ -1434,6 +1494,10 @@ if [ "$INSTALL_CODEX" -eq 1 ] || [ "$CODEX_GENERATION_MODEL" != "gpt-6-astra" ];
|
||||
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"
|
||||
|
||||
# Migrate existing wrappers BEFORE build can regenerate/prune shared host trees.
|
||||
# This also repairs existing Codex/Kiro installs during a Claude-only setup.
|
||||
@@ -3514,6 +3578,11 @@ fi
|
||||
# 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"
|
||||
@@ -3523,6 +3592,8 @@ _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
|
||||
@@ -3534,10 +3605,12 @@ if [ -x "$DETECT_BIN" ]; 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
|
||||
@@ -3560,29 +3633,36 @@ if [ -x "$DETECT_BIN" ]; then
|
||||
# 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" 2>&1)
|
||||
_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" 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"
|
||||
# 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
|
||||
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.
|
||||
if [ -d "$_GSTACK_RENDER_DIR" ] && [ -z "${GSTACK_SKIP_GBRAIN_REGEN:-}" ] && [ "$INSTALL_CLAUDE" -eq 1 ]; then
|
||||
# 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
|
||||
@@ -3593,6 +3673,32 @@ if [ -x "$DETECT_BIN" ]; then
|
||||
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
|
||||
|
||||
@@ -5,7 +5,7 @@ import { describe, expect, test } from 'bun:test';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { ALL_MODEL_NAMES, CLAUDE_OVERLAY_MODELS, resolveClaudeOverlay } from '../scripts/models';
|
||||
import { ALL_MODEL_NAMES, CLAUDE_OVERLAY_MODELS, type ClaudeOverlay, type ClaudeOverlayError, resolveClaudeOverlay } from '../scripts/models';
|
||||
|
||||
const ROOT = resolve(import.meta.dir, '..');
|
||||
|
||||
@@ -23,7 +23,7 @@ describe('resolveClaudeOverlay', () => {
|
||||
['claude-opus-4-7-1', 'claude', true],
|
||||
['claude-haiku-4-5', 'claude', true],
|
||||
['claude-3-opus-20240229', 'claude', true],
|
||||
])('%p uses the %p overlay', (id, overlay, generic) => {
|
||||
] as [string, ClaudeOverlay, boolean][])('%p uses the %p overlay', (id, overlay, generic) => {
|
||||
expect(resolveClaudeOverlay(id)).toEqual({ overlay, generic });
|
||||
});
|
||||
|
||||
@@ -39,7 +39,7 @@ describe('resolveClaudeOverlay', () => {
|
||||
['gpt-6-astra', 'non-claude'],
|
||||
['banana', 'unknown'],
|
||||
['', 'unknown'],
|
||||
])('%p is rejected as %p with problem, cause and fix', (id, kind) => {
|
||||
] as [string, ClaudeOverlayError['kind']][])('%p is rejected as %p with problem, cause and fix', (id, kind) => {
|
||||
const result = resolveClaudeOverlay(id);
|
||||
expect('error' in result).toBe(true);
|
||||
if (!('error' in result)) return;
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* W7 Claude overlay opt-in (`./setup --claude-model <id>`), exercised through
|
||||
* the real ./setup and `gstack-config gbrain-refresh` against throwaway
|
||||
* checkouts and HOMEs (test/helpers/install-fixture.ts).
|
||||
*
|
||||
* The overlay is the family of the persisted `claude_overlay_model`, written
|
||||
* only by `--claude-model`, or `claude` when the key is absent. Renders and
|
||||
* their activation records come from bin/gstack-render-claude.sh. The marker
|
||||
* below is text only the opus-4-7 and opus-4-8 overlays render.
|
||||
*/
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { cleanupFixtures, type Fixture, makeFixture, makeSource, put, registryRows, runSetup, setVersion } from './helpers/install-fixture';
|
||||
|
||||
afterEach(cleanupFixtures);
|
||||
|
||||
const MARKER = 'Effort-match the step';
|
||||
|
||||
/** The fixture's bun, plus a switch that fails every out-dir render. */
|
||||
function failableBun(f: Fixture) {
|
||||
put(join(f.commands, 'bun'), `#!/usr/bin/env bash
|
||||
case "$*" in
|
||||
'install --frozen-lockfile') exit 0 ;;
|
||||
'run build') echo 'Unexpected build in an install fixture' >&2; exit 90 ;;
|
||||
*gen:skill-docs*--out-dir*) if [ -f "$HOME/fail-render" ]; then echo 'render failed (fixture)' >&2; exit 7; fi ;;
|
||||
esac
|
||||
exec ${JSON.stringify(process.execPath)} "$@"
|
||||
`, 0o755);
|
||||
}
|
||||
|
||||
/** gbrain detection driven by $HOME/gbrain-mode: ok, absent (default) or fail. */
|
||||
function fakeDetect(src: string) {
|
||||
put(join(src, 'bin/gstack-gbrain-detect'), `#!/usr/bin/env bash
|
||||
mode="$(cat "$HOME/gbrain-mode" 2>/dev/null || echo absent)"
|
||||
[ "$mode" = fail ] && exit 1
|
||||
if [ "\${1:-}" = --is-ok ]; then [ "$mode" = ok ]; exit $?; fi
|
||||
if [ "$mode" = ok ]; then echo '{"gbrain_local_status":"ok","gbrain_version":"0.0.1"}'; else echo '{"gbrain_local_status":"no-cli"}'; fi
|
||||
`, 0o755);
|
||||
}
|
||||
|
||||
function setup(f: Fixture, src: string, args: string[] = [], cwd?: string) {
|
||||
const r = runSetup(f, join(src, 'setup'), ['--no-prefix', ...args], { cwd });
|
||||
return { ...r, out: `${r.stdout}\n${r.stderr}` };
|
||||
}
|
||||
|
||||
function config(f: Fixture, src: string, ...args: string[]) {
|
||||
return spawnSync('bash', [join(src, 'bin/gstack-config'), ...args], { env: f.env, encoding: 'utf8', timeout: 120_000 });
|
||||
}
|
||||
|
||||
const defaultRender = (f: Fixture) => join(f.home, '.gstack/render/claude');
|
||||
|
||||
function record(render: string): Record<string, string> {
|
||||
const file = `${render}.overlay`;
|
||||
if (!existsSync(file)) return {};
|
||||
return Object.fromEntries(readFileSync(file, 'utf8').trim().split('\n').map(line => line.split(/=(.*)/s).slice(0, 2)));
|
||||
}
|
||||
|
||||
const served = (skills: string) => readFileSync(join(skills, 'autoplan/SKILL.md'), 'utf8');
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('./setup --claude-model (default install)', () => {
|
||||
test('no claude_overlay_model: committed render, no render dir, no record', () => {
|
||||
const f = makeFixture();
|
||||
const src = makeSource(f, join(f.home, '.claude/skills/gstack'));
|
||||
const r = setup(f, src);
|
||||
expect(r.status, r.out).toBe(0);
|
||||
expect(existsSync(defaultRender(f))).toBe(false);
|
||||
expect(existsSync(`${defaultRender(f)}.overlay`)).toBe(false);
|
||||
expect(config(f, src, 'has', 'claude_overlay_model').status).not.toBe(0);
|
||||
expect(served(join(f.home, '.claude/skills'))).toBe(readFileSync(join(src, 'autoplan/SKILL.md'), 'utf8'));
|
||||
expect(r.out).not.toContain('Claude skill overlay:');
|
||||
const status = setup(f, src, ['--status']);
|
||||
expect(status.stdout).toContain('~/.claude/skills: claude (default, committed render)');
|
||||
}, 120_000);
|
||||
|
||||
test('pinned overlay renders and persists; the banner shows only on change; claude returns to committed files', () => {
|
||||
const f = makeFixture();
|
||||
const src = makeSource(f, join(f.home, '.claude/skills/gstack'));
|
||||
const skills = join(f.home, '.claude/skills');
|
||||
|
||||
const pinned = setup(f, src, ['--claude-model', 'claude-opus-4-7']);
|
||||
expect(pinned.status, pinned.out).toBe(0);
|
||||
expect(config(f, src, 'get', 'claude_overlay_model').stdout).toBe('claude-opus-4-7');
|
||||
expect(pinned.out).toContain('Claude skill overlay: opus-4-7 (was claude).');
|
||||
expect(pinned.out).toContain('./setup --claude-model claude');
|
||||
expect(record(defaultRender(f))).toMatchObject({ overlay: 'opus-4-7', source: 'explicit', model: 'claude-opus-4-7', gbrain: 'absent', render: 'user' });
|
||||
expect(served(skills)).toContain(MARKER);
|
||||
expect(readFileSync(join(src, 'autoplan/SKILL.md'), 'utf8')).not.toContain(MARKER);
|
||||
|
||||
const again = setup(f, src);
|
||||
expect(again.status, again.out).toBe(0);
|
||||
expect(again.out).not.toContain('Claude skill overlay:');
|
||||
expect(served(skills)).toContain(MARKER);
|
||||
|
||||
const back = setup(f, src, ['--claude-model', 'claude']);
|
||||
expect(back.status, back.out).toBe(0);
|
||||
expect(config(f, src, 'get', 'claude_overlay_model').stdout).toBe('claude');
|
||||
expect(back.out).toContain('Claude skill overlay: claude (was opus-4-7).');
|
||||
expect(existsSync(defaultRender(f))).toBe(false);
|
||||
expect(record(defaultRender(f))).toMatchObject({ overlay: 'claude', source: 'explicit', render: 'committed' });
|
||||
expect(served(skills)).not.toContain(MARKER);
|
||||
}, 180_000);
|
||||
|
||||
test('an ID without its own overlay is accepted as the generic overlay', () => {
|
||||
const f = makeFixture();
|
||||
const src = makeSource(f, join(f.home, '.claude/skills/gstack'));
|
||||
const r = setup(f, src, ['--claude-model=claude-opus-5-5']);
|
||||
expect(r.status, r.out).toBe(0);
|
||||
expect(r.out).toContain('claude-opus-5-5 has no overlay of its own, so it uses the generic claude overlay');
|
||||
expect(config(f, src, 'get', 'claude_overlay_model').stdout).toBe('claude-opus-5-5');
|
||||
expect(existsSync(defaultRender(f))).toBe(false);
|
||||
}, 120_000);
|
||||
|
||||
test('invalid IDs, a missing value and a non-Claude host error before anything is persisted', () => {
|
||||
const f = makeFixture();
|
||||
const src = makeSource(f, join(f.home, '.claude/skills/gstack'));
|
||||
for (const [args, expected] of [
|
||||
[['--claude-model', 'opus'], 'is a Claude Code alias'],
|
||||
[['--claude-model', 'gpt-5.4'], 'selects Claude overlays only'],
|
||||
[['--claude-model=banana'], 'does not match any model family'],
|
||||
[['--host', 'codex', '--claude-model', 'claude-opus-4-7'], '--claude-model is supported only when Claude is selected'],
|
||||
] as const) {
|
||||
const r = setup(f, src, [...args]);
|
||||
expect(r.status, r.out).toBe(1);
|
||||
expect(r.stderr).toContain(expected);
|
||||
expect(r.stderr).toContain('Nothing was installed or changed.');
|
||||
}
|
||||
expect(config(f, src, 'has', 'claude_overlay_model').status).not.toBe(0);
|
||||
for (const args of [['--claude-model'], ['--claude-model=']]) {
|
||||
const missing = spawnSync('bash', [join(src, 'setup'), ...args], { env: f.env, encoding: 'utf8', timeout: 30_000 });
|
||||
expect(missing.status).toBe(1);
|
||||
expect(missing.stderr).toContain('Missing value for --claude-model');
|
||||
}
|
||||
const typo = runSetup(f, join(src, 'setup'), ['--claude-modle', 'claude-opus-4-8']);
|
||||
expect(typo.status).toBe(2);
|
||||
expect(typo.stderr).toContain('Did you mean: ./setup --claude-model claude-opus-4-8');
|
||||
const codexOnly = setup(f, src, ['--model', 'gpt-5.4']);
|
||||
expect(codexOnly.stderr).toContain('For the Claude overlay, use --claude-model <id>.');
|
||||
}, 240_000);
|
||||
|
||||
test('a persisted ID that no longer resolves falls back to claude and says why', () => {
|
||||
const f = makeFixture();
|
||||
const src = makeSource(f, join(f.home, '.claude/skills/gstack'));
|
||||
expect(config(f, src, 'set', 'claude_overlay_model', 'opus').status).toBe(0);
|
||||
const r = setup(f, src);
|
||||
expect(r.status, r.out).toBe(0);
|
||||
expect(r.out).toContain("claude_overlay_model 'opus' no longer resolves to a Claude overlay");
|
||||
expect(r.out).toContain('using the generic claude overlay');
|
||||
expect(existsSync(defaultRender(f))).toBe(false);
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('overlay render lifecycle with gbrain and failures', () => {
|
||||
test('gbrain present → absent keeps a pinned overlay; a failed detector still renders it', () => {
|
||||
const f = makeFixture();
|
||||
const src = makeSource(f, join(f.home, '.claude/skills/gstack'));
|
||||
fakeDetect(src);
|
||||
const skills = join(f.home, '.claude/skills');
|
||||
|
||||
put(join(f.home, 'gbrain-mode'), 'ok');
|
||||
expect(setup(f, src, ['--claude-model', 'claude-opus-4-8']).status).toBe(0);
|
||||
expect(record(defaultRender(f))).toMatchObject({ overlay: 'opus-4-8', gbrain: 'ok' });
|
||||
expect(served(skills)).toContain(MARKER);
|
||||
|
||||
put(join(f.home, 'gbrain-mode'), 'absent');
|
||||
const refresh = config(f, src, 'gbrain-refresh');
|
||||
expect(refresh.status, refresh.stdout + refresh.stderr).toBe(0);
|
||||
expect(refresh.stdout).toContain('gbrain not detected');
|
||||
expect(record(defaultRender(f))).toMatchObject({ overlay: 'opus-4-8', gbrain: 'absent' });
|
||||
expect(served(skills)).toContain(MARKER);
|
||||
|
||||
put(join(f.home, 'gbrain-mode'), 'fail');
|
||||
const failed = setup(f, src);
|
||||
expect(failed.status, failed.out).toBe(0);
|
||||
expect(failed.out).toContain('gstack-gbrain-detect failed');
|
||||
expect(record(defaultRender(f))).toMatchObject({ overlay: 'opus-4-8', gbrain: 'unknown' });
|
||||
expect(served(skills)).toContain(MARKER);
|
||||
}, 240_000);
|
||||
|
||||
test('gbrain present → absent with the default overlay drops the brain render', () => {
|
||||
const f = makeFixture();
|
||||
const src = makeSource(f, join(f.home, '.claude/skills/gstack'));
|
||||
fakeDetect(src);
|
||||
expect(setup(f, src).status).toBe(0);
|
||||
put(join(f.home, 'gbrain-mode'), 'ok');
|
||||
expect(config(f, src, 'gbrain-refresh').status).toBe(0);
|
||||
expect(existsSync(join(defaultRender(f), 'autoplan/SKILL.md'))).toBe(true);
|
||||
expect(record(defaultRender(f))).toMatchObject({ overlay: 'claude', source: 'default', gbrain: 'ok' });
|
||||
|
||||
put(join(f.home, 'gbrain-mode'), 'absent');
|
||||
const r = config(f, src, 'gbrain-refresh');
|
||||
expect(r.status, r.stdout + r.stderr).toBe(0);
|
||||
expect(existsSync(defaultRender(f))).toBe(false);
|
||||
expect(existsSync(`${defaultRender(f)}.overlay`)).toBe(false);
|
||||
expect(served(join(f.home, '.claude/skills'))).toBe(readFileSync(join(src, 'autoplan/SKILL.md'), 'utf8'));
|
||||
}, 180_000);
|
||||
|
||||
test('a failed render keeps the previous render; after an upgrade it serves the committed files', () => {
|
||||
const f = makeFixture();
|
||||
failableBun(f);
|
||||
const src = makeSource(f, join(f.home, '.claude/skills/gstack'));
|
||||
const skills = join(f.home, '.claude/skills');
|
||||
expect(setup(f, src, ['--claude-model', 'claude-sonnet-5']).status).toBe(0);
|
||||
expect(record(defaultRender(f)).overlay).toBe('sonnet-5');
|
||||
|
||||
put(join(f.home, 'fail-render'), '1');
|
||||
const kept = setup(f, src);
|
||||
expect(kept.status, kept.out).toBe(0);
|
||||
expect(kept.out).toContain('the previous render (sonnet-5 overlay) stays in use');
|
||||
expect(existsSync(join(defaultRender(f), 'autoplan/SKILL.md'))).toBe(true);
|
||||
|
||||
setVersion(src, '9.9.9.9');
|
||||
const upgraded = setup(f, src);
|
||||
expect(upgraded.status, upgraded.out).toBe(0);
|
||||
expect(upgraded.out).toContain('the committed claude render is served');
|
||||
expect(upgraded.out).toContain('Claude skill overlay: claude (was sonnet-5).');
|
||||
expect(existsSync(defaultRender(f))).toBe(false);
|
||||
expect(record(defaultRender(f))).toMatchObject({ overlay: 'claude', source: 'explicit', render: 'committed', version: '9.9.9.9' });
|
||||
expect(served(skills)).toBe(readFileSync(join(src, 'autoplan/SKILL.md'), 'utf8'));
|
||||
}, 240_000);
|
||||
|
||||
test('concurrent gbrain-refresh runs leave one valid render and no temp, old or lock dirs', async () => {
|
||||
const f = makeFixture();
|
||||
const src = makeSource(f, join(f.home, '.claude/skills/gstack'));
|
||||
fakeDetect(src);
|
||||
expect(setup(f, src, ['--claude-model', 'claude-opus-4-7']).status).toBe(0);
|
||||
put(join(f.home, 'gbrain-mode'), 'ok');
|
||||
const run = () => new Promise<{ code: number | null; err: string }>(resolve => {
|
||||
const child = spawn('bash', [join(src, 'bin/gstack-config'), 'gbrain-refresh'], { env: f.env, stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
let err = '';
|
||||
child.stderr.on('data', chunk => { err += chunk; });
|
||||
child.on('exit', code => resolve({ code, err }));
|
||||
});
|
||||
const runs = await Promise.all([run(), run(), run()]);
|
||||
expect(runs).toEqual([{ code: 0, err: '' }, { code: 0, err: '' }, { code: 0, err: '' }]);
|
||||
const renderParent = join(f.home, '.gstack/render');
|
||||
expect(readdirSync(renderParent).sort()).toEqual(['claude', 'claude.overlay']);
|
||||
expect(readdirSync(defaultRender(f)).filter(name => name.startsWith('claude'))).toEqual([]);
|
||||
expect(readFileSync(join(defaultRender(f), 'autoplan/SKILL.md'), 'utf8')).toContain(MARKER);
|
||||
expect(record(defaultRender(f))).toMatchObject({ overlay: 'opus-4-7', gbrain: 'ok' });
|
||||
}, 240_000);
|
||||
});
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('nondefault installs, refresh and status', () => {
|
||||
test('a project install keeps its own per-install render: pinned, back to claude, failure keeps the previous render', () => {
|
||||
const f = makeFixture();
|
||||
failableBun(f);
|
||||
const project = join(f.home, 'proj');
|
||||
const vendored = makeSource(f, join(project, '.claude/skills/gstack'));
|
||||
const skills = join(project, '.claude/skills');
|
||||
const renderOf = () => registryRows(f).find(row => row[0] === 'claude')?.[8] ?? '';
|
||||
|
||||
expect(setup(f, vendored, ['--claude-model', 'claude-opus-4-7'], project).status).toBe(0);
|
||||
const render = renderOf();
|
||||
expect(render).toContain('/render/installs/claude-');
|
||||
expect(record(render)).toMatchObject({ overlay: 'opus-4-7', source: 'explicit' });
|
||||
expect(served(skills)).toContain(MARKER);
|
||||
expect(served(skills)).toContain(`${vendored}/bin/gstack-skill-start`);
|
||||
|
||||
expect(setup(f, vendored, ['--claude-model', 'claude'], project).status).toBe(0);
|
||||
expect(renderOf()).toBe(render);
|
||||
expect(record(render).overlay).toBe('claude');
|
||||
expect(served(skills)).not.toContain(MARKER);
|
||||
expect(served(skills)).toContain(`${vendored}/bin/gstack-skill-start`);
|
||||
|
||||
put(join(f.home, 'fail-render'), '1');
|
||||
const failed = setup(f, vendored, ['--claude-model', 'claude-opus-4-8'], project);
|
||||
expect(failed.status, failed.out).toBe(0);
|
||||
expect(failed.out).toContain('the previous render stays in use');
|
||||
expect(served(skills)).toContain(`${vendored}/bin/gstack-skill-start`);
|
||||
expect(served(skills)).not.toContain(MARKER);
|
||||
}, 240_000);
|
||||
|
||||
test('--refresh-registered persists the overlay before refreshing; later refreshes honor it with no flag', () => {
|
||||
const f = makeFixture();
|
||||
const src = makeSource(f, join(f.home, '.claude/skills/gstack'));
|
||||
expect(setup(f, src).status).toBe(0);
|
||||
const refreshed = setup(f, src, ['--refresh-registered', '--claude-model', 'claude-opus-4-7']);
|
||||
expect(refreshed.status, refreshed.out).toBe(0);
|
||||
expect(config(f, src, 'get', 'claude_overlay_model').stdout).toBe('claude-opus-4-7');
|
||||
expect(record(defaultRender(f)).overlay).toBe('opus-4-7');
|
||||
|
||||
const quiet = runSetup(f, join(src, 'setup'), ['-q', '--refresh-registered']);
|
||||
expect(quiet.status, quiet.stdout + quiet.stderr).toBe(0);
|
||||
expect(record(defaultRender(f)).overlay).toBe('opus-4-7');
|
||||
expect(served(join(f.home, '.claude/skills'))).toContain(MARKER);
|
||||
}, 180_000);
|
||||
|
||||
test('./setup --status reports the overlay without bun on PATH', () => {
|
||||
const f = makeFixture();
|
||||
const src = makeSource(f, join(f.home, '.claude/skills/gstack'));
|
||||
expect(setup(f, src, ['--claude-model', 'claude-opus-4-7']).status).toBe(0);
|
||||
const noBunPath = '/usr/bin:/bin';
|
||||
expect(spawnSync('bash', ['-c', 'command -v bun'], { env: { ...f.env, PATH: noBunPath }, encoding: 'utf8', timeout: 10_000 }).status).not.toBe(0);
|
||||
const r = spawnSync('bash', [join(src, 'setup'), '--status'], { env: { ...f.env, PATH: noBunPath }, encoding: 'utf8', timeout: 30_000 });
|
||||
expect(r.status, r.stdout + r.stderr).toBe(0);
|
||||
expect(r.stdout).toContain('Claude skill overlay:');
|
||||
expect(r.stdout).toContain('~/.claude/skills: opus-4-7 (explicit: claude-opus-4-7, user render, gstack ');
|
||||
}, 120_000);
|
||||
});
|
||||
@@ -30,7 +30,7 @@ function fixture(copy: boolean) {
|
||||
put(path.join(root, 'VERSION'), '2.0.0.0\n');
|
||||
put(path.join(root, 'ETHOS.md'), 'Fixture ethos\n');
|
||||
put(path.join(root, 'package.json'), JSON.stringify({ type: 'module', scripts: { 'gen:skill-docs': 'bun run scripts/gen-skill-docs.ts' } }));
|
||||
for (const bin of ['gstack-config', 'gstack-patch-names', 'gstack-relink', 'gstack-migrate-claude-code', 'gstack-claude-code', 'gstack-state-root.sh', 'gstack-install-registry.sh']) {
|
||||
for (const bin of ['gstack-config', 'gstack-patch-names', 'gstack-relink', 'gstack-migrate-claude-code', 'gstack-claude-code', 'gstack-state-root.sh', 'gstack-install-registry.sh', 'gstack-render-claude.sh']) {
|
||||
const target = path.join(root, 'bin', bin);
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.copyFileSync(path.join(ROOT, 'bin', bin), target);
|
||||
|
||||
Reference in new issue
Block a user