#!/usr/bin/env node // copilot-hook-runner.mjs — Protocol translator between Copilot CLI and Claude Code hooks. // // Copilot CLI sends: { toolName, toolArgs, toolResult, message, sessionId } // Claude Code hooks expect: { tool_name, tool_input, tool_output, message, session_id } // // Claude Code hooks output: { decision: 'block'|'allow', reason, systemMessage } // Copilot CLI expects: { permissionDecision: 'deny'|'allow', reason, message } // // Usage: node copilot-hook-runner.mjs // The wrapper reads stdin, normalizes field names, spawns the original hook // with normalized stdin, captures output, translates back to Copilot format, // and preserves the exit code. import { readFileSync } from 'node:fs'; import { spawn } from 'node:child_process'; import { resolve, dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const pluginRoot = resolve(__dirname, '..', '..'); // Resolve hook path const hookArg = process.argv[2]; if (!hookArg) { process.stderr.write('copilot-hook-runner: missing hook argument\n'); process.exit(0); // fail-open } const hookPath = resolve(__dirname, hookArg); // --- Step 1: Read and normalize stdin (Copilot → Claude Code) --- let normalizedStdin; try { const raw = readFileSync(0, 'utf-8'); const obj = JSON.parse(raw); // Normalize Copilot camelCase → Claude Code snake_case if (obj.toolName !== undefined && obj.tool_name === undefined) obj.tool_name = obj.toolName; if (obj.toolArgs !== undefined && obj.tool_input === undefined) obj.tool_input = obj.toolArgs; if (obj.toolResult !== undefined && obj.tool_output === undefined) obj.tool_output = obj.toolResult; if (obj.sessionId !== undefined && obj.session_id === undefined) obj.session_id = obj.sessionId; // For userPromptSubmitted: normalize prompt field variations. // Claude Code hook expects: { message: { role, content }, prompt (fallback) } // Copilot may send: { message: "text" } or { prompt: "text" } if (typeof obj.message === 'string' && obj.tool_name === undefined) { const text = obj.message; obj.message = { role: 'user', content: text }; if (obj.prompt === undefined) obj.prompt = text; } // Also normalize nested tool_input field names for Copilot // Copilot uses: { toolArgs: { command, filePath, content } } // Claude Code uses: { tool_input: { command, file_path, content, new_string } } const ti = obj.tool_input; if (ti && typeof ti === 'object') { if (ti.filePath !== undefined && ti.file_path === undefined) ti.file_path = ti.filePath; if (ti.newString !== undefined && ti.new_string === undefined) ti.new_string = ti.newString; } normalizedStdin = JSON.stringify(obj); } catch { normalizedStdin = '{}'; // fail-open: hook will get empty input and exit 0 } // --- Step 2: Spawn the original hook --- const env = { ...process.env, CLAUDE_PLUGIN_ROOT: pluginRoot, CLAUDE_WORKING_DIR: process.env.COPILOT_WORKING_DIR || process.cwd(), }; const child = spawn(process.execPath, [hookPath], { stdio: ['pipe', 'pipe', 'pipe'], env, // Windows: no shell needed, node handles it }); // Feed normalized stdin child.stdin.write(normalizedStdin); child.stdin.end(); // --- Step 3: Capture output --- let stdoutData = ''; let stderrData = ''; child.stdout.on('data', (chunk) => { stdoutData += chunk; }); child.stderr.on('data', (chunk) => { stderrData += chunk; process.stderr.write(chunk); // pass stderr through immediately }); child.on('error', () => { process.exit(0); // fail-open on spawn error }); child.on('exit', (code) => { // --- Step 4: Translate output (Claude Code → Copilot) --- const exitCode = code ?? 0; if (stdoutData.trim()) { try { const out = JSON.parse(stdoutData); // Translate decision → permissionDecision if (out.decision === 'block') { out.permissionDecision = 'deny'; delete out.decision; } else if (out.decision === 'allow') { out.permissionDecision = 'allow'; delete out.decision; } // Translate systemMessage → message if (out.systemMessage !== undefined) { out.message = out.message || out.systemMessage; delete out.systemMessage; } process.stdout.write(JSON.stringify(out)); } catch { // Not JSON — pass through as-is process.stdout.write(stdoutData); } } else if (exitCode === 2 && stderrData.trim()) { // Hook wrote to stderr only (e.g. pre-edit-secrets, pre-write-pathguard). // Generate Copilot-format JSON from stderr message + exit code. const msg = stderrData.trim().split('\n')[0]; // first line is the summary process.stdout.write(JSON.stringify({ permissionDecision: 'deny', message: stderrData.trim(), reason: msg, })); } process.exit(exitCode); });