From 4843a06b3a20773c388ad4839a6d4dfcb0bd42b0 Mon Sep 17 00:00:00 2001 From: dungan Date: Fri, 13 Feb 2026 18:04:27 +0900 Subject: [PATCH] fix: Windows compatibility for hook scripts (execFileSync + tmux) (#215) * fix: Windows compatibility for hook scripts - post-edit-format.js: add `shell: process.platform === 'win32'` to execFileSync options so npx.cmd is resolved via cmd.exe on Windows - post-edit-typecheck.js: same fix for tsc invocation via npx - hooks.json: skip tmux-dependent hooks on Windows where tmux is unavailable (dev-server blocker and long-running command reminder) On Windows, execFileSync('npx', ...) without shell:true fails with ENOENT because Node.js cannot directly execute .cmd files. These hooks silently fail on all Windows installations. The tmux hooks unconditionally block dev server commands (exit 2) or warn about tmux on Windows where tmux is not available. Co-Authored-By: Claude Opus 4.6 * fix: parse Claude Code JSONL transcript format correctly The session-end hook expected user messages at entry.content, but Claude Code's actual JSONL format nests them at entry.message.content. This caused all session files to be blank templates (0 user messages despite 136+ actual entries). - Check entry.message?.content in addition to entry.content - Extract tool_use blocks from assistant message.content arrays Verified with Claude Code v2.1.41 JSONL transcripts. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: ddungan Co-authored-by: Claude Opus 4.6 --- hooks/hooks.json | 4 +-- scripts/hooks/post-edit-format.js | 3 +- scripts/hooks/post-edit-typecheck.js | 48 +++++++++++++++++----------- scripts/hooks/session-end.js | 29 +++++++++++++---- 4 files changed, 56 insertions(+), 28 deletions(-) diff --git a/hooks/hooks.json b/hooks/hooks.json index bbd7329f..bfaf2dcd 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -7,7 +7,7 @@ "hooks": [ { "type": "command", - "command": "node -e \"let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{const i=JSON.parse(d);const cmd=i.tool_input?.command||'';if(/(npm run dev\\b|pnpm( run)? dev\\b|yarn dev\\b|bun run dev\\b)/.test(cmd)){console.error('[Hook] BLOCKED: Dev server must run in tmux for log access');console.error('[Hook] Use: tmux new-session -d -s dev \\\"npm run dev\\\"');console.error('[Hook] Then: tmux attach -t dev');process.exit(2)}}catch{}console.log(d)})\"" + "command": "node -e \"let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{const i=JSON.parse(d);const cmd=i.tool_input?.command||'';if(process.platform!=='win32'&&/(npm run dev\\b|pnpm( run)? dev\\b|yarn dev\\b|bun run dev\\b)/.test(cmd)){console.error('[Hook] BLOCKED: Dev server must run in tmux for log access');console.error('[Hook] Use: tmux new-session -d -s dev \\\"npm run dev\\\"');console.error('[Hook] Then: tmux attach -t dev');process.exit(2)}}catch{}console.log(d)})\"" } ], "description": "Block dev servers outside tmux - ensures you can access logs" @@ -17,7 +17,7 @@ "hooks": [ { "type": "command", - "command": "node -e \"let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{const i=JSON.parse(d);const cmd=i.tool_input?.command||'';if(!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')}}catch{}console.log(d)})\"" + "command": "node -e \"let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{const i=JSON.parse(d);const cmd=i.tool_input?.command||'';if(process.platform!=='win32'&&!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')}}catch{}console.log(d)})\"" } ], "description": "Reminder to use tmux for long-running commands" diff --git a/scripts/hooks/post-edit-format.js b/scripts/hooks/post-edit-format.js index 14f882f9..c57ef948 100644 --- a/scripts/hooks/post-edit-format.js +++ b/scripts/hooks/post-edit-format.js @@ -29,7 +29,8 @@ process.stdin.on('end', () => { try { execFileSync('npx', ['prettier', '--write', filePath], { stdio: ['pipe', 'pipe', 'pipe'], - timeout: 15000 + timeout: 15000, + shell: process.platform === 'win32' }); } catch { // Prettier not installed, file missing, or failed — non-blocking diff --git a/scripts/hooks/post-edit-typecheck.js b/scripts/hooks/post-edit-typecheck.js index 94e969d1..5116dd99 100644 --- a/scripts/hooks/post-edit-typecheck.js +++ b/scripts/hooks/post-edit-typecheck.js @@ -9,60 +9,70 @@ * and reports only errors related to the edited file. */ -const { execFileSync } = require('child_process'); -const fs = require('fs'); -const path = require('path'); +const { execFileSync } = require("child_process"); +const fs = require("fs"); +const path = require("path"); const MAX_STDIN = 1024 * 1024; // 1MB limit -let data = ''; -process.stdin.setEncoding('utf8'); +let data = ""; +process.stdin.setEncoding("utf8"); -process.stdin.on('data', chunk => { +process.stdin.on("data", (chunk) => { if (data.length < MAX_STDIN) { data += chunk; } }); -process.stdin.on('end', () => { +process.stdin.on("end", () => { try { const input = JSON.parse(data); const filePath = input.tool_input?.file_path; if (filePath && /\.(ts|tsx)$/.test(filePath)) { const resolvedPath = path.resolve(filePath); - if (!fs.existsSync(resolvedPath)) { console.log(data); return; } + if (!fs.existsSync(resolvedPath)) { + console.log(data); + return; + } // Find nearest tsconfig.json by walking up (max 20 levels to prevent infinite loop) let dir = path.dirname(resolvedPath); const root = path.parse(dir).root; let depth = 0; while (dir !== root && depth < 20) { - if (fs.existsSync(path.join(dir, 'tsconfig.json'))) { + if (fs.existsSync(path.join(dir, "tsconfig.json"))) { break; } dir = path.dirname(dir); depth++; } - if (fs.existsSync(path.join(dir, 'tsconfig.json'))) { + if (fs.existsSync(path.join(dir, "tsconfig.json"))) { try { - execFileSync('npx', ['tsc', '--noEmit', '--pretty', 'false'], { + execFileSync("npx", ["tsc", "--noEmit", "--pretty", "false"], { cwd: dir, - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'pipe'], - timeout: 30000 + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + timeout: 30000, + shell: process.platform === "win32", }); } catch (err) { // tsc exits non-zero when there are errors — filter to edited file - const output = (err.stdout || '') + (err.stderr || ''); + const output = (err.stdout || "") + (err.stderr || ""); const relevantLines = output - .split('\n') - .filter(line => line.includes(filePath) || line.includes(path.basename(filePath))) + .split("\n") + .filter( + (line) => + line.includes(filePath) || + line.includes(path.basename(filePath)), + ) .slice(0, 10); if (relevantLines.length > 0) { - console.error('[Hook] TypeScript errors in ' + path.basename(filePath) + ':'); - relevantLines.forEach(line => console.error(line)); + console.error( + "[Hook] TypeScript errors in " + path.basename(filePath) + ":", + ); + relevantLines.forEach((line) => console.error(line)); } } } diff --git a/scripts/hooks/session-end.js b/scripts/hooks/session-end.js index e4afe24b..d6ce61ac 100644 --- a/scripts/hooks/session-end.js +++ b/scripts/hooks/session-end.js @@ -45,18 +45,20 @@ function extractSessionSummary(transcriptPath) { const entry = JSON.parse(line); // Collect user messages (first 200 chars each) - if (entry.type === 'user' || entry.role === 'user') { - const text = typeof entry.content === 'string' - ? entry.content - : Array.isArray(entry.content) - ? entry.content.map(c => (c && c.text) || '').join(' ') + if (entry.type === 'user' || entry.role === 'user' || entry.message?.role === 'user') { + // Support both direct content and nested message.content (Claude Code JSONL format) + const rawContent = entry.message?.content ?? entry.content; + const text = typeof rawContent === 'string' + ? rawContent + : Array.isArray(rawContent) + ? rawContent.map(c => (c && c.text) || '').join(' ') : ''; if (text.trim()) { userMessages.push(text.trim().slice(0, 200)); } } - // Collect tool names and modified files + // Collect tool names and modified files (direct tool_use entries) if (entry.type === 'tool_use' || entry.tool_name) { const toolName = entry.tool_name || entry.name || ''; if (toolName) toolsUsed.add(toolName); @@ -66,6 +68,21 @@ function extractSessionSummary(transcriptPath) { filesModified.add(filePath); } } + + // Extract tool uses from assistant message content blocks (Claude Code JSONL format) + if (entry.type === 'assistant' && Array.isArray(entry.message?.content)) { + for (const block of entry.message.content) { + if (block.type === 'tool_use') { + const toolName = block.name || ''; + if (toolName) toolsUsed.add(toolName); + + const filePath = block.input?.file_path || ''; + if (filePath && (toolName === 'Edit' || toolName === 'Write')) { + filesModified.add(filePath); + } + } + } + } } catch { parseErrors++; }