diff --git a/gitnexus/src/cli/setup.ts b/gitnexus/src/cli/setup.ts index 3e7cbad8f..fe9d86f52 100644 --- a/gitnexus/src/cli/setup.ts +++ b/gitnexus/src/cli/setup.ts @@ -61,11 +61,13 @@ function resolveGitnexusBin(): string | null { .filter(Boolean); if (isWin) { - // On Windows, `where` returns multiple entries (e.g. the POSIX shell - // script AND the .cmd/.bat wrapper). Prefer the wrapper because - // child_process.spawn() cannot execute a shell script directly. + // On Windows, npm global installs can surface multiple launchers for the + // same package (e.g. a POSIX shell shim plus .cmd/.bat wrappers). Claude + // and the other MCP hosts need a directly spawnable command path, so only + // accept the Windows wrapper. If it is missing, fall back to the slower + // npx entry instead of persisting a non-spawnable shim path. const cmdLine = lines.find((l) => /\.(cmd|bat)$/i.test(l)); - return cmdLine || lines[0] || null; + return cmdLine || null; } return lines[0] || null; diff --git a/gitnexus/test/unit/setup-codex.test.ts b/gitnexus/test/unit/setup-codex.test.ts index 95761bed0..5951c9325 100644 --- a/gitnexus/test/unit/setup-codex.test.ts +++ b/gitnexus/test/unit/setup-codex.test.ts @@ -15,8 +15,13 @@ const execFileMock = vi.fn((...args: any[]) => { } }); +const execFileSyncMock = vi.fn(() => { + throw new Error('not found'); +}); + vi.mock('child_process', () => ({ execFile: execFileMock, + execFileSync: execFileSyncMock, })); describe('setupCommand codex execution', () => { @@ -74,6 +79,21 @@ describe('setupCommand codex execution', () => { ); }); + it('uses Windows npx fallback arguments when where returns only a non-wrapper shim', async () => { + execFileSyncMock.mockReturnValueOnce('C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus\n'); + + const { setupCommand } = await import('../../src/cli/setup.js'); + + await setupCommand(); + + expect(execFileMock).toHaveBeenCalledWith( + 'codex', + ['mcp', 'add', 'gitnexus', '--', 'cmd', '/c', 'npx', '-y', NPX_REF, 'mcp'], + { shell: true }, + expect.any(Function), + ); + }); + it('invokes codex mcp add without shell on non-Windows and does not write fallback config', async () => { setPlatform('darwin'); diff --git a/gitnexus/test/unit/setup-jsonc.test.ts b/gitnexus/test/unit/setup-jsonc.test.ts index e31955950..f4f44c618 100644 --- a/gitnexus/test/unit/setup-jsonc.test.ts +++ b/gitnexus/test/unit/setup-jsonc.test.ts @@ -241,6 +241,50 @@ describe('setupOpenCode — JSONC preservation', () => { }); }); + it('uses Windows npx fallback when where returns only a non-wrapper shim', async () => { + setPlatform('win32'); + execFileSyncMock.mockReturnValueOnce('C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus\n'); + + const jsonc = `{ + "model": "test", + "mcp": {} +}`; + await fs.writeFile(opencodeJsonPath(), jsonc, 'utf-8'); + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand(); + + const raw = await fs.readFile(opencodeJsonPath(), 'utf-8'); + const config = parseJsonc(raw); + + expect(config.mcp.gitnexus).toEqual({ + type: 'local', + command: ['cmd', '/c', 'npx', '-y', NPX_REF, 'mcp'], + }); + }); + + it('uses Windows npx fallback when where returns only a .ps1 path', async () => { + setPlatform('win32'); + execFileSyncMock.mockReturnValueOnce('C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus.ps1\n'); + + const jsonc = `{ + "model": "test", + "mcp": {} +}`; + await fs.writeFile(opencodeJsonPath(), jsonc, 'utf-8'); + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand(); + + const raw = await fs.readFile(opencodeJsonPath(), 'utf-8'); + const config = parseJsonc(raw); + + expect(config.mcp.gitnexus).toEqual({ + type: 'local', + command: ['cmd', '/c', 'npx', '-y', NPX_REF, 'mcp'], + }); + }); + it('preserves tab indentation in existing file', async () => { const tabbed = `{\n\t"model": "test"\n}`; await fs.writeFile(opencodeJsonPath(), tabbed, 'utf-8'); @@ -360,6 +404,22 @@ describe('setupCursor — JSONC preservation', () => { const raw = await fs.readFile(mcpPath(), 'utf-8'); expect(raw).toBe(corrupt); }); + + it('uses Windows npx fallback when where returns only a non-wrapper shim', async () => { + setPlatform('win32'); + execFileSyncMock.mockReturnValueOnce('C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus\n'); + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand(); + + const raw = await fs.readFile(mcpPath(), 'utf-8'); + const config = parseJsonc(raw); + + expect(config.mcpServers.gitnexus).toEqual({ + command: 'cmd', + args: ['/c', 'npx', '-y', NPX_REF, 'mcp'], + }); + }); }); describe('setupClaudeCode — JSONC preservation', () => { diff --git a/gitnexus/test/unit/setup.test.ts b/gitnexus/test/unit/setup.test.ts index bdf1cd1fd..ee43d97d7 100644 --- a/gitnexus/test/unit/setup.test.ts +++ b/gitnexus/test/unit/setup.test.ts @@ -282,9 +282,9 @@ describe('setupClaudeCode', () => { ).resolves.toBeUndefined(); }); - it('falls back to first line on Windows when no .cmd/.bat wrapper found', async () => { + it('falls back to npx on Windows when no .cmd/.bat wrapper is found', async () => { setPlatform('win32'); - // Edge case: where returns only the POSIX script (no .cmd wrapper) + // Edge case: where returns only a non-spawnable shim (no .cmd wrapper) execFileSyncMock.mockReturnValueOnce('C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus\n'); const { setupCommand } = await import('../../src/cli/setup.js'); @@ -294,8 +294,24 @@ describe('setupClaudeCode', () => { const config = JSON.parse(raw); expect(config.mcpServers.gitnexus).toEqual({ - command: 'C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus', - args: ['mcp'], + command: 'cmd', + args: ['/c', 'npx', '-y', NPX_REF, 'mcp'], + }); + }); + + it('falls back to npx on Windows when where returns only a .ps1 path', async () => { + setPlatform('win32'); + execFileSyncMock.mockReturnValueOnce('C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus.ps1\n'); + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand(); + + const raw = await fs.readFile(path.join(tempHome, '.claude.json'), 'utf-8'); + const config = JSON.parse(raw); + + expect(config.mcpServers.gitnexus).toEqual({ + command: 'cmd', + args: ['/c', 'npx', '-y', NPX_REF, 'mcp'], }); }); });