From b4e73595311e32c8c9948210a086e511aa630345 Mon Sep 17 00:00:00 2001 From: Chad Woolley Date: Sat, 31 Jan 2026 15:05:06 -0800 Subject: [PATCH] Fix: Claude Code hooks fail because they expect env vars but receive stdin JSON Claude Code passes hook context as JSON via stdin, not as shell environment variables. All generated hook commands referenced nonexistent variables like $TOOL_INPUT_file_path, $PROMPT, $TOOL_SUCCESS, etc., causing every hook (PreToolUse, PostToolUse, UserPromptSubmit, SessionStart, Notification) to silently fail with empty arguments. The fix introduces .claude/hooks/hook-bridge.sh, a bridge script that reads the stdin JSON with jq, extracts the relevant fields, and forwards them as proper CLI arguments. All hook commands in settings.json and the settings-generator now route through this bridge script. Changes: - Add .claude/hooks/hook-bridge.sh bridge script - Update settings-generator.ts to emit bridge-based hook commands - Update static .claude/settings.json template - Update executor.ts to create .claude/hooks/ dir and copy bridge on init Co-Authored-By: claude-flow (cherry picked from commit f8d16a4d48622f215bda1824e5977008f921e0e5) --- .../cli/.claude/hooks/hook-bridge.sh | 100 ++++++++++++++++++ v3/@claude-flow/cli/.claude/settings.json | 44 ++++---- v3/@claude-flow/cli/src/init/executor.ts | 57 ++++++++++ .../cli/src/init/settings-generator.ts | 43 ++++---- 4 files changed, 203 insertions(+), 41 deletions(-) create mode 100755 v3/@claude-flow/cli/.claude/hooks/hook-bridge.sh diff --git a/v3/@claude-flow/cli/.claude/hooks/hook-bridge.sh b/v3/@claude-flow/cli/.claude/hooks/hook-bridge.sh new file mode 100755 index 0000000000..261b405317 --- /dev/null +++ b/v3/@claude-flow/cli/.claude/hooks/hook-bridge.sh @@ -0,0 +1,100 @@ +#!/bin/bash +# hook-bridge.sh - Bridges Claude Code hooks (stdin JSON) to claude-flow CLI +# +# Claude Code passes hook context as JSON via stdin, NOT as shell environment +# variables. This script reads the stdin JSON, extracts the relevant fields +# using jq, and forwards them as proper CLI arguments. +# +# Usage: hook-bridge.sh +# +# Requires: jq (https://jqlang.github.io/jq/) + +MODE="${1:-}" + +# Read JSON from stdin (Claude Code always pipes hook data this way) +INPUT=$(cat 2>/dev/null) || INPUT='{}' +[ -z "$INPUT" ] && INPUT='{}' + +# Safe JSON field extraction - returns empty string on missing/null fields +jf() { + echo "$INPUT" | jq -r "$1" 2>/dev/null | grep -v '^null$' || echo "" +} + +case "$MODE" in + # -- PreToolUse hooks ----------------------------------------------- + pre-edit) + FILE=$(jf '.tool_input.file_path') + [ -z "$FILE" ] && exit 0 + exec npx @claude-flow/cli@latest hooks pre-edit --file "$FILE" + ;; + + pre-command) + CMD=$(jf '.tool_input.command') + [ -z "$CMD" ] && exit 0 + exec npx @claude-flow/cli@latest hooks pre-command --command "$CMD" + ;; + + pre-task) + DESC=$(jf '.tool_input.prompt // .tool_input.description') + [ -z "$DESC" ] && exit 0 + exec npx @claude-flow/cli@latest hooks pre-task --description "$DESC" + ;; + + # -- PostToolUse hooks ---------------------------------------------- + post-edit) + FILE=$(jf '.tool_input.file_path') + [ -z "$FILE" ] && exit 0 + exec npx @claude-flow/cli@latest hooks post-edit --file "$FILE" --success true + ;; + + post-command) + CMD=$(jf '.tool_input.command') + [ -z "$CMD" ] && exit 0 + exec npx @claude-flow/cli@latest hooks post-command --command "$CMD" --success true + ;; + + post-task) + AGENT_ID=$(jf '.tool_input.description // .tool_input.prompt') + [ -z "$AGENT_ID" ] && AGENT_ID="unknown" + exec npx @claude-flow/cli@latest hooks post-task --task-id "$AGENT_ID" --success true + ;; + + # -- UserPromptSubmit ----------------------------------------------- + route) + PROMPT=$(jf '.prompt') + [ -z "$PROMPT" ] && exit 0 + exec npx @claude-flow/cli@latest hooks route --task "$PROMPT" + ;; + + # -- SessionStart --------------------------------------------------- + daemon-start) + exec npx @claude-flow/cli@latest daemon start --quiet + ;; + + session-restore) + SID=$(jf '.session_id') + if [ -n "$SID" ]; then + exec npx @claude-flow/cli@latest hooks session-restore --session-id "$SID" + else + exec npx @claude-flow/cli@latest hooks session-restore --latest + fi + ;; + + # -- Stop ----------------------------------------------------------- + stop-check) + echo '{"ok":true}' + exit 0 + ;; + + # -- Notification --------------------------------------------------- + notify) + MSG=$(jf '.message // .notification_message // .content') + [ -z "$MSG" ] && exit 0 + exec npx @claude-flow/cli@latest memory store --namespace notifications --key "notify" --value "$MSG" + ;; + + *) + echo "Unknown hook mode: $MODE" >&2 + exit 1 + ;; +esac diff --git a/v3/@claude-flow/cli/.claude/settings.json b/v3/@claude-flow/cli/.claude/settings.json index bfbfada31f..8c6127f00c 100644 --- a/v3/@claude-flow/cli/.claude/settings.json +++ b/v3/@claude-flow/cli/.claude/settings.json @@ -6,8 +6,8 @@ "hooks": [ { "type": "command", - "command": "[ -n \"$TOOL_INPUT_file_path\" ] && npx @claude-flow/cli@latest hooks pre-edit --file \"$TOOL_INPUT_file_path\" 2>/dev/null || true", - "timeout": 5000, + "command": ".claude/hooks/hook-bridge.sh pre-edit", + "timeout": 10000, "continueOnError": true } ] @@ -17,8 +17,8 @@ "hooks": [ { "type": "command", - "command": "[ -n \"$TOOL_INPUT_command\" ] && npx @claude-flow/cli@latest hooks pre-command --command \"$TOOL_INPUT_command\" 2>/dev/null || true", - "timeout": 5000, + "command": ".claude/hooks/hook-bridge.sh pre-command", + "timeout": 10000, "continueOnError": true } ] @@ -28,8 +28,8 @@ "hooks": [ { "type": "command", - "command": "[ -n \"$TOOL_INPUT_prompt\" ] && npx @claude-flow/cli@latest hooks pre-task --task-id \"task-$(date +%s)\" --description \"$TOOL_INPUT_prompt\" 2>/dev/null || true", - "timeout": 5000, + "command": ".claude/hooks/hook-bridge.sh pre-task", + "timeout": 10000, "continueOnError": true } ] @@ -41,8 +41,8 @@ "hooks": [ { "type": "command", - "command": "[ -n \"$TOOL_INPUT_file_path\" ] && npx @claude-flow/cli@latest hooks post-edit --file \"$TOOL_INPUT_file_path\" --success \"${TOOL_SUCCESS:-true}\" 2>/dev/null || true", - "timeout": 5000, + "command": ".claude/hooks/hook-bridge.sh post-edit", + "timeout": 10000, "continueOnError": true } ] @@ -52,8 +52,8 @@ "hooks": [ { "type": "command", - "command": "[ -n \"$TOOL_INPUT_command\" ] && npx @claude-flow/cli@latest hooks post-command --command \"$TOOL_INPUT_command\" --success \"${TOOL_SUCCESS:-true}\" 2>/dev/null || true", - "timeout": 5000, + "command": ".claude/hooks/hook-bridge.sh post-command", + "timeout": 10000, "continueOnError": true } ] @@ -63,8 +63,8 @@ "hooks": [ { "type": "command", - "command": "[ -n \"$TOOL_RESULT_agent_id\" ] && npx @claude-flow/cli@latest hooks post-task --task-id \"$TOOL_RESULT_agent_id\" --success \"${TOOL_SUCCESS:-true}\" 2>/dev/null || true", - "timeout": 5000, + "command": ".claude/hooks/hook-bridge.sh post-task", + "timeout": 10000, "continueOnError": true } ] @@ -75,8 +75,8 @@ "hooks": [ { "type": "command", - "command": "[ -n \"$PROMPT\" ] && npx @claude-flow/cli@latest hooks route --task \"$PROMPT\" || true", - "timeout": 5000, + "command": ".claude/hooks/hook-bridge.sh route", + "timeout": 10000, "continueOnError": true } ] @@ -87,14 +87,14 @@ "hooks": [ { "type": "command", - "command": "npx @claude-flow/cli@latest daemon start --quiet 2>/dev/null || true", - "timeout": 5000, + "command": ".claude/hooks/hook-bridge.sh daemon-start", + "timeout": 10000, "continueOnError": true }, { "type": "command", - "command": "[ -n \"$SESSION_ID\" ] && npx @claude-flow/cli@latest hooks session-restore --session-id \"$SESSION_ID\" 2>/dev/null || true", - "timeout": 10000, + "command": ".claude/hooks/hook-bridge.sh session-restore", + "timeout": 15000, "continueOnError": true } ] @@ -105,7 +105,7 @@ "hooks": [ { "type": "command", - "command": "echo '{\"ok\": true}'", + "command": ".claude/hooks/hook-bridge.sh stop-check", "timeout": 1000 } ] @@ -116,8 +116,8 @@ "hooks": [ { "type": "command", - "command": "[ -n \"$NOTIFICATION_MESSAGE\" ] && npx @claude-flow/cli@latest memory store --namespace notifications --key \"notify-$(date +%s)\" --value \"$NOTIFICATION_MESSAGE\" 2>/dev/null || true", - "timeout": 3000, + "command": ".claude/hooks/hook-bridge.sh notify", + "timeout": 5000, "continueOnError": true } ] @@ -234,4 +234,4 @@ "threatModel": true } } -} \ No newline at end of file +} diff --git a/v3/@claude-flow/cli/src/init/executor.ts b/v3/@claude-flow/cli/src/init/executor.ts index 5cde164f74..33d2c3ffdb 100644 --- a/v3/@claude-flow/cli/src/init/executor.ts +++ b/v3/@claude-flow/cli/src/init/executor.ts @@ -129,6 +129,7 @@ const DIRECTORIES = { '.claude/commands', '.claude/agents', '.claude/helpers', + '.claude/hooks', ], runtime: [ '.claude-flow', @@ -757,6 +758,57 @@ function findSourceHelpersDir(sourceBaseDir?: string): string | null { return null; } +/** + * Write hook-bridge.sh to .claude/hooks/ + * + * This script is required for Claude Code hooks to function correctly. + * Claude Code passes hook context as JSON via stdin (not as env vars), + * so a bridge script is needed to parse the JSON and forward fields + * as proper CLI arguments. + */ +async function writeHookBridge( + targetDir: string, + options: InitOptions, + result: InitResult +): Promise { + const hooksDir = path.join(targetDir, '.claude', 'hooks'); + + if (!fs.existsSync(hooksDir)) { + fs.mkdirSync(hooksDir, { recursive: true }); + } + + const bridgePath = path.join(hooksDir, 'hook-bridge.sh'); + + if (fs.existsSync(bridgePath) && !options.force) { + result.skipped.push('.claude/hooks/hook-bridge.sh'); + return; + } + + // Try to copy from package source first + const sourceHooksDir = findSourceDir('hooks', options.sourceBaseDir); + if (sourceHooksDir) { + const sourceBridge = path.join(sourceHooksDir, 'hook-bridge.sh'); + if (fs.existsSync(sourceBridge)) { + fs.copyFileSync(sourceBridge, bridgePath); + fs.chmodSync(bridgePath, '755'); + result.created.files.push('.claude/hooks/hook-bridge.sh'); + return; + } + } + + // Fallback: check package's own .claude/hooks directory + const packageRoot = path.resolve(__dirname, '..', '..', '..'); + const packageBridge = path.join(packageRoot, '.claude', 'hooks', 'hook-bridge.sh'); + if (fs.existsSync(packageBridge)) { + fs.copyFileSync(packageBridge, bridgePath); + fs.chmodSync(bridgePath, '755'); + result.created.files.push('.claude/hooks/hook-bridge.sh'); + return; + } + + result.errors.push('Could not find hook-bridge.sh source. Hooks may not function correctly.'); +} + /** * Write helper scripts */ @@ -767,6 +819,11 @@ async function writeHelpers( ): Promise { const helpersDir = path.join(targetDir, '.claude', 'helpers'); + // Write hook-bridge.sh (required for Claude Code hooks to work) + // Claude Code passes hook context as JSON via stdin, not as env vars. + // hook-bridge.sh reads the JSON and forwards fields as CLI arguments. + await writeHookBridge(targetDir, options, result); + // Find source helpers directory (works for npm package and local dev) const sourceHelpersDir = findSourceHelpersDir(options.sourceBaseDir); diff --git a/v3/@claude-flow/cli/src/init/settings-generator.ts b/v3/@claude-flow/cli/src/init/settings-generator.ts index c355d9254f..50110ba311 100644 --- a/v3/@claude-flow/cli/src/init/settings-generator.ts +++ b/v3/@claude-flow/cli/src/init/settings-generator.ts @@ -143,11 +143,17 @@ function generateStatusLineConfig(options: InitOptions): object { /** * Generate hooks configuration + * + * IMPORTANT: Claude Code passes hook context as JSON via stdin, NOT as shell + * environment variables. The hook-bridge.sh script reads stdin JSON with jq + * and forwards the extracted fields as proper CLI arguments. + * + * See .claude/hooks/hook-bridge.sh for the implementation. */ function generateHooksConfig(config: HooksConfig): object { const hooks: Record = {}; - // PreToolUse hooks - cross-platform via npx with defensive guards + // PreToolUse hooks - use hook-bridge.sh to parse stdin JSON if (config.preToolUse) { hooks.PreToolUse = [ // File edit hooks with intelligence routing @@ -156,7 +162,7 @@ function generateHooksConfig(config: HooksConfig): object { hooks: [ { type: 'command', - command: '[ -n "$TOOL_INPUT_file_path" ] && npx @claude-flow/cli@latest hooks pre-edit --file "$TOOL_INPUT_file_path" 2>/dev/null || true', + command: '.claude/hooks/hook-bridge.sh pre-edit', timeout: config.timeout, continueOnError: true, }, @@ -168,19 +174,19 @@ function generateHooksConfig(config: HooksConfig): object { hooks: [ { type: 'command', - command: '[ -n "$TOOL_INPUT_command" ] && npx @claude-flow/cli@latest hooks pre-command --command "$TOOL_INPUT_command" 2>/dev/null || true', + command: '.claude/hooks/hook-bridge.sh pre-command', timeout: config.timeout, continueOnError: true, }, ], }, - // Task/Agent hooks - require task-id for tracking + // Task/Agent hooks { matcher: '^Task$', hooks: [ { type: 'command', - command: '[ -n "$TOOL_INPUT_prompt" ] && npx @claude-flow/cli@latest hooks pre-task --task-id "task-$(date +%s)" --description "$TOOL_INPUT_prompt" 2>/dev/null || true', + command: '.claude/hooks/hook-bridge.sh pre-task', timeout: config.timeout, continueOnError: true, }, @@ -189,7 +195,7 @@ function generateHooksConfig(config: HooksConfig): object { ]; } - // PostToolUse hooks - cross-platform via npx with defensive guards + // PostToolUse hooks - use hook-bridge.sh to parse stdin JSON if (config.postToolUse) { hooks.PostToolUse = [ // File edit hooks with neural pattern training @@ -198,7 +204,7 @@ function generateHooksConfig(config: HooksConfig): object { hooks: [ { type: 'command', - command: '[ -n "$TOOL_INPUT_file_path" ] && npx @claude-flow/cli@latest hooks post-edit --file "$TOOL_INPUT_file_path" --success "${TOOL_SUCCESS:-true}" 2>/dev/null || true', + command: '.claude/hooks/hook-bridge.sh post-edit', timeout: config.timeout, continueOnError: true, }, @@ -210,19 +216,19 @@ function generateHooksConfig(config: HooksConfig): object { hooks: [ { type: 'command', - command: '[ -n "$TOOL_INPUT_command" ] && npx @claude-flow/cli@latest hooks post-command --command "$TOOL_INPUT_command" --success "${TOOL_SUCCESS:-true}" 2>/dev/null || true', + command: '.claude/hooks/hook-bridge.sh post-command', timeout: config.timeout, continueOnError: true, }, ], }, - // Task completion hooks - use task-id + // Task completion hooks { matcher: '^Task$', hooks: [ { type: 'command', - command: '[ -n "$TOOL_RESULT_agent_id" ] && npx @claude-flow/cli@latest hooks post-task --task-id "$TOOL_RESULT_agent_id" --success "${TOOL_SUCCESS:-true}" 2>/dev/null || true', + command: '.claude/hooks/hook-bridge.sh post-task', timeout: config.timeout, continueOnError: true, }, @@ -238,7 +244,7 @@ function generateHooksConfig(config: HooksConfig): object { hooks: [ { type: 'command', - command: '[ -n "$PROMPT" ] && npx @claude-flow/cli@latest hooks route --task "$PROMPT" || true', + command: '.claude/hooks/hook-bridge.sh route', timeout: config.timeout, continueOnError: true, }, @@ -254,14 +260,14 @@ function generateHooksConfig(config: HooksConfig): object { hooks: [ { type: 'command', - command: 'npx @claude-flow/cli@latest daemon start --quiet 2>/dev/null || true', - timeout: 5000, + command: '.claude/hooks/hook-bridge.sh daemon-start', + timeout: 10000, continueOnError: true, }, { type: 'command', - command: '[ -n "$SESSION_ID" ] && npx @claude-flow/cli@latest hooks session-restore --session-id "$SESSION_ID" 2>/dev/null || true', - timeout: 10000, + command: '.claude/hooks/hook-bridge.sh session-restore', + timeout: 15000, continueOnError: true, }, ], @@ -270,14 +276,13 @@ function generateHooksConfig(config: HooksConfig): object { } // Stop hooks for task evaluation - always return ok by default - // The hook outputs JSON that Claude Code validates if (config.stop) { hooks.Stop = [ { hooks: [ { type: 'command', - command: 'echo \'{"ok": true}\'', + command: '.claude/hooks/hook-bridge.sh stop-check', timeout: 1000, }, ], @@ -292,8 +297,8 @@ function generateHooksConfig(config: HooksConfig): object { hooks: [ { type: 'command', - command: '[ -n "$NOTIFICATION_MESSAGE" ] && npx @claude-flow/cli@latest memory store --namespace notifications --key "notify-$(date +%s)" --value "$NOTIFICATION_MESSAGE" 2>/dev/null || true', - timeout: 3000, + command: '.claude/hooks/hook-bridge.sh notify', + timeout: 5000, continueOnError: true, }, ],