import { spawn } from 'node:child_process'; import { mkdirSync, readFileSync, writeFileSync, existsSync, appendFileSync } from 'node:fs'; import { homedir, tmpdir, platform } from 'node:os'; import { join } from 'node:path'; const IS_WIN = platform() === 'win32'; type EnvLike = Record; interface ClaudeSettings { env?: Record; } function normalizeEnvValue(key: string, value: unknown): string | undefined { if (value == null) return undefined; if (typeof value === 'string') { // Filter out empty strings - they cause issues if (value.trim() === '') return undefined; return value; } if (typeof value === 'number' || typeof value === 'boolean') { return String(value); } // ANTHROPIC_CUSTOM_HEADERS can be an object in settings.json — serialize it. // Other object values are skipped to prevent "Invalid header name" errors. if (typeof value === 'object') { if (key === 'ANTHROPIC_CUSTOM_HEADERS') { try { return JSON.stringify(value); } catch { return undefined; } } return undefined; } return undefined; } function readSingleSettingsFile(filePath: string): EnvLike { try { const raw = readFileSync(filePath, 'utf-8'); const parsed = JSON.parse(raw) as ClaudeSettings; if (!parsed.env || typeof parsed.env !== 'object') return {}; const env: EnvLike = {}; for (const [key, value] of Object.entries(parsed.env)) { const normalized = normalizeEnvValue(key, value); if (normalized !== undefined) { env[key] = normalized; } } return env; } catch { return {}; } } /** * Read env from ~/.claude/settings.json and ~/.claude/settings.local.json. * Local settings take priority (same as Claude Code's own precedence). */ function readClaudeSettingsEnv(): EnvLike { const claudeDir = join(homedir(), '.claude'); const base = readSingleSettingsFile(join(claudeDir, 'settings.json')); const local = readSingleSettingsFile(join(claudeDir, 'settings.local.json')); return { ...base, ...local }; } /** * Validate if a string is valid JSON (for ANTHROPIC_CUSTOM_HEADERS). */ function isValidJson(str: string): boolean { try { JSON.parse(str); return true; } catch { return false; } } /** * On Windows, Claude Code SDK may fail with EPERM when writing to ~/.claude.json * or ~/.claude/ config files. Ensure the directory and config file exist and are writable. */ function ensureClaudeConfigWritable(): void { if (!IS_WIN) return; try { const claudeDir = join(homedir(), '.claude'); mkdirSync(claudeDir, { recursive: true }); // Ensure .claude.json exists — Claude SDK crashes if it can't write/lock it const configFile = join(homedir(), '.claude.json'); if (!existsSync(configFile)) { writeFileSync(configFile, '{}', 'utf-8'); } // Ensure credentials.json exists — SDK may crash trying to read/write it const credFile = join(claudeDir, 'credentials.json'); if (!existsSync(credFile)) { writeFileSync(credFile, '{}', 'utf-8'); } // Ensure statsig/ cache dir exists — SDK crashes writing feature gate cache const statsigDir = join(claudeDir, 'statsig'); mkdirSync(statsigDir, { recursive: true }); } catch { // Best effort — if we can't fix it, the SDK error hint will guide the user } } /** * Build env passed to Claude Agent SDK. * Priority: current process env > ~/.claude/settings.json env. */ export function buildClaudeAgentEnv(): EnvLike { // On Windows, pre-create config files to avoid EPERM errors ensureClaudeConfigWritable(); const fromSettings = readClaudeSettingsEnv(); const fromProcess = process.env as EnvLike; const merged: EnvLike = { ...fromSettings, ...fromProcess, }; // Validate ANTHROPIC_CUSTOM_HEADERS if it exists - must be valid JSON // If invalid, delete it to prevent "Invalid header name" errors if (merged.ANTHROPIC_CUSTOM_HEADERS) { if (!isValidJson(merged.ANTHROPIC_CUSTOM_HEADERS)) { delete merged.ANTHROPIC_CUSTOM_HEADERS; } } // Compatibility: use ANTHROPIC_AUTH_TOKEN as ANTHROPIC_API_KEY if no API key is set const authToken = merged.ANTHROPIC_AUTH_TOKEN; if (authToken && !merged.ANTHROPIC_API_KEY) { merged.ANTHROPIC_API_KEY = authToken; } // Running inside Claude terminal can break nested Claude invocations. delete merged.CLAUDECODE; // Remove Electron-specific env vars that may confuse spawned CLI processes delete merged.ELECTRON_RUN_AS_NODE; delete merged.ELECTRON_RESOURCES_PATH; delete merged.CHROME_CRASHPAD_PIPE_NAME; // Enable Agent SDK debug stderr so we can capture CLI crash diagnostics. // Without this, the SDK sets stderr to "ignore" and crash output is lost. if (!merged.DEBUG_CLAUDE_AGENT_SDK) { merged.DEBUG_CLAUDE_AGENT_SDK = '1'; } if (IS_WIN) { // Redirect Claude debug output to temp to avoid write permission issues if (!merged.CLAUDE_DEBUG_FILE) { const debugPath = getClaudeAgentDebugFilePath(); if (debugPath) merged.CLAUDE_DEBUG_FILE = debugPath; } // Set CLAUDE_CONFIG_DIR to a writable temp location as fallback // if the default ~/.claude directory is not writable (common in Windows Electron) if (!merged.CLAUDE_CONFIG_DIR) { try { const fallbackDir = join(tmpdir(), 'openpencil-claude-config'); mkdirSync(fallbackDir, { recursive: true }); // Only use fallback if we can't write to the default location const defaultDir = join(homedir(), '.claude'); const testFile = join(defaultDir, '.write-test'); try { writeFileSync(testFile, '', 'utf-8'); const { unlinkSync } = require('node:fs'); unlinkSync(testFile); } catch { // Default dir is not writable — use fallback merged.CLAUDE_CONFIG_DIR = fallbackDir; } } catch { /* ignore */ } } } return merged; } /** * Force Claude CLI debug output into a writable temp location. * This avoids crashes in restricted environments where ~/.claude/debug is not writable. */ export function getClaudeAgentDebugFilePath(): string | undefined { try { const dir = join(tmpdir(), 'openpencil-claude-debug'); mkdirSync(dir, { recursive: true }); return join(dir, 'claude-agent.log'); } catch { return undefined; } } /** * Custom spawnClaudeCodeProcess for Windows. * On Windows, npm-installed CLIs are .cmd/.ps1 scripts that can't be spawned * directly without a shell. * * - `.cmd` files: use `cmd.exe /c` (PowerShell can't run .cmd directly) * - `.ps1` files: use `powershell.exe` * - `.exe` files: spawned directly without shell * - Others: use `cmd.exe /c` as safe default * * Also captures stderr to the debug file — when Claude Code crashes early, * the debug file may be empty but stderr often contains the root cause. */ export function buildSpawnClaudeCodeProcess() { if (process.platform !== 'win32') return undefined; return (options: { command: string; args: string[]; cwd?: string; env: Record; signal: AbortSignal; }) => { const cmd = options.command; const isPowerShell = cmd.endsWith('.ps1'); let child; if (isPowerShell) { // For .ps1 scripts, invoke via PowerShell const psArgs = ['-ExecutionPolicy', 'Bypass', '-File', cmd, ...options.args]; child = spawn('powershell.exe', psArgs, { cwd: options.cwd, env: options.env as NodeJS.ProcessEnv, signal: options.signal, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, }); } else if (cmd.endsWith('.exe')) { // .exe files can be spawned directly without shell child = spawn(cmd, options.args, { cwd: options.cwd, env: options.env as NodeJS.ProcessEnv, signal: options.signal, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, }); } else { // For .cmd or extensionless binaries, use shell. // When shell: true on Windows, empty string args get swallowed. // Filter out --setting-sources with empty value to prevent the next // flag (e.g. --permission-mode) from being consumed as its value. const safeArgs: string[] = []; for (let i = 0; i < options.args.length; i++) { const arg = options.args[i]; // Skip --setting-sources followed by an empty string if ( arg === '--setting-sources' && i + 1 < options.args.length && options.args[i + 1] === '' ) { i++; // skip the empty value too continue; } safeArgs.push(arg); } child = spawn(cmd, safeArgs, { cwd: options.cwd, env: options.env as NodeJS.ProcessEnv, signal: options.signal, stdio: ['pipe', 'pipe', 'pipe'], shell: true, windowsHide: true, }); } // Capture stderr to debug file — helps diagnose crashes where the process // exits before writing anything to the debug log const stderrChunks: Buffer[] = []; child.stderr?.on('data', (chunk: Buffer) => { stderrChunks.push(chunk); }); child.on('exit', (code) => { if (code !== 0 && stderrChunks.length > 0) { const stderr = Buffer.concat(stderrChunks).toString('utf-8').trim(); if (stderr) { const debugPath = getClaudeAgentDebugFilePath(); if (debugPath) { try { appendFileSync(debugPath, `\n[stderr exit=${code}] ${stderr}\n`); } catch { /* best effort */ } } } } }); return child; }; }