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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: ddungan <sckim@mococo.co.kr>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dungan
2026-02-13 18:04:27 +09:00
committed by GitHub
parent 1823b441a9
commit 4843a06b3a
4 changed files with 56 additions and 28 deletions

View File

@@ -7,7 +7,7 @@
"hooks": [ "hooks": [
{ {
"type": "command", "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" "description": "Block dev servers outside tmux - ensures you can access logs"
@@ -17,7 +17,7 @@
"hooks": [ "hooks": [
{ {
"type": "command", "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" "description": "Reminder to use tmux for long-running commands"

View File

@@ -29,7 +29,8 @@ process.stdin.on('end', () => {
try { try {
execFileSync('npx', ['prettier', '--write', filePath], { execFileSync('npx', ['prettier', '--write', filePath], {
stdio: ['pipe', 'pipe', 'pipe'], stdio: ['pipe', 'pipe', 'pipe'],
timeout: 15000 timeout: 15000,
shell: process.platform === 'win32'
}); });
} catch { } catch {
// Prettier not installed, file missing, or failed — non-blocking // Prettier not installed, file missing, or failed — non-blocking

View File

@@ -9,60 +9,70 @@
* and reports only errors related to the edited file. * and reports only errors related to the edited file.
*/ */
const { execFileSync } = require('child_process'); const { execFileSync } = require("child_process");
const fs = require('fs'); const fs = require("fs");
const path = require('path'); const path = require("path");
const MAX_STDIN = 1024 * 1024; // 1MB limit const MAX_STDIN = 1024 * 1024; // 1MB limit
let data = ''; let data = "";
process.stdin.setEncoding('utf8'); process.stdin.setEncoding("utf8");
process.stdin.on('data', chunk => { process.stdin.on("data", (chunk) => {
if (data.length < MAX_STDIN) { if (data.length < MAX_STDIN) {
data += chunk; data += chunk;
} }
}); });
process.stdin.on('end', () => { process.stdin.on("end", () => {
try { try {
const input = JSON.parse(data); const input = JSON.parse(data);
const filePath = input.tool_input?.file_path; const filePath = input.tool_input?.file_path;
if (filePath && /\.(ts|tsx)$/.test(filePath)) { if (filePath && /\.(ts|tsx)$/.test(filePath)) {
const resolvedPath = path.resolve(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) // Find nearest tsconfig.json by walking up (max 20 levels to prevent infinite loop)
let dir = path.dirname(resolvedPath); let dir = path.dirname(resolvedPath);
const root = path.parse(dir).root; const root = path.parse(dir).root;
let depth = 0; let depth = 0;
while (dir !== root && depth < 20) { while (dir !== root && depth < 20) {
if (fs.existsSync(path.join(dir, 'tsconfig.json'))) { if (fs.existsSync(path.join(dir, "tsconfig.json"))) {
break; break;
} }
dir = path.dirname(dir); dir = path.dirname(dir);
depth++; depth++;
} }
if (fs.existsSync(path.join(dir, 'tsconfig.json'))) { if (fs.existsSync(path.join(dir, "tsconfig.json"))) {
try { try {
execFileSync('npx', ['tsc', '--noEmit', '--pretty', 'false'], { execFileSync("npx", ["tsc", "--noEmit", "--pretty", "false"], {
cwd: dir, cwd: dir,
encoding: 'utf8', encoding: "utf8",
stdio: ['pipe', 'pipe', 'pipe'], stdio: ["pipe", "pipe", "pipe"],
timeout: 30000 timeout: 30000,
shell: process.platform === "win32",
}); });
} catch (err) { } catch (err) {
// tsc exits non-zero when there are errors — filter to edited file // 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 const relevantLines = output
.split('\n') .split("\n")
.filter(line => line.includes(filePath) || line.includes(path.basename(filePath))) .filter(
(line) =>
line.includes(filePath) ||
line.includes(path.basename(filePath)),
)
.slice(0, 10); .slice(0, 10);
if (relevantLines.length > 0) { if (relevantLines.length > 0) {
console.error('[Hook] TypeScript errors in ' + path.basename(filePath) + ':'); console.error(
relevantLines.forEach(line => console.error(line)); "[Hook] TypeScript errors in " + path.basename(filePath) + ":",
);
relevantLines.forEach((line) => console.error(line));
} }
} }
} }

View File

@@ -45,18 +45,20 @@ function extractSessionSummary(transcriptPath) {
const entry = JSON.parse(line); const entry = JSON.parse(line);
// Collect user messages (first 200 chars each) // Collect user messages (first 200 chars each)
if (entry.type === 'user' || entry.role === 'user') { if (entry.type === 'user' || entry.role === 'user' || entry.message?.role === 'user') {
const text = typeof entry.content === 'string' // Support both direct content and nested message.content (Claude Code JSONL format)
? entry.content const rawContent = entry.message?.content ?? entry.content;
: Array.isArray(entry.content) const text = typeof rawContent === 'string'
? entry.content.map(c => (c && c.text) || '').join(' ') ? rawContent
: Array.isArray(rawContent)
? rawContent.map(c => (c && c.text) || '').join(' ')
: ''; : '';
if (text.trim()) { if (text.trim()) {
userMessages.push(text.trim().slice(0, 200)); 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) { if (entry.type === 'tool_use' || entry.tool_name) {
const toolName = entry.tool_name || entry.name || ''; const toolName = entry.tool_name || entry.name || '';
if (toolName) toolsUsed.add(toolName); if (toolName) toolsUsed.add(toolName);
@@ -66,6 +68,21 @@ function extractSessionSummary(transcriptPath) {
filesModified.add(filePath); 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 { } catch {
parseErrors++; parseErrors++;
} }