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

@@ -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

View File

@@ -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));
}
}
}

View File

@@ -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++;
}