mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-01 19:25:10 +02:00
feat: team-friendly gstack install mode (v0.15.7.0) (#809)
* feat: add gstack-settings-hook for atomic Claude Code hook management DRY helper for adding/removing SessionStart hooks in ~/.claude/settings.json. Handles missing files, deduplication, malformed JSON, and atomic writes (.tmp + rename) to prevent corruption on crash or disk-full. Part of team-install-mode feature (credit: Jared Friedman). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add gstack-session-update for automatic team updates SessionStart hook target that auto-updates gstack at session start. Background fork (zero latency), throttled to once/hour, with lockfile (mkdir + PID), stale lock recovery, GIT_TERMINAL_PROMPT=0, and debug logging to ~/.gstack/analytics/session-update.log. Part of team-install-mode feature (credit: Jared Friedman). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add --team, --no-team, -q flags to setup --team enables auto_upgrade and registers SessionStart hook via gstack-settings-hook. --no-team reverses it. -q/--quiet suppresses all informational output (for hook-triggered setup runs). --local now prints a deprecation warning. Replaces ~20 echo calls with log() helper for quiet mode support. Part of team-install-mode feature (credit: Jared Friedman). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add gstack-team-init for repo-level team bootstrapping Two modes: 'optional' (gentle CLAUDE.md suggestion) and 'required' (CLAUDE.md enforcement + .claude/hooks/check-gstack.sh PreToolUse hook that blocks work without gstack installed). Atomic JSON writes, idempotent, prints git add instructions. Part of team-install-mode feature (credit: Jared Friedman). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: deprecate vendoring, document team mode, clean up uninstall - README: replace "Step 2: Add to your repo" vendoring instructions with team mode (./setup --team + gstack-team-init) - CLAUDE.md: rename "Vendored symlink awareness" to "Dev symlink awareness", add deprecation note - CONTRIBUTING.md: remove vendoring language from prefix section - bin/gstack-uninstall: clean up SessionStart hook on uninstall Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add vendoring deprecation detection to skill preamble Detects vendored gstack in CWD (.claude/skills/gstack/ that's not a symlink and has VERSION or .git). Outputs VENDORED_GSTACK: yes/no. Adds generateVendoringDeprecation() section that offers one-time migration to team mode via AskUserQuestion. Part of team-install-mode feature (credit: Jared Friedman). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: regenerate SKILL.md files with vendoring deprecation preamble Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: team mode (v0.15.7.0) — credit Jared Friedman Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: add integration tests for team mode (20 tests) Covers gstack-settings-hook (add, remove, dedup, preserve existing, atomic write), gstack-session-update (guards, throttle, non-fatal), gstack-team-init (optional, required, enforcement hook, idempotent), and setup flags (-q, --local deprecation). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Executable
+116
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env bash
|
||||
# gstack-session-update — auto-update gstack on session start (team mode)
|
||||
#
|
||||
# Called by Claude Code SessionStart hook. Must be fast, silent, non-fatal.
|
||||
# The entire update runs in background (forked). The hook itself exits
|
||||
# immediately so session startup is never delayed.
|
||||
#
|
||||
# Exit 0 always — errors must never block a Claude Code session.
|
||||
|
||||
set +e
|
||||
|
||||
GSTACK_DIR="${GSTACK_DIR:-$HOME/.claude/skills/gstack}"
|
||||
STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}"
|
||||
THROTTLE_FILE="$STATE_DIR/.last-session-update"
|
||||
LOCK_DIR="$STATE_DIR/.setup-lock"
|
||||
LOG_FILE="$STATE_DIR/analytics/session-update.log"
|
||||
THROTTLE_SECONDS=3600 # 1 hour
|
||||
|
||||
log_entry() {
|
||||
mkdir -p "$(dirname "$LOG_FILE")"
|
||||
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $1" >> "$LOG_FILE" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# ── Guard: gstack must be a git repo ──
|
||||
if [ ! -d "$GSTACK_DIR/.git" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Guard: team mode must be enabled ──
|
||||
AUTO=$("$GSTACK_DIR/bin/gstack-config" get auto_upgrade 2>/dev/null || true)
|
||||
if [ "$AUTO" != "true" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Throttle: skip if checked recently ──
|
||||
if [ -f "$THROTTLE_FILE" ]; then
|
||||
LAST=$(cat "$THROTTLE_FILE" 2>/dev/null || echo 0)
|
||||
NOW=$(date +%s)
|
||||
ELAPSED=$(( NOW - LAST ))
|
||||
if [ "$ELAPSED" -lt "$THROTTLE_SECONDS" ]; then
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Fork to background: zero latency on session start ──
|
||||
(
|
||||
# Prevent git from prompting for credentials (would hang the background process)
|
||||
export GIT_TERMINAL_PROMPT=0
|
||||
|
||||
mkdir -p "$STATE_DIR"
|
||||
|
||||
# ── Acquire lockfile (skip if another session is running setup) ──
|
||||
if ! mkdir "$LOCK_DIR" 2>/dev/null; then
|
||||
# Lock exists — check if stale (PID dead)
|
||||
if [ -f "$LOCK_DIR/pid" ]; then
|
||||
LOCK_PID=$(cat "$LOCK_DIR/pid" 2>/dev/null || echo 0)
|
||||
if [ "$LOCK_PID" -gt 0 ] 2>/dev/null && ! kill -0 "$LOCK_PID" 2>/dev/null; then
|
||||
# Stale lock — remove and re-acquire
|
||||
rm -rf "$LOCK_DIR" 2>/dev/null
|
||||
mkdir "$LOCK_DIR" 2>/dev/null || { log_entry "SKIP lock_contested"; exit 0; }
|
||||
else
|
||||
log_entry "SKIP locked_by=$LOCK_PID"
|
||||
exit 0
|
||||
fi
|
||||
else
|
||||
log_entry "SKIP locked_no_pid"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Write PID for stale lock detection
|
||||
echo $$ > "$LOCK_DIR/pid" 2>/dev/null
|
||||
|
||||
# Clean up lock on exit
|
||||
trap 'rm -rf "$LOCK_DIR" 2>/dev/null' EXIT
|
||||
|
||||
# ── Pull latest ──
|
||||
OLD_HEAD=$(git -C "$GSTACK_DIR" rev-parse HEAD 2>/dev/null)
|
||||
git -C "$GSTACK_DIR" pull --ff-only -q 2>/dev/null
|
||||
PULL_EXIT=$?
|
||||
NEW_HEAD=$(git -C "$GSTACK_DIR" rev-parse HEAD 2>/dev/null)
|
||||
|
||||
# Record check time regardless of outcome
|
||||
date +%s > "$THROTTLE_FILE" 2>/dev/null
|
||||
|
||||
if [ "$PULL_EXIT" -ne 0 ]; then
|
||||
log_entry "PULL_FAILED exit=$PULL_EXIT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── If HEAD moved, run setup -q ──
|
||||
if [ "$OLD_HEAD" != "$NEW_HEAD" ]; then
|
||||
log_entry "UPDATING old=$OLD_HEAD new=$NEW_HEAD"
|
||||
|
||||
# bun must be available for setup
|
||||
if command -v bun >/dev/null 2>&1; then
|
||||
( cd "$GSTACK_DIR" && ./setup -q ) >/dev/null 2>&1 || {
|
||||
log_entry "SETUP_FAILED"
|
||||
}
|
||||
else
|
||||
log_entry "SETUP_SKIPPED bun_missing"
|
||||
fi
|
||||
|
||||
# Write marker so next skill preamble shows "just upgraded"
|
||||
OLD_VER=$(git -C "$GSTACK_DIR" show "$OLD_HEAD:VERSION" 2>/dev/null || echo "unknown")
|
||||
echo "$OLD_VER" > "$STATE_DIR/just-upgraded-from" 2>/dev/null
|
||||
rm -f "$STATE_DIR/last-update-check" 2>/dev/null
|
||||
rm -f "$STATE_DIR/update-snoozed" 2>/dev/null
|
||||
|
||||
log_entry "UPDATED from=$OLD_VER to=$(cat "$GSTACK_DIR/VERSION" 2>/dev/null || echo unknown)"
|
||||
else
|
||||
log_entry "UP_TO_DATE head=$OLD_HEAD"
|
||||
fi
|
||||
) &
|
||||
|
||||
exit 0
|
||||
Executable
+82
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env bash
|
||||
# gstack-settings-hook — add/remove SessionStart hooks in Claude Code settings.json
|
||||
#
|
||||
# Usage:
|
||||
# gstack-settings-hook add <hook-command> # add SessionStart hook
|
||||
# gstack-settings-hook remove <hook-command> # remove SessionStart hook
|
||||
#
|
||||
# Requires: bun (already a gstack hard dependency)
|
||||
# Writes atomically: .tmp + rename to prevent corruption on crash/disk-full.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ACTION="${1:-}"
|
||||
HOOK_CMD="${2:-}"
|
||||
SETTINGS_FILE="${GSTACK_SETTINGS_FILE:-$HOME/.claude/settings.json}"
|
||||
|
||||
if [ -z "$ACTION" ] || [ -z "$HOOK_CMD" ]; then
|
||||
echo "Usage: gstack-settings-hook {add|remove} <hook-command>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v bun >/dev/null 2>&1; then
|
||||
echo "Error: bun is required but not installed." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$ACTION" in
|
||||
add)
|
||||
bun -e "
|
||||
const fs = require('fs');
|
||||
const settingsPath = '$SETTINGS_FILE';
|
||||
const hookCmd = $(printf '%s' "$HOOK_CMD" | bun -e "process.stdout.write(JSON.stringify(require('fs').readFileSync('/dev/stdin','utf8')))");
|
||||
|
||||
let settings = {};
|
||||
try { settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch {}
|
||||
|
||||
if (!settings.hooks) settings.hooks = {};
|
||||
if (!settings.hooks.SessionStart) settings.hooks.SessionStart = [];
|
||||
|
||||
// Dedup: check if hook command already registered
|
||||
const exists = settings.hooks.SessionStart.some(entry =>
|
||||
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gstack-session-update'))
|
||||
);
|
||||
|
||||
if (!exists) {
|
||||
settings.hooks.SessionStart.push({
|
||||
hooks: [{ type: 'command', command: hookCmd }]
|
||||
});
|
||||
}
|
||||
|
||||
const tmp = settingsPath + '.tmp';
|
||||
fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + '\n');
|
||||
fs.renameSync(tmp, settingsPath);
|
||||
" 2>/dev/null
|
||||
;;
|
||||
remove)
|
||||
[ -f "$SETTINGS_FILE" ] || exit 0
|
||||
bun -e "
|
||||
const fs = require('fs');
|
||||
const settingsPath = '$SETTINGS_FILE';
|
||||
|
||||
let settings = {};
|
||||
try { settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch { process.exit(0); }
|
||||
|
||||
if (settings.hooks && settings.hooks.SessionStart) {
|
||||
settings.hooks.SessionStart = settings.hooks.SessionStart.filter(entry =>
|
||||
!(entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gstack-session-update')))
|
||||
);
|
||||
if (settings.hooks.SessionStart.length === 0) delete settings.hooks.SessionStart;
|
||||
if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
|
||||
}
|
||||
|
||||
const tmp = settingsPath + '.tmp';
|
||||
fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + '\n');
|
||||
fs.renameSync(tmp, settingsPath);
|
||||
" 2>/dev/null
|
||||
;;
|
||||
*)
|
||||
echo "Unknown action: $ACTION (expected add or remove)" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
Executable
+172
@@ -0,0 +1,172 @@
|
||||
#!/usr/bin/env bash
|
||||
# gstack-team-init — generate repo-level bootstrap files for team mode
|
||||
#
|
||||
# Usage:
|
||||
# gstack-team-init optional # gentle CLAUDE.md suggestion, one-time offer
|
||||
# gstack-team-init required # CLAUDE.md enforcement + PreToolUse hook
|
||||
#
|
||||
# Run from the root of your team's repo (not from the gstack directory).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
MODE="${1:-}"
|
||||
|
||||
if [ "$MODE" != "optional" ] && [ "$MODE" != "required" ]; then
|
||||
echo "Usage: gstack-team-init {optional|required}" >&2
|
||||
echo "" >&2
|
||||
echo " optional — suggest gstack install once per developer (gentle)" >&2
|
||||
echo " required — enforce gstack install, block work without it" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Must be in a git repo
|
||||
if ! git rev-parse --show-toplevel >/dev/null 2>&1; then
|
||||
echo "Error: not in a git repository. Run from your project root." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REPO_ROOT=$(git rev-parse --show-toplevel)
|
||||
CLAUDE_MD="$REPO_ROOT/CLAUDE.md"
|
||||
GENERATED=()
|
||||
|
||||
# ── CLAUDE.md snippet ──────────────────────────────────────────
|
||||
|
||||
if [ "$MODE" = "optional" ]; then
|
||||
SNIPPET='## gstack (recommended)
|
||||
|
||||
This project uses [gstack](https://github.com/garrytan/gstack) for AI-assisted workflows.
|
||||
Install it for the best experience:
|
||||
|
||||
```bash
|
||||
git clone --depth 1 https://github.com/garrytan/gstack.git ~/.claude/skills/gstack
|
||||
cd ~/.claude/skills/gstack && ./setup --team
|
||||
```
|
||||
|
||||
Skills like /qa, /ship, /review, /investigate, and /browse become available after install.
|
||||
Use /browse for all web browsing. Use ~/.claude/skills/gstack/... for gstack file paths.'
|
||||
else
|
||||
SNIPPET='## gstack (REQUIRED — global install)
|
||||
|
||||
**Before doing ANY work, verify gstack is installed:**
|
||||
|
||||
```bash
|
||||
test -d ~/.claude/skills/gstack/bin && echo "GSTACK_OK" || echo "GSTACK_MISSING"
|
||||
```
|
||||
|
||||
If GSTACK_MISSING: STOP. Do not proceed. Tell the user:
|
||||
|
||||
> gstack is required for all AI-assisted work in this repo.
|
||||
> Install it:
|
||||
> ```bash
|
||||
> git clone --depth 1 https://github.com/garrytan/gstack.git ~/.claude/skills/gstack
|
||||
> cd ~/.claude/skills/gstack && ./setup --team
|
||||
> ```
|
||||
> Then restart your AI coding tool.
|
||||
|
||||
Do not skip skills, ignore gstack errors, or work around missing gstack.
|
||||
|
||||
Using gstack skills: After install, skills like /qa, /ship, /review, /investigate,
|
||||
and /browse are available. Use /browse for all web browsing.
|
||||
Use ~/.claude/skills/gstack/... for gstack file paths (the global path).'
|
||||
fi
|
||||
|
||||
# Check if CLAUDE.md already has a gstack section
|
||||
if [ -f "$CLAUDE_MD" ] && grep -q "## gstack" "$CLAUDE_MD" 2>/dev/null; then
|
||||
echo "CLAUDE.md already has a gstack section. Skipping CLAUDE.md update."
|
||||
echo " To replace it, remove the existing ## gstack section and re-run."
|
||||
else
|
||||
if [ -f "$CLAUDE_MD" ]; then
|
||||
echo "" >> "$CLAUDE_MD"
|
||||
fi
|
||||
echo "$SNIPPET" >> "$CLAUDE_MD"
|
||||
GENERATED+=("CLAUDE.md")
|
||||
echo " + CLAUDE.md — added gstack $MODE section"
|
||||
fi
|
||||
|
||||
# ── Required mode: enforcement hook ────────────────────────────
|
||||
|
||||
if [ "$MODE" = "required" ]; then
|
||||
HOOKS_DIR="$REPO_ROOT/.claude/hooks"
|
||||
SETTINGS="$REPO_ROOT/.claude/settings.json"
|
||||
|
||||
# Create enforcement hook script
|
||||
mkdir -p "$HOOKS_DIR"
|
||||
cat > "$HOOKS_DIR/check-gstack.sh" << 'HOOK_EOF'
|
||||
#!/bin/bash
|
||||
# Block skill usage when gstack is not installed globally.
|
||||
|
||||
if [ ! -d "$HOME/.claude/skills/gstack/bin" ]; then
|
||||
cat >&2 <<'MSG'
|
||||
BLOCKED: gstack is not installed globally.
|
||||
|
||||
gstack is required for AI-assisted work in this repo.
|
||||
|
||||
Install it:
|
||||
git clone --depth 1 https://github.com/garrytan/gstack.git ~/.claude/skills/gstack
|
||||
cd ~/.claude/skills/gstack && ./setup --team
|
||||
|
||||
Then restart your AI coding tool.
|
||||
MSG
|
||||
echo '{"permissionDecision":"deny","message":"gstack is required but not installed. See stderr for install instructions."}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo '{}'
|
||||
HOOK_EOF
|
||||
chmod +x "$HOOKS_DIR/check-gstack.sh"
|
||||
GENERATED+=(".claude/hooks/check-gstack.sh")
|
||||
echo " + .claude/hooks/check-gstack.sh — enforcement hook"
|
||||
|
||||
# Add hook to project-level settings.json
|
||||
if command -v bun >/dev/null 2>&1; then
|
||||
bun -e "
|
||||
const fs = require('fs');
|
||||
const settingsPath = '$SETTINGS';
|
||||
|
||||
let settings = {};
|
||||
try { settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch {}
|
||||
|
||||
if (!settings.hooks) settings.hooks = {};
|
||||
if (!settings.hooks.PreToolUse) settings.hooks.PreToolUse = [];
|
||||
|
||||
// Dedup
|
||||
const exists = settings.hooks.PreToolUse.some(entry =>
|
||||
entry.matcher === 'Skill' &&
|
||||
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('check-gstack'))
|
||||
);
|
||||
|
||||
if (!exists) {
|
||||
settings.hooks.PreToolUse.push({
|
||||
matcher: 'Skill',
|
||||
hooks: [{
|
||||
type: 'command',
|
||||
command: '\"\$CLAUDE_PROJECT_DIR/.claude/hooks/check-gstack.sh\"'
|
||||
}]
|
||||
});
|
||||
}
|
||||
|
||||
const tmp = settingsPath + '.tmp';
|
||||
fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + '\n');
|
||||
fs.renameSync(tmp, settingsPath);
|
||||
" 2>/dev/null
|
||||
GENERATED+=(".claude/settings.json")
|
||||
echo " + .claude/settings.json — PreToolUse hook registered"
|
||||
else
|
||||
echo " ! bun not found — manually add the PreToolUse hook to .claude/settings.json"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Summary ────────────────────────────────────────────────────
|
||||
|
||||
echo ""
|
||||
echo "Team mode ($MODE) initialized."
|
||||
echo ""
|
||||
if [ ${#GENERATED[@]} -gt 0 ]; then
|
||||
echo "Commit the generated files:"
|
||||
echo " git add ${GENERATED[*]}"
|
||||
echo " git commit -m \"chore: require gstack for AI-assisted work\""
|
||||
fi
|
||||
echo ""
|
||||
echo "Each developer then runs:"
|
||||
echo " git clone --depth 1 https://github.com/garrytan/gstack.git ~/.claude/skills/gstack"
|
||||
echo " cd ~/.claude/skills/gstack && ./setup --team"
|
||||
@@ -227,6 +227,13 @@ if [ -n "$_GIT_ROOT" ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# ─── Remove SessionStart hook from Claude Code settings ─────
|
||||
SETTINGS_HOOK="$(dirname "$0")/gstack-settings-hook"
|
||||
SESSION_UPDATE="$(dirname "$0")/gstack-session-update"
|
||||
if [ -x "$SETTINGS_HOOK" ]; then
|
||||
"$SETTINGS_HOOK" remove "$SESSION_UPDATE" 2>/dev/null && REMOVED+=("SessionStart hook") || true
|
||||
fi
|
||||
|
||||
# ─── Remove global state ────────────────────────────────────
|
||||
if [ "$KEEP_STATE" -eq 0 ] && [ -d "$STATE_DIR" ]; then
|
||||
rm -rf "$STATE_DIR"
|
||||
|
||||
Reference in New Issue
Block a user