diff --git a/bin/gstack-settings-hook b/bin/gstack-settings-hook index 8879a7d21..6d663b23f 100755 --- a/bin/gstack-settings-hook +++ b/bin/gstack-settings-hook @@ -1,21 +1,44 @@ #!/usr/bin/env bash -# gstack-settings-hook — add/remove SessionStart hooks in Claude Code settings.json +# gstack-settings-hook — manage Claude Code hooks in ~/.claude/settings.json # -# Usage: -# gstack-settings-hook add # add SessionStart hook -# gstack-settings-hook remove # remove SessionStart hook +# 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. # -# 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 +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 @@ -24,59 +47,239 @@ if ! command -v bun >/dev/null 2>&1; then 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) - GSTACK_SETTINGS_PATH="$SETTINGS_FILE" GSTACK_HOOK_CMD="$HOOK_CMD" bun -e " - const fs = require('fs'); + 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 {} - + 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')) + 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 }] + hooks: [{ type: "command", command: hookCmd }] }); } - - const tmp = settingsPath + '.tmp'; - fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + '\n'); + const tmp = settingsPath + ".tmp"; + fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n"); fs.renameSync(tmp, settingsPath); - " 2>/dev/null + ' 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 - GSTACK_SETTINGS_PATH="$SETTINGS_FILE" bun -e " - const fs = require('fs'); + 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); } - + 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'))) + !(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'); + const tmp = settingsPath + ".tmp"; + fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n"); fs.renameSync(tmp, settingsPath); - " 2>/dev/null + ' 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 (expected add or remove)" >&2 + echo "Unknown action: $ACTION" >&2 exit 1 ;; esac diff --git a/test/gstack-settings-hook-schema-aware.test.ts b/test/gstack-settings-hook-schema-aware.test.ts new file mode 100644 index 000000000..ada8ec40c --- /dev/null +++ b/test/gstack-settings-hook-schema-aware.test.ts @@ -0,0 +1,302 @@ +/** + * gstack-settings-hook schema-aware surface (T3 plan-tune cathedral). + * + * Verifies add-event / remove-source / diff-event / rollback / list-sources + * for PreToolUse + PostToolUse registration. Existing team-mode.test.ts + * covers the legacy `add ` / `remove ` shape; this file only + * covers the new surface introduced for the plan-tune cathedral. + */ + +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { execSync } from 'child_process'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const SETTINGS_HOOK = path.join(ROOT, 'bin', 'gstack-settings-hook'); + +let tmpDir: string; +let settingsFile: string; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-shsa-')); + settingsFile = path.join(tmpDir, 'settings.json'); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +function run(args: string[]): { stdout: string; stderr: string; exitCode: number } { + try { + const stdout = execSync([SETTINGS_HOOK, ...args].map((s) => `'${s}'`).join(' '), { + env: { ...process.env, GSTACK_SETTINGS_FILE: settingsFile }, + encoding: 'utf-8', + timeout: 10000, + }); + return { stdout, stderr: '', exitCode: 0 }; + } catch (e: any) { + return { stdout: e.stdout || '', stderr: e.stderr || '', exitCode: e.status ?? 1 }; + } +} + +function settings(): any { + return JSON.parse(fs.readFileSync(settingsFile, 'utf-8')); +} + +// ---------------------------------------------------------------------- +// add-event +// ---------------------------------------------------------------------- + +describe('add-event', () => { + test('registers a PreToolUse hook with matcher + source tag', () => { + const r = run([ + 'add-event', + '--event', 'PreToolUse', + '--matcher', '(AskUserQuestion|mcp__.*__AskUserQuestion)', + '--command', '/abs/path/to/question-preference-hook', + '--source', 'plan-tune-cathedral', + '--timeout', '5', + ]); + expect(r.exitCode).toBe(0); + const s = settings(); + expect(s.hooks.PreToolUse).toHaveLength(1); + expect(s.hooks.PreToolUse[0].matcher).toBe('(AskUserQuestion|mcp__.*__AskUserQuestion)'); + expect(s.hooks.PreToolUse[0]._gstack_source).toBe('plan-tune-cathedral'); + expect(s.hooks.PreToolUse[0].hooks[0].command).toBe('/abs/path/to/question-preference-hook'); + expect(s.hooks.PreToolUse[0].hooks[0].timeout).toBe(5); + }); + + test('registers a PostToolUse hook independently of PreToolUse', () => { + run([ + 'add-event', + '--event', 'PreToolUse', + '--matcher', 'AskUserQuestion', + '--command', '/pre', + '--source', 'plan-tune-cathedral', + ]); + const r = run([ + 'add-event', + '--event', 'PostToolUse', + '--matcher', 'AskUserQuestion', + '--command', '/post', + '--source', 'plan-tune-cathedral', + ]); + expect(r.exitCode).toBe(0); + const s = settings(); + expect(s.hooks.PreToolUse).toHaveLength(1); + expect(s.hooks.PostToolUse).toHaveLength(1); + expect(s.hooks.PreToolUse[0].hooks[0].command).toBe('/pre'); + expect(s.hooks.PostToolUse[0].hooks[0].command).toBe('/post'); + }); + + test('idempotent: re-adding same (event, matcher, source) updates in place', () => { + run([ + 'add-event', + '--event', 'PreToolUse', + '--matcher', 'AskUserQuestion', + '--command', '/v1', + '--source', 'plan-tune-cathedral', + ]); + run([ + 'add-event', + '--event', 'PreToolUse', + '--matcher', 'AskUserQuestion', + '--command', '/v2', + '--source', 'plan-tune-cathedral', + ]); + const s = settings(); + expect(s.hooks.PreToolUse).toHaveLength(1); + expect(s.hooks.PreToolUse[0].hooks[0].command).toBe('/v2'); + }); + + test('preserves unrelated existing hooks', () => { + fs.writeFileSync( + settingsFile, + JSON.stringify({ + hooks: { + PreToolUse: [ + { + matcher: 'Bash', + hooks: [{ type: 'command', command: '/user-own-hook' }], + }, + ], + }, + }, null, 2), + ); + run([ + 'add-event', + '--event', 'PreToolUse', + '--matcher', 'AskUserQuestion', + '--command', '/gstack-hook', + '--source', 'plan-tune-cathedral', + ]); + const s = settings(); + expect(s.hooks.PreToolUse).toHaveLength(2); + // User's Bash hook still present + const bash = s.hooks.PreToolUse.find((e: any) => e.matcher === 'Bash'); + expect(bash).toBeDefined(); + expect(bash.hooks[0].command).toBe('/user-own-hook'); + }); + + test('writes a timestamped backup before mutating', () => { + fs.writeFileSync(settingsFile, JSON.stringify({ existing: 'value' })); + run([ + 'add-event', + '--event', 'PreToolUse', + '--matcher', 'AskUserQuestion', + '--command', '/gstack', + '--source', 'plan-tune-cathedral', + ]); + const backups = fs + .readdirSync(tmpDir) + .filter((f) => f.startsWith('settings.json.bak.')); + expect(backups.length).toBeGreaterThanOrEqual(1); + const backupContent = JSON.parse(fs.readFileSync(path.join(tmpDir, backups[0]), 'utf-8')); + expect(backupContent.existing).toBe('value'); + expect(backupContent.hooks).toBeUndefined(); + }); + + test('rejects invalid --event', () => { + const r = run([ + 'add-event', + '--event', 'NotAnEvent', + '--command', '/x', + '--source', 'plan-tune', + ]); + expect(r.exitCode).not.toBe(0); + expect(r.stderr).toMatch(/invalid --event/); + }); +}); + +// ---------------------------------------------------------------------- +// remove-source +// ---------------------------------------------------------------------- + +describe('remove-source', () => { + test('removes all entries with a given source tag, leaves others alone', () => { + fs.writeFileSync( + settingsFile, + JSON.stringify({ + hooks: { + PreToolUse: [ + { matcher: 'Bash', hooks: [{ command: '/keep-me' }] }, + ], + }, + }), + ); + run([ + 'add-event', + '--event', 'PreToolUse', + '--matcher', 'AskUserQuestion', + '--command', '/a', + '--source', 'plan-tune-cathedral', + ]); + run([ + 'add-event', + '--event', 'PostToolUse', + '--matcher', 'AskUserQuestion', + '--command', '/b', + '--source', 'plan-tune-cathedral', + ]); + const r = run(['remove-source', '--source', 'plan-tune-cathedral']); + expect(r.exitCode).toBe(0); + expect(r.stdout).toMatch(/removed 2 hook/); + const s = settings(); + expect(s.hooks.PostToolUse).toBeUndefined(); + expect(s.hooks.PreToolUse).toHaveLength(1); + expect(s.hooks.PreToolUse[0].hooks[0].command).toBe('/keep-me'); + }); + + test('safely no-ops when settings.json missing', () => { + const r = run(['remove-source', '--source', 'plan-tune-cathedral']); + expect(r.exitCode).toBe(0); + }); +}); + +// ---------------------------------------------------------------------- +// diff-event +// ---------------------------------------------------------------------- + +describe('diff-event', () => { + test('emits BEFORE + AFTER without mutating settings.json', () => { + fs.writeFileSync(settingsFile, JSON.stringify({ existing: 'value' })); + const r = run([ + 'diff-event', + '--event', 'PreToolUse', + '--matcher', 'AskUserQuestion', + '--command', '/gstack', + '--source', 'plan-tune-cathedral', + ]); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain('--- BEFORE'); + expect(r.stdout).toContain('--- AFTER'); + expect(r.stdout).toContain('plan-tune-cathedral'); + // Settings file unchanged. + expect(JSON.parse(fs.readFileSync(settingsFile, 'utf-8'))).toEqual({ existing: 'value' }); + }); +}); + +// ---------------------------------------------------------------------- +// rollback +// ---------------------------------------------------------------------- + +describe('rollback', () => { + test('restores latest backup', () => { + fs.writeFileSync(settingsFile, JSON.stringify({ original: true })); + run([ + 'add-event', + '--event', 'PreToolUse', + '--matcher', 'AskUserQuestion', + '--command', '/gstack', + '--source', 'plan-tune-cathedral', + ]); + expect(settings().hooks).toBeDefined(); + const r = run(['rollback']); + expect(r.exitCode).toBe(0); + const s = settings(); + expect(s.original).toBe(true); + expect(s.hooks).toBeUndefined(); + }); + + test('fails clearly when no backup pointer exists', () => { + const r = run(['rollback']); + expect(r.exitCode).not.toBe(0); + expect(r.stderr).toMatch(/no backup pointer/); + }); +}); + +// ---------------------------------------------------------------------- +// list-sources +// ---------------------------------------------------------------------- + +describe('list-sources', () => { + test('shows source-tagged hooks across all events', () => { + run([ + 'add-event', + '--event', 'PreToolUse', + '--matcher', 'AskUserQuestion', + '--command', '/pre', + '--source', 'plan-tune-cathedral', + ]); + run([ + 'add-event', + '--event', 'PostToolUse', + '--matcher', 'AskUserQuestion', + '--command', '/post', + '--source', 'plan-tune-cathedral', + ]); + const r = run(['list-sources']); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain('PreToolUse'); + expect(r.stdout).toContain('PostToolUse'); + expect(r.stdout).toContain('plan-tune-cathedral'); + }); + + test('empty when no settings file', () => { + const r = run(['list-sources']); + expect(r.exitCode).toBe(0); + expect(r.stdout).toMatch(/no settings file/); + }); +});