From 1fabf4d2cf6eaaa44233c4d4c753c691ff93bce7 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Tue, 14 Apr 2026 21:23:57 -0700 Subject: [PATCH] fix: consolidate bash hooks without fork storms --- hooks/hooks.json | 172 +------------------ scripts/hooks/auto-tmux-dev.js | 55 ++++-- scripts/hooks/bash-hook-dispatcher.js | 177 ++++++++++++++++++++ scripts/hooks/post-bash-build-complete.js | 48 ++++-- scripts/hooks/post-bash-command-log.js | 31 ++-- scripts/hooks/post-bash-dispatcher.js | 24 +++ scripts/hooks/post-bash-pr-created.js | 52 ++++-- scripts/hooks/pre-bash-commit-quality.js | 6 +- scripts/hooks/pre-bash-dispatcher.js | 24 +++ scripts/hooks/pre-bash-git-push-reminder.js | 52 ++++-- scripts/hooks/pre-bash-tmux-reminder.js | 52 ++++-- tests/hooks/bash-hook-dispatcher.test.js | 114 +++++++++++++ tests/hooks/hooks.test.js | 27 +++ tests/integration/hooks.test.js | 32 ++-- tests/scripts/install-apply.test.js | 18 +- 15 files changed, 605 insertions(+), 279 deletions(-) create mode 100644 scripts/hooks/bash-hook-dispatcher.js create mode 100644 scripts/hooks/post-bash-dispatcher.js create mode 100644 scripts/hooks/pre-bash-dispatcher.js create mode 100644 tests/hooks/bash-hook-dispatcher.test.js diff --git a/hooks/hooks.json b/hooks/hooks.json index 9c1415e2..9c5f7161 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -12,92 +12,12 @@ "-e", "const p=require('path');const r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [[\"ecc\"],[\"ecc@ecc\"],[\"marketplace\",\"ecc\"],[\"everything-claude-code\"],[\"everything-claude-code@everything-claude-code\"],[\"marketplace\",\"everything-claude-code\"]]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of [\"ecc\",\"everything-claude-code\"]){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)", "node", - "scripts/hooks/run-with-flags.js", - "pre:bash:block-no-verify", - "scripts/hooks/block-no-verify.js", - "minimal,standard,strict" + "scripts/hooks/pre-bash-dispatcher.js" ] } ], - "description": "Block git hook-bypass flag to protect pre-commit, commit-msg, and pre-push hooks from being skipped", - "id": "pre:bash:block-no-verify" - }, - { - "matcher": "Bash", - "hooks": [ - { - "type": "command", - "command": [ - "node", - "-e", - "const p=require('path');const r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [[\"ecc\"],[\"ecc@ecc\"],[\"marketplace\",\"ecc\"],[\"everything-claude-code\"],[\"everything-claude-code@everything-claude-code\"],[\"marketplace\",\"everything-claude-code\"]]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of [\"ecc\",\"everything-claude-code\"]){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)", - "node", - "scripts/hooks/auto-tmux-dev.js" - ] - } - ], - "description": "Auto-start dev servers in tmux with directory-based session names", - "id": "pre:bash:auto-tmux-dev" - }, - { - "matcher": "Bash", - "hooks": [ - { - "type": "command", - "command": [ - "node", - "-e", - "const p=require('path');const r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [[\"ecc\"],[\"ecc@ecc\"],[\"marketplace\",\"ecc\"],[\"everything-claude-code\"],[\"everything-claude-code@everything-claude-code\"],[\"marketplace\",\"everything-claude-code\"]]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of [\"ecc\",\"everything-claude-code\"]){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)", - "node", - "scripts/hooks/run-with-flags.js", - "pre:bash:tmux-reminder", - "scripts/hooks/pre-bash-tmux-reminder.js", - "strict" - ] - } - ], - "description": "Reminder to use tmux for long-running commands", - "id": "pre:bash:tmux-reminder" - }, - { - "matcher": "Bash", - "hooks": [ - { - "type": "command", - "command": [ - "node", - "-e", - "const p=require('path');const r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [[\"ecc\"],[\"ecc@ecc\"],[\"marketplace\",\"ecc\"],[\"everything-claude-code\"],[\"everything-claude-code@everything-claude-code\"],[\"marketplace\",\"everything-claude-code\"]]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of [\"ecc\",\"everything-claude-code\"]){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)", - "node", - "scripts/hooks/run-with-flags.js", - "pre:bash:git-push-reminder", - "scripts/hooks/pre-bash-git-push-reminder.js", - "strict" - ] - } - ], - "description": "Reminder before git push to review changes", - "id": "pre:bash:git-push-reminder" - }, - { - "matcher": "Bash", - "hooks": [ - { - "type": "command", - "command": [ - "node", - "-e", - "const p=require('path');const r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [[\"ecc\"],[\"ecc@ecc\"],[\"marketplace\",\"ecc\"],[\"everything-claude-code\"],[\"everything-claude-code@everything-claude-code\"],[\"marketplace\",\"everything-claude-code\"]]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of [\"ecc\",\"everything-claude-code\"]){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)", - "node", - "scripts/hooks/run-with-flags.js", - "pre:bash:commit-quality", - "scripts/hooks/pre-bash-commit-quality.js", - "strict" - ] - } - ], - "description": "Pre-commit quality check: lint staged files, validate commit message format, detect console.log/debugger/secrets before committing", - "id": "pre:bash:commit-quality" + "description": "Consolidated Bash preflight dispatcher for quality, tmux, push, and GateGuard checks", + "id": "pre:bash:dispatcher" }, { "matcher": "Write", @@ -243,27 +163,6 @@ ], "description": "Fact-forcing gate: block first Edit/Write/MultiEdit per file and demand investigation (importers, data schemas, user instruction) before allowing", "id": "pre:edit-write:gateguard-fact-force" - }, - { - "matcher": "Bash", - "hooks": [ - { - "type": "command", - "command": [ - "node", - "-e", - "const p=require('path');const r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [[\"ecc\"],[\"ecc@ecc\"],[\"marketplace\",\"ecc\"],[\"everything-claude-code\"],[\"everything-claude-code@everything-claude-code\"],[\"marketplace\",\"everything-claude-code\"]]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of [\"ecc\",\"everything-claude-code\"]){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)", - "node", - "scripts/hooks/run-with-flags.js", - "pre:bash:gateguard-fact-force", - "scripts/hooks/gateguard-fact-force.js", - "standard,strict" - ], - "timeout": 5 - } - ], - "description": "Fact-forcing gate: block destructive Bash commands and demand rollback plan; quote user instruction on first Bash per session", - "id": "pre:bash:gateguard-fact-force" } ], "PreCompact": [ @@ -318,73 +217,14 @@ "-e", "const p=require('path');const r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [[\"ecc\"],[\"ecc@ecc\"],[\"marketplace\",\"ecc\"],[\"everything-claude-code\"],[\"everything-claude-code@everything-claude-code\"],[\"marketplace\",\"everything-claude-code\"]]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of [\"ecc\",\"everything-claude-code\"]){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)", "node", - "scripts/hooks/post-bash-command-log.js", - "audit" - ] - } - ], - "description": "Audit log all bash commands to ~/.claude/bash-commands.log", - "id": "post:bash:command-log-audit" - }, - { - "matcher": "Bash", - "hooks": [ - { - "type": "command", - "command": [ - "node", - "-e", - "const p=require('path');const r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [[\"ecc\"],[\"ecc@ecc\"],[\"marketplace\",\"ecc\"],[\"everything-claude-code\"],[\"everything-claude-code@everything-claude-code\"],[\"marketplace\",\"everything-claude-code\"]]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of [\"ecc\",\"everything-claude-code\"]){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)", - "node", - "scripts/hooks/post-bash-command-log.js", - "cost" - ] - } - ], - "description": "Cost tracker - log bash tool usage with timestamps", - "id": "post:bash:command-log-cost" - }, - { - "matcher": "Bash", - "hooks": [ - { - "type": "command", - "command": [ - "node", - "-e", - "const p=require('path');const r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [[\"ecc\"],[\"ecc@ecc\"],[\"marketplace\",\"ecc\"],[\"everything-claude-code\"],[\"everything-claude-code@everything-claude-code\"],[\"marketplace\",\"everything-claude-code\"]]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of [\"ecc\",\"everything-claude-code\"]){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)", - "node", - "scripts/hooks/run-with-flags.js", - "post:bash:pr-created", - "scripts/hooks/post-bash-pr-created.js", - "standard,strict" - ] - } - ], - "description": "Log PR URL and provide review command after PR creation", - "id": "post:bash:pr-created" - }, - { - "matcher": "Bash", - "hooks": [ - { - "type": "command", - "command": [ - "node", - "-e", - "const p=require('path');const r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [[\"ecc\"],[\"ecc@ecc\"],[\"marketplace\",\"ecc\"],[\"everything-claude-code\"],[\"everything-claude-code@everything-claude-code\"],[\"marketplace\",\"everything-claude-code\"]]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of [\"ecc\",\"everything-claude-code\"]){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)", - "node", - "scripts/hooks/run-with-flags.js", - "post:bash:build-complete", - "scripts/hooks/post-bash-build-complete.js", - "standard,strict" + "scripts/hooks/post-bash-dispatcher.js" ], "async": true, "timeout": 30 } ], - "description": "Example: async hook for build analysis (runs in background without blocking)", - "id": "post:bash:build-complete" + "description": "Consolidated Bash postflight dispatcher for logging, PR, and build notifications", + "id": "post:bash:dispatcher" }, { "matcher": "Edit|Write|MultiEdit", diff --git a/scripts/hooks/auto-tmux-dev.js b/scripts/hooks/auto-tmux-dev.js index b3a561a8..2f1a7a1e 100755 --- a/scripts/hooks/auto-tmux-dev.js +++ b/scripts/hooks/auto-tmux-dev.js @@ -30,19 +30,10 @@ const { spawnSync } = require('child_process'); const MAX_STDIN = 1024 * 1024; // 1MB limit let data = ''; -process.stdin.setEncoding('utf8'); -process.stdin.on('data', chunk => { - if (data.length < MAX_STDIN) { - const remaining = MAX_STDIN - data.length; - data += chunk.substring(0, remaining); - } -}); - -process.stdin.on('end', () => { - let input; +function run(rawInput) { try { - input = JSON.parse(data); + const input = typeof rawInput === 'string' ? JSON.parse(rawInput) : rawInput; const cmd = input.tool_input?.command || ''; // Detect dev server commands: npm run dev, pnpm dev, yarn dev, bun run dev @@ -60,7 +51,13 @@ process.stdin.on('end', () => { // Windows: open in a new cmd window (non-blocking) // Escape double quotes in cmd for cmd /k syntax const escapedCmd = cmd.replace(/"/g, '""'); - input.tool_input.command = `start "DevServer-${sessionName}" cmd /k "${escapedCmd}"`; + return JSON.stringify({ + ...input, + tool_input: { + ...input.tool_input, + command: `start "DevServer-${sessionName}" cmd /k "${escapedCmd}"`, + }, + }); } else { // Unix (macOS/Linux): Check tmux is available before transforming const tmuxCheck = spawnSync('which', ['tmux'], { encoding: 'utf8' }); @@ -73,16 +70,38 @@ process.stdin.on('end', () => { // 2. Create new detached session with the dev command // 3. Echo confirmation message with instructions for viewing logs const transformedCmd = `SESSION="${sessionName}"; tmux kill-session -t "$SESSION" 2>/dev/null || true; tmux new-session -d -s "$SESSION" '${escapedCmd}' && echo "[Hook] Dev server started in tmux session '${sessionName}'. View logs: tmux capture-pane -t ${sessionName} -p -S -100"`; - - input.tool_input.command = transformedCmd; + return JSON.stringify({ + ...input, + tool_input: { + ...input.tool_input, + command: transformedCmd, + }, + }); } // else: tmux not found, pass through original command unchanged } } - process.stdout.write(JSON.stringify(input)); + + return JSON.stringify(input); } catch { // Invalid input — pass through original data unchanged - process.stdout.write(data); + return typeof rawInput === 'string' ? rawInput : JSON.stringify(rawInput); } - process.exit(0); -}); +} + +if (require.main === module) { + process.stdin.setEncoding('utf8'); + process.stdin.on('data', chunk => { + if (data.length < MAX_STDIN) { + const remaining = MAX_STDIN - data.length; + data += chunk.substring(0, remaining); + } + }); + + process.stdin.on('end', () => { + process.stdout.write(run(data)); + process.exit(0); + }); +} + +module.exports = { run }; diff --git a/scripts/hooks/bash-hook-dispatcher.js b/scripts/hooks/bash-hook-dispatcher.js new file mode 100644 index 00000000..9485738c --- /dev/null +++ b/scripts/hooks/bash-hook-dispatcher.js @@ -0,0 +1,177 @@ +#!/usr/bin/env node +'use strict'; + +const { isHookEnabled } = require('../lib/hook-flags'); + +const { run: runBlockNoVerify } = require('./block-no-verify'); +const { run: runAutoTmuxDev } = require('./auto-tmux-dev'); +const { run: runTmuxReminder } = require('./pre-bash-tmux-reminder'); +const { run: runGitPushReminder } = require('./pre-bash-git-push-reminder'); +const { run: runCommitQuality } = require('./pre-bash-commit-quality'); +const { run: runGateGuard } = require('./gateguard-fact-force'); +const { run: runCommandLog } = require('./post-bash-command-log'); +const { run: runPrCreated } = require('./post-bash-pr-created'); +const { run: runBuildComplete } = require('./post-bash-build-complete'); + +const MAX_STDIN = 1024 * 1024; + +const PRE_BASH_HOOKS = [ + { + id: 'pre:bash:block-no-verify', + profiles: 'minimal,standard,strict', + run: rawInput => runBlockNoVerify(rawInput), + }, + { + id: 'pre:bash:auto-tmux-dev', + run: rawInput => runAutoTmuxDev(rawInput), + }, + { + id: 'pre:bash:tmux-reminder', + profiles: 'strict', + run: rawInput => runTmuxReminder(rawInput), + }, + { + id: 'pre:bash:git-push-reminder', + profiles: 'strict', + run: rawInput => runGitPushReminder(rawInput), + }, + { + id: 'pre:bash:commit-quality', + profiles: 'strict', + run: rawInput => runCommitQuality(rawInput), + }, + { + id: 'pre:bash:gateguard-fact-force', + profiles: 'standard,strict', + run: rawInput => runGateGuard(rawInput), + }, +]; + +const POST_BASH_HOOKS = [ + { + id: 'post:bash:command-log-audit', + run: rawInput => runCommandLog(rawInput, 'audit'), + }, + { + id: 'post:bash:command-log-cost', + run: rawInput => runCommandLog(rawInput, 'cost'), + }, + { + id: 'post:bash:pr-created', + profiles: 'standard,strict', + run: rawInput => runPrCreated(rawInput), + }, + { + id: 'post:bash:build-complete', + profiles: 'standard,strict', + run: rawInput => runBuildComplete(rawInput), + }, +]; + +function readStdinRaw() { + return new Promise(resolve => { + let raw = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', chunk => { + if (raw.length < MAX_STDIN) { + const remaining = MAX_STDIN - raw.length; + raw += chunk.substring(0, remaining); + } + }); + process.stdin.on('end', () => resolve(raw)); + process.stdin.on('error', () => resolve(raw)); + }); +} + +function normalizeHookResult(previousRaw, output) { + if (typeof output === 'string' || Buffer.isBuffer(output)) { + return { + raw: String(output), + stderr: '', + exitCode: 0, + }; + } + + if (output && typeof output === 'object') { + const nextRaw = Object.prototype.hasOwnProperty.call(output, 'stdout') + ? String(output.stdout ?? '') + : !Number.isInteger(output.exitCode) || output.exitCode === 0 + ? previousRaw + : ''; + + return { + raw: nextRaw, + stderr: typeof output.stderr === 'string' ? output.stderr : '', + exitCode: Number.isInteger(output.exitCode) ? output.exitCode : 0, + }; + } + + return { + raw: previousRaw, + stderr: '', + exitCode: 0, + }; +} + +function runHooks(rawInput, hooks) { + let currentRaw = rawInput; + let stderr = ''; + + for (const hook of hooks) { + if (!isHookEnabled(hook.id, { profiles: hook.profiles })) { + continue; + } + + try { + const result = normalizeHookResult(currentRaw, hook.run(currentRaw)); + currentRaw = result.raw; + if (result.stderr) { + stderr += result.stderr.endsWith('\n') ? result.stderr : `${result.stderr}\n`; + } + if (result.exitCode !== 0) { + return { output: currentRaw, stderr, exitCode: result.exitCode }; + } + } catch (error) { + stderr += `[Hook] ${hook.id} failed: ${error.message}\n`; + } + } + + return { output: currentRaw, stderr, exitCode: 0 }; +} + +function runPreBash(rawInput) { + return runHooks(rawInput, PRE_BASH_HOOKS); +} + +function runPostBash(rawInput) { + return runHooks(rawInput, POST_BASH_HOOKS); +} + +async function main() { + const mode = process.argv[2]; + const raw = await readStdinRaw(); + + const result = mode === 'post' + ? runPostBash(raw) + : runPreBash(raw); + + if (result.stderr) { + process.stderr.write(result.stderr); + } + process.stdout.write(result.output); + process.exit(result.exitCode); +} + +if (require.main === module) { + main().catch(error => { + process.stderr.write(`[Hook] bash-hook-dispatcher failed: ${error.message}\n`); + process.exit(0); + }); +} + +module.exports = { + PRE_BASH_HOOKS, + POST_BASH_HOOKS, + runPreBash, + runPostBash, +}; diff --git a/scripts/hooks/post-bash-build-complete.js b/scripts/hooks/post-bash-build-complete.js index ad26c948..b7a8275c 100755 --- a/scripts/hooks/post-bash-build-complete.js +++ b/scripts/hooks/post-bash-build-complete.js @@ -4,24 +4,46 @@ const MAX_STDIN = 1024 * 1024; let raw = ''; -process.stdin.setEncoding('utf8'); -process.stdin.on('data', chunk => { - if (raw.length < MAX_STDIN) { - const remaining = MAX_STDIN - raw.length; - raw += chunk.substring(0, remaining); - } -}); - -process.stdin.on('end', () => { +function run(rawInput) { try { - const input = JSON.parse(raw); + const input = typeof rawInput === 'string' ? JSON.parse(rawInput) : rawInput; const cmd = String(input.tool_input?.command || ''); if (/(npm run build|pnpm build|yarn build)/.test(cmd)) { - console.error('[Hook] Build completed - async analysis running in background'); + return { + stdout: typeof rawInput === 'string' ? rawInput : JSON.stringify(rawInput), + stderr: '[Hook] Build completed - async analysis running in background', + exitCode: 0, + }; } } catch { // ignore parse errors and pass through } - process.stdout.write(raw); -}); + return typeof rawInput === 'string' ? rawInput : JSON.stringify(rawInput); +} + +if (require.main === module) { + process.stdin.setEncoding('utf8'); + process.stdin.on('data', chunk => { + if (raw.length < MAX_STDIN) { + const remaining = MAX_STDIN - raw.length; + raw += chunk.substring(0, remaining); + } + }); + + process.stdin.on('end', () => { + const result = run(raw); + if (result && typeof result === 'object') { + if (result.stderr) { + process.stderr.write(`${result.stderr}\n`); + } + process.stdout.write(String(result.stdout || '')); + process.exitCode = Number.isInteger(result.exitCode) ? result.exitCode : 0; + return; + } + + process.stdout.write(String(result)); + }); +} + +module.exports = { run }; diff --git a/scripts/hooks/post-bash-command-log.js b/scripts/hooks/post-bash-command-log.js index 4e3a1f73..bdea239d 100644 --- a/scripts/hooks/post-bash-command-log.js +++ b/scripts/hooks/post-bash-command-log.js @@ -38,8 +38,24 @@ function appendLine(filePath, line) { fs.appendFileSync(filePath, `${line}\n`, 'utf8'); } +function run(rawInput, mode = 'audit') { + const config = MODE_CONFIG[mode]; + + try { + if (config) { + const input = String(rawInput || '').trim() ? JSON.parse(String(rawInput)) : {}; + const command = sanitizeCommand(input.tool_input?.command || '?'); + appendLine(path.join(os.homedir(), '.claude', config.fileName), config.format(command)); + } + } catch { + // Logging must never block the calling hook. + } + + return typeof rawInput === 'string' ? rawInput : JSON.stringify(rawInput); +} + function main() { - const config = MODE_CONFIG[process.argv[2]]; + const mode = process.argv[2]; process.stdin.setEncoding('utf8'); process.stdin.on('data', chunk => { @@ -50,17 +66,7 @@ function main() { }); process.stdin.on('end', () => { - try { - if (config) { - const input = raw.trim() ? JSON.parse(raw) : {}; - const command = sanitizeCommand(input.tool_input?.command || '?'); - appendLine(path.join(os.homedir(), '.claude', config.fileName), config.format(command)); - } - } catch { - // Logging must never block the calling hook. - } - - process.stdout.write(raw); + process.stdout.write(run(raw, mode)); }); } @@ -69,5 +75,6 @@ if (require.main === module) { } module.exports = { + run, sanitizeCommand, }; diff --git a/scripts/hooks/post-bash-dispatcher.js b/scripts/hooks/post-bash-dispatcher.js new file mode 100644 index 00000000..43ec128c --- /dev/null +++ b/scripts/hooks/post-bash-dispatcher.js @@ -0,0 +1,24 @@ +#!/usr/bin/env node +'use strict'; + +const { runPostBash } = require('./bash-hook-dispatcher'); + +let raw = ''; +const MAX_STDIN = 1024 * 1024; + +process.stdin.setEncoding('utf8'); +process.stdin.on('data', chunk => { + if (raw.length < MAX_STDIN) { + const remaining = MAX_STDIN - raw.length; + raw += chunk.substring(0, remaining); + } +}); + +process.stdin.on('end', () => { + const result = runPostBash(raw); + if (result.stderr) { + process.stderr.write(result.stderr); + } + process.stdout.write(result.output); + process.exitCode = result.exitCode; +}); diff --git a/scripts/hooks/post-bash-pr-created.js b/scripts/hooks/post-bash-pr-created.js index 118e2c08..b18dad53 100755 --- a/scripts/hooks/post-bash-pr-created.js +++ b/scripts/hooks/post-bash-pr-created.js @@ -4,17 +4,9 @@ const MAX_STDIN = 1024 * 1024; let raw = ''; -process.stdin.setEncoding('utf8'); -process.stdin.on('data', chunk => { - if (raw.length < MAX_STDIN) { - const remaining = MAX_STDIN - raw.length; - raw += chunk.substring(0, remaining); - } -}); - -process.stdin.on('end', () => { +function run(rawInput) { try { - const input = JSON.parse(raw); + const input = typeof rawInput === 'string' ? JSON.parse(rawInput) : rawInput; const cmd = String(input.tool_input?.command || ''); if (/\bgh\s+pr\s+create\b/.test(cmd)) { @@ -24,13 +16,45 @@ process.stdin.on('end', () => { const prUrl = match[0]; const repo = prUrl.replace(/https:\/\/github\.com\/([^/]+\/[^/]+)\/pull\/\d+/, '$1'); const prNum = prUrl.replace(/.+\/pull\/(\d+)/, '$1'); - console.error(`[Hook] PR created: ${prUrl}`); - console.error(`[Hook] To review: gh pr review ${prNum} --repo ${repo}`); + return { + stdout: typeof rawInput === 'string' ? rawInput : JSON.stringify(rawInput), + stderr: [ + `[Hook] PR created: ${prUrl}`, + `[Hook] To review: gh pr review ${prNum} --repo ${repo}`, + ].join('\n'), + exitCode: 0, + }; } } } catch { // ignore parse errors and pass through } - process.stdout.write(raw); -}); + return typeof rawInput === 'string' ? rawInput : JSON.stringify(rawInput); +} + +if (require.main === module) { + process.stdin.setEncoding('utf8'); + process.stdin.on('data', chunk => { + if (raw.length < MAX_STDIN) { + const remaining = MAX_STDIN - raw.length; + raw += chunk.substring(0, remaining); + } + }); + + process.stdin.on('end', () => { + const result = run(raw); + if (result && typeof result === 'object') { + if (result.stderr) { + process.stderr.write(`${result.stderr}\n`); + } + process.stdout.write(String(result.stdout || '')); + process.exit(Number.isInteger(result.exitCode) ? result.exitCode : 0); + return; + } + + process.stdout.write(String(result)); + }); +} + +module.exports = { run }; diff --git a/scripts/hooks/pre-bash-commit-quality.js b/scripts/hooks/pre-bash-commit-quality.js index b554bff1..37e6bc7e 100644 --- a/scripts/hooks/pre-bash-commit-quality.js +++ b/scripts/hooks/pre-bash-commit-quality.js @@ -380,7 +380,11 @@ function evaluate(rawInput) { } function run(rawInput) { - return evaluate(rawInput).output; + const result = evaluate(rawInput); + return { + stdout: result.output, + exitCode: result.exitCode, + }; } // ── stdin entry point ──────────────────────────────────────────── diff --git a/scripts/hooks/pre-bash-dispatcher.js b/scripts/hooks/pre-bash-dispatcher.js new file mode 100644 index 00000000..b9ccad7d --- /dev/null +++ b/scripts/hooks/pre-bash-dispatcher.js @@ -0,0 +1,24 @@ +#!/usr/bin/env node +'use strict'; + +const { runPreBash } = require('./bash-hook-dispatcher'); + +let raw = ''; +const MAX_STDIN = 1024 * 1024; + +process.stdin.setEncoding('utf8'); +process.stdin.on('data', chunk => { + if (raw.length < MAX_STDIN) { + const remaining = MAX_STDIN - raw.length; + raw += chunk.substring(0, remaining); + } +}); + +process.stdin.on('end', () => { + const result = runPreBash(raw); + if (result.stderr) { + process.stderr.write(result.stderr); + } + process.stdout.write(result.output); + process.exitCode = result.exitCode; +}); diff --git a/scripts/hooks/pre-bash-git-push-reminder.js b/scripts/hooks/pre-bash-git-push-reminder.js index 6d593886..62db1ff9 100755 --- a/scripts/hooks/pre-bash-git-push-reminder.js +++ b/scripts/hooks/pre-bash-git-push-reminder.js @@ -4,25 +4,49 @@ const MAX_STDIN = 1024 * 1024; let raw = ''; -process.stdin.setEncoding('utf8'); -process.stdin.on('data', chunk => { - if (raw.length < MAX_STDIN) { - const remaining = MAX_STDIN - raw.length; - raw += chunk.substring(0, remaining); - } -}); - -process.stdin.on('end', () => { +function run(rawInput) { try { - const input = JSON.parse(raw); + const input = typeof rawInput === 'string' ? JSON.parse(rawInput) : rawInput; const cmd = String(input.tool_input?.command || ''); if (/\bgit\s+push\b/.test(cmd)) { - console.error('[Hook] Review changes before push...'); - console.error('[Hook] Continuing with push (remove this hook to add interactive review)'); + return { + stdout: typeof rawInput === 'string' ? rawInput : JSON.stringify(rawInput), + stderr: [ + '[Hook] Review changes before push...', + '[Hook] Continuing with push (remove this hook to add interactive review)', + ].join('\n'), + exitCode: 0, + }; } } catch { // ignore parse errors and pass through } - process.stdout.write(raw); -}); + return typeof rawInput === 'string' ? rawInput : JSON.stringify(rawInput); +} + +if (require.main === module) { + process.stdin.setEncoding('utf8'); + process.stdin.on('data', chunk => { + if (raw.length < MAX_STDIN) { + const remaining = MAX_STDIN - raw.length; + raw += chunk.substring(0, remaining); + } + }); + + process.stdin.on('end', () => { + const result = run(raw); + if (result && typeof result === 'object') { + if (result.stderr) { + process.stderr.write(`${result.stderr}\n`); + } + process.stdout.write(String(result.stdout || '')); + process.exitCode = Number.isInteger(result.exitCode) ? result.exitCode : 0; + return; + } + + process.stdout.write(String(result)); + }); +} + +module.exports = { run }; diff --git a/scripts/hooks/pre-bash-tmux-reminder.js b/scripts/hooks/pre-bash-tmux-reminder.js index a0d24ae1..fe3833ea 100755 --- a/scripts/hooks/pre-bash-tmux-reminder.js +++ b/scripts/hooks/pre-bash-tmux-reminder.js @@ -4,17 +4,9 @@ const MAX_STDIN = 1024 * 1024; let raw = ''; -process.stdin.setEncoding('utf8'); -process.stdin.on('data', chunk => { - if (raw.length < MAX_STDIN) { - const remaining = MAX_STDIN - raw.length; - raw += chunk.substring(0, remaining); - } -}); - -process.stdin.on('end', () => { +function run(rawInput) { try { - const input = JSON.parse(raw); + const input = typeof rawInput === 'string' ? JSON.parse(rawInput) : rawInput; const cmd = String(input.tool_input?.command || ''); if ( @@ -22,12 +14,44 @@ process.stdin.on('end', () => { !process.env.TMUX && /(npm (install|test)|pnpm (install|test)|yarn (install|test)?|bun (install|test)|cargo build|make\b|docker\b|pytest|vitest|playwright)/.test(cmd) ) { - console.error('[Hook] Consider running in tmux for session persistence'); - console.error('[Hook] tmux new -s dev | tmux attach -t dev'); + return { + stdout: typeof rawInput === 'string' ? rawInput : JSON.stringify(rawInput), + stderr: [ + '[Hook] Consider running in tmux for session persistence', + '[Hook] tmux new -s dev | tmux attach -t dev', + ].join('\n'), + exitCode: 0, + }; } } catch { // ignore parse errors and pass through } - process.stdout.write(raw); -}); + return typeof rawInput === 'string' ? rawInput : JSON.stringify(rawInput); +} + +if (require.main === module) { + process.stdin.setEncoding('utf8'); + process.stdin.on('data', chunk => { + if (raw.length < MAX_STDIN) { + const remaining = MAX_STDIN - raw.length; + raw += chunk.substring(0, remaining); + } + }); + + process.stdin.on('end', () => { + const result = run(raw); + if (result && typeof result === 'object') { + if (result.stderr) { + process.stderr.write(`${result.stderr}\n`); + } + process.stdout.write(String(result.stdout || '')); + process.exitCode = Number.isInteger(result.exitCode) ? result.exitCode : 0; + return; + } + + process.stdout.write(String(result)); + }); +} + +module.exports = { run }; diff --git a/tests/hooks/bash-hook-dispatcher.test.js b/tests/hooks/bash-hook-dispatcher.test.js new file mode 100644 index 00000000..2f89c90b --- /dev/null +++ b/tests/hooks/bash-hook-dispatcher.test.js @@ -0,0 +1,114 @@ +/** + * Tests for consolidated Bash hook dispatchers. + */ + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const preDispatcher = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'pre-bash-dispatcher.js'); +const postDispatcher = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'post-bash-dispatcher.js'); + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + return true; + } catch (error) { + console.log(` ✗ ${name}`); + console.log(` Error: ${error.message}`); + return false; + } +} + +function runScript(scriptPath, input, env = {}) { + return spawnSync('node', [scriptPath], { + input: typeof input === 'string' ? input : JSON.stringify(input), + encoding: 'utf8', + env: { + ...process.env, + ...env, + }, + timeout: 10000, + }); +} + +function runTests() { + console.log('\n=== Testing Bash hook dispatchers ===\n'); + + let passed = 0; + let failed = 0; + + if (test('pre dispatcher blocks --no-verify before other Bash checks', () => { + const input = { tool_input: { command: 'git commit --no-verify -m "x"' } }; + const result = runScript(preDispatcher, input, { ECC_HOOK_PROFILE: 'strict' }); + assert.strictEqual(result.status, 2, 'Expected dispatcher to block git hook bypass'); + assert.ok(result.stderr.includes('--no-verify'), 'Expected block-no-verify reason in stderr'); + assert.strictEqual(result.stdout, '', 'Blocking hook should not pass through stdout'); + })) passed++; else failed++; + + if (test('pre dispatcher still honors per-hook disable flags', () => { + const input = { tool_input: { command: 'git push origin main' } }; + + const enabled = runScript(preDispatcher, input, { ECC_HOOK_PROFILE: 'strict' }); + assert.strictEqual(enabled.status, 0); + assert.ok(enabled.stderr.includes('Review changes before push'), 'Expected git push reminder when enabled'); + + const disabled = runScript(preDispatcher, input, { + ECC_HOOK_PROFILE: 'strict', + ECC_DISABLED_HOOKS: 'pre:bash:git-push-reminder', + }); + assert.strictEqual(disabled.status, 0); + assert.ok(!disabled.stderr.includes('Review changes before push'), 'Disabled hook should not emit reminder'); + })) passed++; else failed++; + + if (test('pre dispatcher respects hook profiles inside the consolidated path', () => { + const input = { tool_input: { command: 'git push origin main' } }; + const result = runScript(preDispatcher, input, { ECC_HOOK_PROFILE: 'minimal' }); + assert.strictEqual(result.status, 0); + assert.strictEqual(result.stderr, '', 'Strict-only reminders should stay disabled in minimal profile'); + assert.strictEqual(result.stdout, JSON.stringify(input)); + })) passed++; else failed++; + + if (test('post dispatcher writes both bash audit and cost logs in one pass', () => { + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-bash-dispatcher-')); + const payload = { tool_input: { command: 'npm publish --token=$PUBLISH_TOKEN' } }; + + try { + const result = runScript(postDispatcher, payload, { + HOME: homeDir, + USERPROFILE: homeDir, + }); + assert.strictEqual(result.status, 0); + assert.strictEqual(result.stdout, JSON.stringify(payload)); + + const auditLog = fs.readFileSync(path.join(homeDir, '.claude', 'bash-commands.log'), 'utf8'); + const costLog = fs.readFileSync(path.join(homeDir, '.claude', 'cost-tracker.log'), 'utf8'); + + assert.ok(auditLog.includes('--token=')); + assert.ok(costLog.includes('tool=Bash command=npm publish --token=')); + assert.ok(!auditLog.includes('$PUBLISH_TOKEN')); + assert.ok(!costLog.includes('$PUBLISH_TOKEN')); + } finally { + fs.rmSync(homeDir, { recursive: true, force: true }); + } + })) passed++; else failed++; + + if (test('post dispatcher preserves PR-created hints after consolidated execution', () => { + const payload = { + tool_input: { command: 'gh pr create --title "Fix bug" --body "desc"' }, + tool_output: { output: 'https://github.com/owner/repo/pull/42\n' }, + }; + const result = runScript(postDispatcher, payload); + assert.strictEqual(result.status, 0); + assert.ok(result.stderr.includes('PR created: https://github.com/owner/repo/pull/42')); + assert.ok(result.stderr.includes('gh pr review 42 --repo owner/repo')); + })) passed++; else failed++; + + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); + process.exit(failed > 0 ? 1 : 0); +} + +runTests(); diff --git a/tests/hooks/hooks.test.js b/tests/hooks/hooks.test.js index 2cc3c8d9..baa1c49a 100644 --- a/tests/hooks/hooks.test.js +++ b/tests/hooks/hooks.test.js @@ -1888,6 +1888,33 @@ async function runTests() { passed++; else failed++; + if ( + test('hooks.json consolidates Bash hooks into one pre and one post dispatcher', () => { + const hooksPath = path.join(__dirname, '..', '..', 'hooks', 'hooks.json'); + const hooks = JSON.parse(fs.readFileSync(hooksPath, 'utf8')); + + const preBash = hooks.hooks.PreToolUse.filter(entry => entry.matcher === 'Bash'); + const postBash = hooks.hooks.PostToolUse.filter(entry => entry.matcher === 'Bash'); + + assert.strictEqual(preBash.length, 1, 'Should have exactly one PreToolUse Bash dispatcher'); + assert.strictEqual(postBash.length, 1, 'Should have exactly one PostToolUse Bash dispatcher'); + assert.strictEqual(preBash[0].id, 'pre:bash:dispatcher'); + assert.strictEqual(postBash[0].id, 'post:bash:dispatcher'); + + const preCommand = Array.isArray(preBash[0].hooks[0].command) + ? preBash[0].hooks[0].command.join(' ') + : preBash[0].hooks[0].command; + const postCommand = Array.isArray(postBash[0].hooks[0].command) + ? postBash[0].hooks[0].command.join(' ') + : postBash[0].hooks[0].command; + + assert.ok(preCommand.includes('pre-bash-dispatcher.js'), 'PreToolUse Bash hook should use the pre dispatcher'); + assert.ok(postCommand.includes('post-bash-dispatcher.js'), 'PostToolUse Bash hook should use the post dispatcher'); + }) + ) + passed++; + else failed++; + if ( test('SessionEnd marker hook is async and cleanup-safe', () => { const hooksPath = path.join(__dirname, '..', '..', 'hooks', 'hooks.json'); diff --git a/tests/integration/hooks.test.js b/tests/integration/hooks.test.js index 1f88b95f..5d6b52bf 100644 --- a/tests/integration/hooks.test.js +++ b/tests/integration/hooks.test.js @@ -256,6 +256,14 @@ function getHookCommandByDescription(hooks, lifecycle, descriptionText) { return hookGroup.hooks[0].command; } +function getHookCommandById(hooks, lifecycle, hookId) { + const hookGroup = hooks.hooks[lifecycle]?.find(entry => entry.id === hookId); + + assert.ok(hookGroup, `Expected ${lifecycle} hook with id "${hookId}"`); + assert.ok(hookGroup.hooks?.[0]?.command, `Expected ${lifecycle} hook command for id "${hookId}"`); + return hookGroup.hooks[0].command; +} + // Test suite async function runTests() { console.log('\n=== Hook Integration Tests ===\n'); @@ -340,12 +348,7 @@ async function runTests() { })) passed++; else failed++; if (await asyncTest('dev server hook transforms command to tmux session', async () => { - // Test the auto-tmux dev hook — transforms dev commands to run in tmux - const hookCommand = getHookCommandByDescription( - hooks, - 'PreToolUse', - 'Auto-start dev servers in tmux' - ); + const hookCommand = getHookCommandById(hooks, 'PreToolUse', 'pre:bash:dispatcher'); const result = await runHookCommand(hookCommand, { tool_input: { command: 'npm run dev' } }); @@ -526,12 +529,7 @@ async function runTests() { })) passed++; else failed++; if (await asyncTest('dev server hook transforms yarn dev to tmux session', async () => { - // The auto-tmux dev hook transforms dev commands (yarn dev, npm run dev, etc.) - const hookCommand = getHookCommandByDescription( - hooks, - 'PreToolUse', - 'Auto-start dev servers in tmux' - ); + const hookCommand = getHookCommandById(hooks, 'PreToolUse', 'pre:bash:dispatcher'); const result = await runHookCommand(hookCommand, { tool_input: { command: 'yarn dev' } }); @@ -663,14 +661,8 @@ async function runTests() { })) passed++; else failed++; if (await asyncTest('PostToolUse PR hook extracts PR URL', async () => { - // Find the PR logging hook - const prHook = hooks.hooks.PostToolUse.find(h => - h.description && h.description.includes('PR URL') - ); - - assert.ok(prHook, 'PR hook should exist'); - - const result = await runHookCommand(prHook.hooks[0].command, { + const hookCommand = getHookCommandById(hooks, 'PostToolUse', 'post:bash:dispatcher'); + const result = await runHookCommand(hookCommand, { tool_input: { command: 'gh pr create --title "Test"' }, tool_output: { output: 'Creating pull request...\nhttps://github.com/owner/repo/pull/123' } }); diff --git a/tests/scripts/install-apply.test.js b/tests/scripts/install-apply.test.js index 775cb928..9130a9ad 100644 --- a/tests/scripts/install-apply.test.js +++ b/tests/scripts/install-apply.test.js @@ -361,23 +361,27 @@ function runTests() { const claudeRoot = path.join(homeDir, '.claude'); const installedHooks = readJson(path.join(claudeRoot, 'hooks', 'hooks.json')); - const installedAutoTmuxEntry = installedHooks.hooks.PreToolUse.find(entry => entry.id === 'pre:bash:auto-tmux-dev'); - assert.ok(installedAutoTmuxEntry, 'hooks/hooks.json should include the auto tmux hook'); - assert.ok(Array.isArray(installedAutoTmuxEntry.hooks[0].command), 'hooks/hooks.json should install argv-form commands for cross-platform safety'); + const installedBashDispatcherEntry = installedHooks.hooks.PreToolUse.find(entry => entry.id === 'pre:bash:dispatcher'); + assert.ok(installedBashDispatcherEntry, 'hooks/hooks.json should include the consolidated Bash dispatcher hook'); + assert.ok(Array.isArray(installedBashDispatcherEntry.hooks[0].command), 'hooks/hooks.json should install argv-form commands for cross-platform safety'); assert.ok( - installedAutoTmuxEntry.hooks[0].command[0] === 'node' && installedAutoTmuxEntry.hooks[0].command[1] === '-e', + installedBashDispatcherEntry.hooks[0].command[0] === 'node' && installedBashDispatcherEntry.hooks[0].command[1] === '-e', 'hooks/hooks.json should use the inline node bootstrap contract' ); assert.ok( - installedAutoTmuxEntry.hooks[0].command.some(part => String(part).includes('plugin-hook-bootstrap.js')), + installedBashDispatcherEntry.hooks[0].command.some(part => String(part).includes('plugin-hook-bootstrap.js')), 'hooks/hooks.json should route plugin-managed hooks through the shared bootstrap' ); assert.ok( - installedAutoTmuxEntry.hooks[0].command.some(part => String(part).includes('CLAUDE_PLUGIN_ROOT')), + installedBashDispatcherEntry.hooks[0].command.some(part => String(part).includes('CLAUDE_PLUGIN_ROOT')), 'hooks/hooks.json should still consult CLAUDE_PLUGIN_ROOT for runtime resolution' ); assert.ok( - !installedAutoTmuxEntry.hooks[0].command.some(part => String(part).includes('${CLAUDE_PLUGIN_ROOT}')), + installedBashDispatcherEntry.hooks[0].command.some(part => String(part).includes('pre-bash-dispatcher.js')), + 'hooks/hooks.json should point the Bash preflight contract at the consolidated dispatcher' + ); + assert.ok( + !installedBashDispatcherEntry.hooks[0].command.some(part => String(part).includes('${CLAUDE_PLUGIN_ROOT}')), 'hooks/hooks.json should not retain raw CLAUDE_PLUGIN_ROOT shell placeholders after install' ); } finally {