mirror of https://github.com/garrytan/gstack.git
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:
parent
6dc113838a
commit
2147532c07
|
|
@ -1,21 +1,44 @@
|
||||||
#!/usr/bin/env bash
|
#!/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:
|
# Two shapes:
|
||||||
# gstack-settings-hook add <hook-command> # add SessionStart hook
|
#
|
||||||
# gstack-settings-hook remove <hook-command> # remove SessionStart hook
|
# 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.
|
# Writes atomically: .tmp + rename to prevent corruption on crash/disk-full.
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
ACTION="${1:-}"
|
ACTION="${1:-}"
|
||||||
HOOK_CMD="${2:-}"
|
|
||||||
SETTINGS_FILE="${GSTACK_SETTINGS_FILE:-$HOME/.claude/settings.json}"
|
SETTINGS_FILE="${GSTACK_SETTINGS_FILE:-$HOME/.claude/settings.json}"
|
||||||
|
|
||||||
if [ -z "$ACTION" ] || [ -z "$HOOK_CMD" ]; then
|
if [ -z "$ACTION" ]; then
|
||||||
echo "Usage: gstack-settings-hook {add|remove} <hook-command>" >&2
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -24,59 +47,239 @@ if ! command -v bun >/dev/null 2>&1; then
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
case "$ACTION" in
|
||||||
add)
|
add)
|
||||||
GSTACK_SETTINGS_PATH="$SETTINGS_FILE" GSTACK_HOOK_CMD="$HOOK_CMD" bun -e "
|
HOOK_CMD="${2:-}"
|
||||||
const fs = require('fs');
|
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 settingsPath = process.env.GSTACK_SETTINGS_PATH;
|
||||||
const hookCmd = process.env.GSTACK_HOOK_CMD;
|
const hookCmd = process.env.GSTACK_HOOK_CMD;
|
||||||
|
|
||||||
let settings = {};
|
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) settings.hooks = {};
|
||||||
if (!settings.hooks.SessionStart) settings.hooks.SessionStart = [];
|
if (!settings.hooks.SessionStart) settings.hooks.SessionStart = [];
|
||||||
|
|
||||||
// Dedup: check if hook command already registered
|
|
||||||
const exists = settings.hooks.SessionStart.some(entry =>
|
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) {
|
if (!exists) {
|
||||||
settings.hooks.SessionStart.push({
|
settings.hooks.SessionStart.push({
|
||||||
hooks: [{ type: 'command', command: hookCmd }]
|
hooks: [{ type: "command", command: hookCmd }]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const tmp = settingsPath + ".tmp";
|
||||||
const tmp = settingsPath + '.tmp';
|
fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n");
|
||||||
fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + '\n');
|
|
||||||
fs.renameSync(tmp, settingsPath);
|
fs.renameSync(tmp, settingsPath);
|
||||||
" 2>/dev/null
|
' 2>/dev/null
|
||||||
;;
|
;;
|
||||||
|
|
||||||
remove)
|
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
|
[ -f "$SETTINGS_FILE" ] || exit 1
|
||||||
GSTACK_SETTINGS_PATH="$SETTINGS_FILE" bun -e "
|
backup_settings
|
||||||
const fs = require('fs');
|
GSTACK_SETTINGS_PATH="$SETTINGS_FILE" bun -e '
|
||||||
|
const fs = require("fs");
|
||||||
const settingsPath = process.env.GSTACK_SETTINGS_PATH;
|
const settingsPath = process.env.GSTACK_SETTINGS_PATH;
|
||||||
|
|
||||||
let settings = {};
|
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) {
|
if (settings.hooks && settings.hooks.SessionStart) {
|
||||||
settings.hooks.SessionStart = settings.hooks.SessionStart.filter(entry =>
|
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 (settings.hooks.SessionStart.length === 0) delete settings.hooks.SessionStart;
|
||||||
if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
|
if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
|
||||||
}
|
}
|
||||||
|
const tmp = settingsPath + ".tmp";
|
||||||
const tmp = settingsPath + '.tmp';
|
fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n");
|
||||||
fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + '\n');
|
|
||||||
fs.renameSync(tmp, settingsPath);
|
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
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
|
||||||
|
|
@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue