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'],