#!/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 ensure-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 # # ensure-event is the update-in-place verb: same flags as add-event, but it # first compares the REGISTERED payload for (event, matcher, source) against # the requested one. Identical → no write, no backup ("unchanged"). Different # → the single matching entry is replaced via one atomic tmp+rename, so a # failed update can never leave zero or two registrations. This is what heals # a stale absolute hook path (e.g. a deleted dev worktree) baked into # settings.json by an earlier setup — presence-only dedup never re-pointed it. # # Every add-event/remove-source writes a backup to ~/.claude/settings.json.bak. # before mutating (Codex correction — silent settings.json mutation is wrong); # ensure-event backs up only when it actually mutates, so a no-op re-run of # ./setup doesn't churn backup files. # # 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:-${CLAUDE_CONFIG_DIR:-$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 ensure-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 = {}; // An EXISTING file that does not parse must never be rewritten: the // old catch{} folded it to {} and the atomic write below replaced the // user permissions/env/other hooks with just ours. Refuse loudly. if (fs.existsSync(settingsPath)) { try { settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); } catch (e) { console.error("error: " + settingsPath + " exists but is not valid JSON (" + (e && e.message ? e.message : e) + "); refusing to rewrite it. Fix or move the file, then re-run."); process.exit(1); } } 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." + process.pid; // per-process: parallel writers must not share a tmp fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n"); fs.renameSync(tmp, settingsPath); ' ;; 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." + process.pid; // per-process: parallel writers must not share a tmp fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n"); fs.renameSync(tmp, settingsPath); ' 2>/dev/null ;; add-event|diff-event|ensure-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/ensure-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 ENSURE="" if [ "$ACTION" = "ensure-event" ]; then ENSURE=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" \ GSTACK_ENSURE="$ENSURE" \ 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"; const ensure = process.env.GSTACK_ENSURE === "1"; let settings = {}; // An EXISTING file that does not parse must never be rewritten: the // old catch{} folded it to {} and the atomic write below replaced the // user permissions/env/other hooks with just ours. Refuse loudly. if (fs.existsSync(settingsPath)) { try { settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); } catch (e) { console.error("error: " + settingsPath + " exists but is not valid JSON (" + (e && e.message ? e.message : e) + "); refusing to rewrite it. Fix or move the file, then re-run."); process.exit(1); } } const before = JSON.stringify(settings, null, 2); if (!settings.hooks) settings.hooks = {}; if (!settings.hooks[event]) settings.hooks[event] = []; // Identity key is (event, source): any existing entry carrying OUR // source tag for this event IS the entry to compare/update — a matcher // change must update it in place, never push a SECOND gstack entry // (the old key included the matcher, so a future matcher change would // have duplicated the registration). Untagged legacy entries are still // adopted when both matcher and command line up. const matchesEntry = (entry) => { if (entry._gstack_source === source) return true; const sameMatcher = (entry.matcher || "") === matcher; const sameCommand = entry.hooks && entry.hooks[0] && entry.hooks[0].command === cmd; return sameMatcher && sameCommand; }; // Collect ALL matches, not just the first: pre-existing installs can // carry two entries with the same (event, _gstack_source) from the old // matcher-keyed dedup. `.find()` updated only the first and left the // stale twin running forever. Keep ONE canonical entry (the first), // remove the rest in the same atomic write. const matched = settings.hooks[event].filter(matchesEntry); let existing = matched.length > 0 ? matched[0] : undefined; let collapsed = 0; if (matched.length > 1) { const extras = new Set(matched.slice(1)); settings.hooks[event] = settings.hooks[event].filter((e) => !extras.has(e)); collapsed = matched.length - 1; } 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]; existing._gstack_source = source; // Keep the matcher current too — under the (event, source) key the // matched entry may carry a stale matcher. if (matcher) existing.matcher = matcher; else delete existing.matcher; } 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); } if (ensure && before === after) { // Registered payload already matches the canonical one — no write, no // backup, no churn. Re-running ./setup stays a true no-op. console.log("OK: " + event + " hook unchanged (source: " + source + ")"); process.exit(0); } try { if (ensure && fs.existsSync(settingsPath)) { // Mirrors backup_settings (bash) — but only when a write actually // happens, so a no-op ensure-event never creates backup files. const d = new Date(); const pad = (n) => String(n).padStart(2, "0"); const ts = "" + d.getFullYear() + pad(d.getMonth() + 1) + pad(d.getDate()) + "-" + pad(d.getHours()) + pad(d.getMinutes()) + pad(d.getSeconds()); fs.copyFileSync(settingsPath, settingsPath + ".bak." + ts); fs.writeFileSync(settingsPath + ".bak-latest", settingsPath + ".bak." + ts + "\n"); } // Atomic tmp+rename: the settings file is either the old JSON (with // the old single registration) or the new JSON (with the replaced // one) — a failed update can never leave zero or two registrations. // Per-process tmp suffix: a fixed settings.json.tmp let two parallel // writers consume one another. (No apostrophes here: this JS lives // inside a bash single-quoted string.) const tmp = settingsPath + ".tmp." + process.pid; fs.writeFileSync(tmp, after + "\n"); fs.renameSync(tmp, settingsPath); } catch (e) { // Explicit catch + exit 1: bun -e has been observed (1.3.13) to turn // an uncaught sync fs error into a SILENT exit 0, which would let a // failed update masquerade as success to the caller. console.error("error: could not update " + settingsPath + ": " + (e && e.message ? e.message : e)); process.exit(1); } if (collapsed > 0) { console.error("collapsed " + collapsed + " duplicate (event, source) hook entr" + (collapsed === 1 ? "y" : "ies") + " for " + event + " (source: " + source + ")"); } if (ensure && existing) { console.log("OK: " + event + " hook re-pointed (source: " + source + ")"); } else { 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." + process.pid; // per-process: parallel writers must not share a 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