Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 52 additions & 5 deletions lib/provider-detection.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
Expand All @@ -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();
};
Expand All @@ -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,
};
6 changes: 4 additions & 2 deletions task-lib/attachable-watcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -241,7 +243,7 @@ server = new AttachServer({
id: taskId,
socketPath,
command,
args: finalArgs,
args: spawnSpec.args,
cwd,
env,
cols: 120,
Expand Down
3 changes: 2 additions & 1 deletion task-lib/runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand Down Expand Up @@ -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 || {},
};
}
Expand Down
7 changes: 4 additions & 3 deletions task-lib/watcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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'],
Expand Down
109 changes: 109 additions & 0 deletions tests/providers/detection.test.js
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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');
});
});