feat(settings-hook): schema-aware PreToolUse/PostToolUse registration

Plan-tune cathedral T3 (per D4 + Codex correction). The previous bin only
knew SessionStart and dedup'd on the hardcoded `gstack-session-update`
substring. The cathedral needs PreToolUse + PostToolUse hooks registered
side-by-side with the user's own hooks, with explicit consent UX, backups,
and rollback.

New subcommands:
- add-event --event <SessionStart|PreToolUse|PostToolUse|...> --command <cmd>
  --source <tag> [--matcher <re>] [--timeout <s>]
- remove-source --source <tag>      # removes all entries tagged by source
- diff-event ...                    # preview without mutating
- rollback                          # restore latest backup
- list-sources                      # audit gstack-tagged hooks

Multi-source dedup via a new `_gstack_source` field on each hook entry
(Claude Code preserves unknown fields). Source tag lets plan-tune-cathedral
register PreToolUse + PostToolUse without colliding with the existing
SessionStart wiring, and lets remove-source clean up cleanly during
gstack-uninstall.

Backups written automatically to settings.json.bak.<ts> before any
mutation, with a .bak-latest pointer the rollback subcommand reads.

Existing legacy `add <cmd>` / `remove <cmd>` shape preserved verbatim so
setup --team and gstack-uninstall keep working unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan 2026-05-27 07:40:51 -07:00
parent 6dc113838a
commit 2147532c07
No known key found for this signature in database
GPG Key ID: C1F69E85C74EFE1D
2 changed files with 539 additions and 34 deletions

View File

@ -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 <hook-command> # add SessionStart hook
# gstack-settings-hook remove <hook-command> # remove SessionStart hook
# Two shapes:
#
# 1. Legacy (SessionStart only — used by setup --team and gstack-uninstall):
# gstack-settings-hook add <cmd> # adds SessionStart hook
# gstack-settings-hook remove <cmd> # removes matching SessionStart hook
#
# 2. Schema-aware (plan-tune cathedral T3 — supports PreToolUse + PostToolUse):
# gstack-settings-hook add-event --event <SessionStart|PreToolUse|PostToolUse> \
# --command <cmd> --source <tag> [--matcher <regex>] [--timeout <s>]
# gstack-settings-hook remove-source --source <tag>
# 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.<ts>
# 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} <hook-command>" >&2
if [ -z "$ACTION" ]; then
cat <<EOF >&2
Usage:
gstack-settings-hook add <hook-command> # legacy SessionStart add
gstack-settings-hook remove <hook-command> # legacy SessionStart remove
gstack-settings-hook add-event --event <name> --command <cmd> --source <tag> [--matcher <re>] [--timeout <s>]
gstack-settings-hook remove-source --source <tag>
gstack-settings-hook diff-event --event <name> --command <cmd> --source <tag> [--matcher <re>] [--timeout <s>]
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 <hook-command>" >&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 <hook-command>" >&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 <tag>" >&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

View File

@ -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 <cmd>` / `remove <cmd>` 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/);
});
});