#!/usr/bin/env bash # gstack-settings-hook — manage Claude Code hooks in ~/.claude/settings.json # # Two shapes: # # 1. Legacy (SessionStart only — used by setup --team and gstack-uninstall): # gstack-settings-hook add # adds SessionStart hook # gstack-settings-hook remove # removes matching SessionStart hook # # 2. Schema-aware (plan-tune cathedral T3 — supports PreToolUse + PostToolUse): # gstack-settings-hook add-event --event \ # --command --source [--matcher ] [--timeout ] # gstack-settings-hook remove-source --source # gstack-settings-hook diff-event --event ... --command ... --source ... [--matcher ...] # gstack-settings-hook rollback # restore latest backup # gstack-settings-hook list-sources # show all gstack-tagged hook entries # # Every add-event/remove-source writes a backup to ~/.claude/settings.json.bak. # before mutating (Codex correction — silent settings.json mutation is wrong). # # Dedup: legacy `add`/`remove` dedupe by the historical `gstack-session-update` # substring. Schema-aware `add-event` dedupes by (event, matcher, _gstack_source) so # multiple gstack registrations (plan-tune, ...) don't collide. # # Writes atomically: .tmp + rename to prevent corruption on crash/disk-full. set -euo pipefail ACTION="${1:-}" SETTINGS_FILE="${GSTACK_SETTINGS_FILE:-$HOME/.claude/settings.json}" if [ -z "$ACTION" ]; then cat <&2 Usage: gstack-settings-hook add # legacy SessionStart add gstack-settings-hook remove # legacy SessionStart remove gstack-settings-hook add-event --event --command --source [--matcher ] [--timeout ] gstack-settings-hook remove-source --source gstack-settings-hook diff-event --event --command --source [--matcher ] [--timeout ] gstack-settings-hook rollback gstack-settings-hook list-sources EOF exit 1 fi if ! command -v bun >/dev/null 2>&1; then echo "Error: bun is required but not installed." >&2 exit 1 fi backup_settings() { if [ -f "$SETTINGS_FILE" ]; then local ts ts=$(date +%Y%m%d-%H%M%S) cp "$SETTINGS_FILE" "$SETTINGS_FILE.bak.$ts" echo "$SETTINGS_FILE.bak.$ts" > "$SETTINGS_FILE.bak-latest" fi } # --- legacy SessionStart add/remove (backwards compat) ----------------- case "$ACTION" in add) HOOK_CMD="${2:-}" if [ -z "$HOOK_CMD" ]; then echo "Usage: gstack-settings-hook add " >&2 exit 1 fi backup_settings GSTACK_SETTINGS_PATH="$SETTINGS_FILE" GSTACK_HOOK_CMD="$HOOK_CMD" bun -e ' const fs = require("fs"); const settingsPath = process.env.GSTACK_SETTINGS_PATH; const hookCmd = process.env.GSTACK_HOOK_CMD; let settings = {}; try { settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); } catch {} if (!settings.hooks) settings.hooks = {}; if (!settings.hooks.SessionStart) settings.hooks.SessionStart = []; 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) HOOK_CMD="${2:-}" if [ -z "$HOOK_CMD" ]; then echo "Usage: gstack-settings-hook remove " >&2 exit 1 fi [ -f "$SETTINGS_FILE" ] || exit 1 backup_settings GSTACK_SETTINGS_PATH="$SETTINGS_FILE" bun -e ' const fs = require("fs"); const settingsPath = process.env.GSTACK_SETTINGS_PATH; 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 ;; add-event|diff-event) EVENT="" COMMAND="" SOURCE="" MATCHER="" TIMEOUT="" shift while [ $# -gt 0 ]; do case "$1" in --event) EVENT="$2"; shift 2 ;; --command) COMMAND="$2"; shift 2 ;; --source) SOURCE="$2"; shift 2 ;; --matcher) MATCHER="$2"; shift 2 ;; --timeout) TIMEOUT="$2"; shift 2 ;; *) echo "unknown flag: $1" >&2; exit 1 ;; esac done if [ -z "$EVENT" ] || [ -z "$COMMAND" ] || [ -z "$SOURCE" ]; then echo "add-event/diff-event require --event, --command, --source" >&2 exit 1 fi case "$EVENT" in SessionStart|PreToolUse|PostToolUse|UserPromptSubmit|Stop|Notification) ;; *) echo "invalid --event '$EVENT'; must be one of SessionStart|PreToolUse|PostToolUse|UserPromptSubmit|Stop|Notification" >&2; exit 1 ;; esac if [ "$ACTION" = "add-event" ]; then backup_settings fi DIFF_ONLY="" if [ "$ACTION" = "diff-event" ]; then DIFF_ONLY=1; fi GSTACK_SETTINGS_PATH="$SETTINGS_FILE" \ GSTACK_EVENT="$EVENT" \ GSTACK_COMMAND="$COMMAND" \ GSTACK_SOURCE="$SOURCE" \ GSTACK_MATCHER="$MATCHER" \ GSTACK_TIMEOUT="$TIMEOUT" \ GSTACK_DIFF_ONLY="$DIFF_ONLY" \ bun -e ' const fs = require("fs"); const settingsPath = process.env.GSTACK_SETTINGS_PATH; const event = process.env.GSTACK_EVENT; const cmd = process.env.GSTACK_COMMAND; const source = process.env.GSTACK_SOURCE; const matcher = process.env.GSTACK_MATCHER || ""; const timeoutRaw = process.env.GSTACK_TIMEOUT || ""; const diffOnly = process.env.GSTACK_DIFF_ONLY === "1"; let settings = {}; try { settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); } catch {} const before = JSON.stringify(settings, null, 2); if (!settings.hooks) settings.hooks = {}; if (!settings.hooks[event]) settings.hooks[event] = []; const matchesEntry = (entry) => { const sameMatcher = (entry.matcher || "") === matcher; const sameSource = entry._gstack_source === source; return sameMatcher && sameSource; }; let existing = settings.hooks[event].find(matchesEntry); const hookEntry = { type: "command", command: cmd }; if (timeoutRaw) { const n = Number(timeoutRaw); if (Number.isFinite(n) && n > 0) hookEntry.timeout = n; } if (existing) { existing.hooks = [hookEntry]; } else { const newEntry = { _gstack_source: source, hooks: [hookEntry] }; if (matcher) newEntry.matcher = matcher; settings.hooks[event].push(newEntry); } const after = JSON.stringify(settings, null, 2); if (diffOnly) { console.log("--- BEFORE"); console.log(before); console.log("--- AFTER"); console.log(after); process.exit(0); } const tmp = settingsPath + ".tmp"; fs.writeFileSync(tmp, after + "\n"); fs.renameSync(tmp, settingsPath); console.log("OK: " + event + " hook registered (source: " + source + ")"); ' ;; remove-source) SOURCE="" shift while [ $# -gt 0 ]; do case "$1" in --source) SOURCE="$2"; shift 2 ;; *) echo "unknown flag: $1" >&2; exit 1 ;; esac done if [ -z "$SOURCE" ]; then echo "remove-source requires --source " >&2 exit 1 fi [ -f "$SETTINGS_FILE" ] || exit 0 backup_settings GSTACK_SETTINGS_PATH="$SETTINGS_FILE" GSTACK_SOURCE="$SOURCE" bun -e ' const fs = require("fs"); const settingsPath = process.env.GSTACK_SETTINGS_PATH; const source = process.env.GSTACK_SOURCE; let settings = {}; try { settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); } catch { process.exit(0); } if (!settings.hooks) { process.exit(0); } let removed = 0; for (const event of Object.keys(settings.hooks)) { const before = settings.hooks[event].length; settings.hooks[event] = settings.hooks[event].filter(entry => entry._gstack_source !== source); removed += before - settings.hooks[event].length; if (settings.hooks[event].length === 0) delete settings.hooks[event]; } 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); console.log("OK: removed " + removed + " hook entry/entries tagged source=" + source); ' ;; rollback) if [ ! -f "$SETTINGS_FILE.bak-latest" ]; then echo "rollback: no backup pointer at $SETTINGS_FILE.bak-latest" >&2 exit 1 fi LATEST=$(cat "$SETTINGS_FILE.bak-latest") if [ ! -f "$LATEST" ]; then echo "rollback: pointer references missing backup $LATEST" >&2 exit 1 fi cp "$LATEST" "$SETTINGS_FILE" echo "OK: restored $SETTINGS_FILE from $LATEST" ;; list-sources) [ -f "$SETTINGS_FILE" ] || { echo "(no settings file)"; exit 0; } GSTACK_SETTINGS_PATH="$SETTINGS_FILE" bun -e ' const fs = require("fs"); let settings = {}; try { settings = JSON.parse(fs.readFileSync(process.env.GSTACK_SETTINGS_PATH, "utf8")); } catch { process.exit(0); } const hooks = settings.hooks || {}; let any = false; for (const event of Object.keys(hooks)) { for (const entry of hooks[event]) { if (entry._gstack_source) { any = true; console.log(event + "\t" + entry._gstack_source + "\t" + (entry.matcher || "(no matcher)")); } } } if (!any) console.log("(no gstack-tagged hooks)"); ' ;; *) echo "Unknown action: $ACTION" >&2 exit 1 ;; esac