From 9c429d83ae78411a705bb941de4c7258e818f962 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sat, 4 Apr 2026 21:00:27 -0700 Subject: [PATCH] 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) --- bin/gstack-settings-hook | 82 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100755 bin/gstack-settings-hook diff --git a/bin/gstack-settings-hook b/bin/gstack-settings-hook new file mode 100755 index 00000000..93a537f0 --- /dev/null +++ b/bin/gstack-settings-hook @@ -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 # add SessionStart hook +# gstack-settings-hook remove # 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} " >&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