diff --git a/lib/provider-detection.js b/lib/provider-detection.js index 69526496..8e80d858 100644 --- a/lib/provider-detection.js +++ b/lib/provider-detection.js @@ -2,13 +2,17 @@ const { execSync, spawnSync } = require('child_process'); const fs = require('fs'); const path = require('path'); +function commandLookupCommand(command) { + return process.platform === 'win32' ? `where ${command}` : `command -v ${command}`; +} + function commandExists(command) { if (!command) return false; if (command.includes(path.sep)) { return fs.existsSync(command); } try { - execSync(`command -v ${command}`, { stdio: 'pipe' }); + execSync(commandLookupCommand(command), { stdio: 'pipe' }); return true; } catch { return false; @@ -21,8 +25,8 @@ function getCommandPath(command) { return fs.existsSync(command) ? command : null; } try { - const output = execSync(`command -v ${command}`, { encoding: 'utf8', stdio: 'pipe' }); - return output.trim() || null; + const output = execSync(commandLookupCommand(command), { encoding: 'utf8', stdio: 'pipe' }); + return output.trim().split(/\r?\n/)[0] || null; } catch { return null; } @@ -32,7 +36,7 @@ function getHelpOutput(command, args = []) { if (!commandExists(command)) return ''; const attempt = (flag) => { - const result = spawnSync(command, [...args, flag], { encoding: 'utf8' }); + const result = spawnCommandSync(command, [...args, flag]); const output = `${result.stdout || ''}${result.stderr || ''}`; return output.trim(); }; @@ -46,14 +50,57 @@ function getHelpOutput(command, args = []) { function getVersionOutput(command, args = []) { if (!commandExists(command)) return ''; - const result = spawnSync(command, [...args, '--version'], { encoding: 'utf8' }); + const result = spawnCommandSync(command, [...args, '--version']); const output = `${result.stdout || ''}${result.stderr || ''}`; return output.trim(); } +function spawnCommandSync(command, args = []) { + const spawnSpec = resolveWindowsCommandSpawn(command, args); + return spawnSync(spawnSpec.command, spawnSpec.args, { encoding: 'utf8' }); +} + +function resolveWindowsCommandSpawn(command, args = []) { + if (process.platform !== 'win32') { + return { command, args: [...args] }; + } + + const resolvedCommand = getCommandPath(command) || command; + if (!/\.(cmd|bat)$/i.test(resolvedCommand)) { + return { command: resolvedCommand, args: [...args] }; + } + + const wrapperContent = fs.readFileSync(resolvedCommand, 'utf8'); + const scriptPath = extractNodeScriptFromCmdWrapper(wrapperContent, resolvedCommand); + + if (!scriptPath) { + return { command: resolvedCommand, args: [...args] }; + } + + return { + command: process.execPath, + args: [scriptPath, ...args], + }; +} + +function extractNodeScriptFromCmdWrapper(wrapperContent, wrapperPath) { + const normalized = wrapperContent.replace(/\r\n/g, '\n'); + const match = normalized.match(/"%~dp0\\([^"\n]+\.(?:js|cjs|mjs))"/i); + if (!match) { + return null; + } + + const relativeScriptPath = match[1].replace(/\\/g, path.sep); + return path.resolve(path.dirname(wrapperPath), relativeScriptPath); +} + module.exports = { commandExists, getCommandPath, getHelpOutput, getVersionOutput, + commandLookupCommand, + resolveWindowsCommandSpawn, + extractNodeScriptFromCmdWrapper, + spawnCommandSync, }; diff --git a/task-lib/attachable-watcher.js b/task-lib/attachable-watcher.js index fedeaf0e..45e3a1b2 100644 --- a/task-lib/attachable-watcher.js +++ b/task-lib/attachable-watcher.js @@ -68,6 +68,7 @@ process.on('unhandledRejection', (reason) => { const require = createRequire(import.meta.url); const { AttachServer } = require('../src/attach'); const { normalizeProviderName } = require('../lib/provider-names'); +const { resolveWindowsCommandSpawn } = require('../lib/provider-detection'); const taskId = taskIdArg; const cwd = cwdArg; @@ -91,8 +92,9 @@ const providerName = normalizeProviderName(config.provider || 'claude'); const enableRecovery = providerName === 'claude'; const env = { ...process.env, ...(config.env || {}) }; -const command = config.command || 'claude'; const finalArgs = [...args]; +const spawnSpec = resolveWindowsCommandSpawn(config.command || 'claude', finalArgs); +const command = spawnSpec.command; const silentJsonMode = config.outputFormat === 'json' && config.jsonSchema && config.silentJsonOutput && enableRecovery; @@ -241,7 +243,7 @@ server = new AttachServer({ id: taskId, socketPath, command, - args: finalArgs, + args: spawnSpec.args, cwd, env, cols: 120, diff --git a/task-lib/runner.js b/task-lib/runner.js index 807008c7..a6045a17 100644 --- a/task-lib/runner.js +++ b/task-lib/runner.js @@ -9,6 +9,7 @@ const require = createRequire(import.meta.url); const { loadSettings } = require('../lib/settings.js'); const { normalizeProviderName } = require('../lib/provider-names'); const { getProvider } = require('../src/providers'); +const { getCommandPath } = require('../lib/provider-detection'); const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -156,7 +157,7 @@ function buildWatcherConfig(outputFormat, jsonSchema, options, providerName, com jsonSchema, silentJsonOutput: options.silentJsonOutput || false, provider: providerName, - command: commandSpec.binary, + command: getCommandPath(commandSpec.binary) || commandSpec.binary, env: commandSpec.env || {}, }; } diff --git a/task-lib/watcher.js b/task-lib/watcher.js index ea36169b..0e496b64 100644 --- a/task-lib/watcher.js +++ b/task-lib/watcher.js @@ -17,6 +17,7 @@ import { createRequire } from 'module'; const require = createRequire(import.meta.url); const { normalizeProviderName } = require('../lib/provider-names'); +const { resolveWindowsCommandSpawn } = require('../lib/provider-detection'); const [, , taskId, cwd, logFile, argsJson, configJson] = process.argv; const args = JSON.parse(argsJson); @@ -30,10 +31,10 @@ const providerName = normalizeProviderName(config.provider || 'claude'); const enableRecovery = providerName === 'claude'; const env = { ...process.env, ...(config.env || {}) }; -const command = config.command || 'claude'; const finalArgs = [...args]; - -const child = spawn(command, finalArgs, { +const spawnSpec = resolveWindowsCommandSpawn(config.command || 'claude', finalArgs); +const command = spawnSpec.command; +const child = spawn(command, spawnSpec.args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'], diff --git a/tests/providers/detection.test.js b/tests/providers/detection.test.js index ef02018d..0c3550b6 100644 --- a/tests/providers/detection.test.js +++ b/tests/providers/detection.test.js @@ -1,9 +1,17 @@ const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const sinon = require('sinon'); const { commandExists, getCommandPath, getHelpOutput, getVersionOutput, + commandLookupCommand, + resolveWindowsCommandSpawn, + extractNodeScriptFromCmdWrapper, + spawnCommandSync, } = require('../../lib/provider-detection'); describe('Provider CLI detection', () => { @@ -28,3 +36,104 @@ describe('Provider CLI detection', () => { assert.ok(version.length > 0); }); }); + +describe('commandLookupCommand', () => { + let platformStub; + + afterEach(() => { + if (platformStub) platformStub.restore(); + }); + + it('uses where on Windows', () => { + platformStub = sinon.stub(process, 'platform').value('win32'); + assert.strictEqual(commandLookupCommand('claude'), 'where claude'); + }); + + it('uses command -v on non-Windows platforms', () => { + platformStub = sinon.stub(process, 'platform').value('linux'); + assert.strictEqual(commandLookupCommand('claude'), 'command -v claude'); + }); +}); + +describe('extractNodeScriptFromCmdWrapper', () => { + it('extracts the node entry script from an npm cmd wrapper', () => { + const wrapperPath = 'C:\\Users\\sense\\AppData\\Roaming\\npm\\claude.cmd'; + const wrapper = `@ECHO off\r\n"%~dp0\\node.exe" "%~dp0\\node_modules\\@anthropic-ai\\claude-code\\cli.js" %*\r\n`; + + const scriptPath = extractNodeScriptFromCmdWrapper(wrapper, wrapperPath); + assert.strictEqual( + scriptPath, + path.resolve(path.dirname(wrapperPath), 'node_modules/@anthropic-ai/claude-code/cli.js') + ); + }); + + it('returns null when the wrapper does not reference a node script', () => { + const wrapper = '@ECHO off\r\necho hello\r\n'; + assert.strictEqual(extractNodeScriptFromCmdWrapper(wrapper, 'C:\\tmp\\tool.cmd'), null); + }); +}); + +describe('resolveWindowsCommandSpawn', () => { + let platformStub; + let tempDir; + + afterEach(() => { + sinon.restore(); + platformStub = null; + if (tempDir) { + fs.rmSync(tempDir, { recursive: true, force: true }); + tempDir = null; + } + }); + + it('invokes node directly for npm cmd wrappers on Windows', () => { + platformStub = sinon.stub(process, 'platform').value('win32'); + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'provider-detection-')); + const wrapperPath = path.join(tempDir, 'claude.cmd'); + const scriptRel = 'node_modules\\@anthropic-ai\\claude-code\\cli.js'; + fs.writeFileSync( + wrapperPath, + `@ECHO off\r\n"%~dp0\\node.exe" "%~dp0\\${scriptRel}" %*\r\n` + ); + + const spec = resolveWindowsCommandSpawn(wrapperPath, ['--print', 'hello world']); + assert.strictEqual(spec.command, process.execPath); + assert.strictEqual( + spec.args[0], + path.resolve(tempDir, 'node_modules/@anthropic-ai/claude-code/cli.js') + ); + assert.deepStrictEqual(spec.args.slice(1), ['--print', 'hello world']); + }); + + it('returns the original command on non-Windows platforms', () => { + platformStub = sinon.stub(process, 'platform').value('linux'); + const spec = resolveWindowsCommandSpawn('claude', ['--print', 'hello']); + assert.strictEqual(spec.command, 'claude'); + assert.deepStrictEqual(spec.args, ['--print', 'hello']); + }); + + it('uses the resolved spawn spec for sync help/version probes', () => { + platformStub = sinon.stub(process, 'platform').value('win32'); + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'provider-detection-')); + const wrapperPath = path.join(tempDir, 'codex.cmd'); + const scriptDir = path.join(tempDir, 'node_modules', '@openai', 'codex', 'dist'); + fs.mkdirSync(scriptDir, { recursive: true }); + const scriptPath = path.join(scriptDir, 'cli.js'); + fs.writeFileSync( + scriptPath, + "if (process.argv.includes('--help')) { console.log('wrapped-help'); } else if (process.argv.includes('--version')) { console.log('wrapped-version'); }" + ); + fs.writeFileSync( + wrapperPath, + '@ECHO off\r\n"%~dp0\\node.exe" "%~dp0\\node_modules\\@openai\\codex\\dist\\cli.js" %*\r\n' + ); + + const help = getHelpOutput(wrapperPath, ['exec']); + const version = getVersionOutput(wrapperPath); + const result = spawnCommandSync(wrapperPath, ['exec', '--help']); + + assert.strictEqual(help, 'wrapped-help'); + assert.strictEqual(version, 'wrapped-version'); + assert.strictEqual(result.stdout.trim(), 'wrapped-help'); + }); +});