From cbc1405f100060ef9904f020a4c61c97931ab521 Mon Sep 17 00:00:00 2001 From: zerojarvis Date: Mon, 15 Jun 2026 15:29:03 -0700 Subject: [PATCH] fix: spawn via cmd /c on Windows to resolve npm .cmd shim Node child_process.spawn(claude) fails with ENOENT on Windows because npm installs claude as a .cmd batch file, which cannot be executed directly without shell:true. Routing through cmd /c claude passes all arguments as discrete array elements, avoiding the quoting and newline pitfalls of shell:true. Adds a test asserting the Windows code path and updates the platform-agnostic spawn-argument tests to match either command name. --- src/__tests__/providers/cliProvider.test.ts | 45 ++++++++++++++++----- src/providers/cliProvider.ts | 19 ++++++--- 2 files changed, 49 insertions(+), 15 deletions(-) diff --git a/src/__tests__/providers/cliProvider.test.ts b/src/__tests__/providers/cliProvider.test.ts index 7e69b2b..6bd58a5 100644 --- a/src/__tests__/providers/cliProvider.test.ts +++ b/src/__tests__/providers/cliProvider.test.ts @@ -68,7 +68,9 @@ describe('CliProvider', () => { }); describe('arguments', () => { - it('passes correct args to spawn', async () => { + it('passes correct args to spawn (non-Windows)', async () => { + Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }); + const provider = new CliProvider('/my/cwd'); const token = createMockCancellationToken(); @@ -88,6 +90,35 @@ describe('CliProvider', () => { stdio: ['pipe', 'pipe', 'pipe'], } ); + + Object.defineProperty(process, 'platform', { value: process.platform, configurable: true }); + }); + + it('routes through cmd /c on Windows so the npm .cmd shim is resolved', async () => { + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); + + const provider = new CliProvider('/my/cwd'); + const token = createMockCancellationToken(); + + const promise = provider.generateMessage('my instruction', 'ctx', token); + mockProcess.emitClose(0); + await promise; + + expect(mockSpawn).toHaveBeenCalledWith( + 'cmd', + [ + '/c', 'claude', + '-p', 'my instruction', + '--model', 'sonnet', + '--system-prompt', expect.any(String), + ], + { + cwd: '/my/cwd', + stdio: ['pipe', 'pipe', 'pipe'], + } + ); + + Object.defineProperty(process, 'platform', { value: process.platform, configurable: true }); }); it('default model is sonnet when no options', async () => { @@ -99,7 +130,7 @@ describe('CliProvider', () => { await promise; expect(mockSpawn).toHaveBeenCalledWith( - 'claude', + expect.any(String), expect.arrayContaining(['--model', 'sonnet']), expect.any(Object) ); @@ -114,12 +145,8 @@ describe('CliProvider', () => { await promise; expect(mockSpawn).toHaveBeenCalledWith( - 'claude', - [ - '-p', 'inst', - '--model', 'opus', - '--system-prompt', expect.any(String), - ], + expect.any(String), + expect.arrayContaining(['-p', 'inst', '--model', 'opus', '--system-prompt', expect.any(String)]), expect.any(Object) ); }); @@ -137,7 +164,7 @@ describe('CliProvider', () => { await promise; expect(mockSpawn).toHaveBeenCalledWith( - 'claude', + expect.any(String), expect.arrayContaining(['--model', 'sonnet']), expect.any(Object) ); diff --git a/src/providers/cliProvider.ts b/src/providers/cliProvider.ts index 62c25de..5d2f026 100644 --- a/src/providers/cliProvider.ts +++ b/src/providers/cliProvider.ts @@ -25,13 +25,20 @@ export class CliProvider implements CommitMessageProvider { return; } + // On Windows, npm wraps the claude binary as claude.cmd which + // Node's spawn cannot execute without a shell. Route through + // cmd /c so Windows resolves the .cmd shim correctly while + // still passing each argument as a discrete array element + // (avoiding the quoting/newline pitfalls of shell:true). + const isWindows = process.platform === 'win32'; + const command = isWindows ? 'cmd' : 'claude'; + const args = isWindows + ? ['/c', 'claude', '-p', instruction, '--model', model, '--system-prompt', buildSystemPrompt()] + : ['-p', instruction, '--model', model, '--system-prompt', buildSystemPrompt()]; + const child: ChildProcess = spawn( - 'claude', - [ - '-p', instruction, - '--model', model, - '--system-prompt', buildSystemPrompt(), - ], + command, + args, { cwd: this.cwd, stdio: ['pipe', 'pipe', 'pipe'],