Skip to content
Merged
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
116 changes: 116 additions & 0 deletions src/agents/detect.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
const registry = require('./registry');
const { run } = require('../core/runtime');

function registryEntries() {
const source =
registry.agents ||
registry.AGENTS ||
registry.registry ||
registry.entries ||
registry.default ||
registry;

if (Array.isArray(source)) {
return source;
}

if (source && typeof source === 'object') {
return Object.entries(source)
.filter(([, entry]) => entry && typeof entry === 'object')
.map(([id, entry]) => ({ id, ...entry }));
}

return [];
}

function findAgent(agentId) {
if (typeof registry.getAgent === 'function') {
const entry = registry.getAgent(agentId);
if (entry) return entry;
}

return registryEntries().find((entry) => entry.id === agentId);
}

function normalizeDetectCommand(detectCommand) {
if (Array.isArray(detectCommand)) {
const [cmd, ...args] = detectCommand;
return { cmd, args, command: detectCommand.join(' ') };
}

if (typeof detectCommand === 'string') {
const [cmd, ...args] = detectCommand.trim().split(/\s+/).filter(Boolean);
return { cmd, args, command: detectCommand.trim() };
}

if (detectCommand && typeof detectCommand === 'object') {
const cmd = detectCommand.cmd || detectCommand.command || detectCommand.bin;
const args = Array.isArray(detectCommand.args) ? detectCommand.args : [];
return {
cmd,
args,
command: [cmd, ...args].filter(Boolean).join(' '),
};
}

return { cmd: null, args: [], command: null };
}

function resultError(result) {
if (result.error) {
return result.error.message || String(result.error);
}

const output = `${result.stderr || ''}${result.stdout || ''}`.trim();
if (output) return output;

if (typeof result.status === 'number') {
return `detect command exited with status ${result.status}`;
}

return 'detect command failed';
}

function detectionResult(entry, available, command, error = null) {
return {
id: entry.id,
label: entry.label || entry.id,
available,
command,
error,
};
}

function detectAgent(agentId) {
const entry = findAgent(agentId);
if (!entry) {
return detectionResult({ id: agentId, label: agentId }, false, null, `unknown agent: ${agentId}`);
}

const { cmd, args, command } = normalizeDetectCommand(entry.detectCommand);
if (!cmd) {
return detectionResult(entry, false, command, 'missing detectCommand');
}

const result = run(cmd, args, { stdio: 'pipe' });
if (!result.error && result.status === 0) {
return detectionResult(entry, true, command, null);
}

return detectionResult(entry, false, command, resultError(result));
}

function detectAgents(agentIds) {
const ids = Array.isArray(agentIds) ? agentIds : registryEntries().map((entry) => entry.id);
return ids.map((agentId) => detectAgent(agentId));
}

function detectAvailableAgents() {
return detectAgents().filter((agent) => agent.available);
}

module.exports = {
detectAgent,
detectAgents,
detectAvailableAgents,
};
165 changes: 165 additions & 0 deletions test/agents-detect.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
const { test, assert } = require('./helpers/install-test-helpers');
const Module = require('node:module');
const path = require('node:path');

const detectPath = path.resolve(__dirname, '..', 'src', 'agents', 'detect.js');
const registryPath = path.resolve(__dirname, '..', 'src', 'agents', 'registry.js');
const runtimePath = path.resolve(__dirname, '..', 'src', 'core', 'runtime.js');

function withMockedDetection({ registry, run }, fn) {
const originalLoad = Module._load;
delete require.cache[detectPath];

Module._load = function load(request, parent, isMain) {
if (parent?.filename === detectPath && request === './registry') {
return registry;
}
if (parent?.filename === detectPath && request === '../core/runtime') {
return { run };
}

const resolved = Module._resolveFilename(request, parent, isMain);
if (resolved === registryPath) {
return registry;
}
if (resolved === runtimePath) {
return { run };
}
return originalLoad.apply(this, arguments);
};

try {
return fn(require(detectPath));
} finally {
delete require.cache[detectPath];
Module._load = originalLoad;
}
}

test('detectAgent reports an available registered agent without launching it', () => {
const calls = [];
const registry = {
agents: [
{ id: 'codex', label: 'Codex', detectCommand: ['codex', '--version'] },
{ id: 'claude', label: 'Claude', detectCommand: ['claude', '--version'] },
],
};

withMockedDetection(
{
registry,
run: (cmd, args, options) => {
calls.push({ cmd, args, options });
return { status: 0, stdout: 'codex 1.2.3\n', stderr: '' };
},
},
({ detectAgent }) => {
assert.deepEqual(detectAgent('codex'), {
id: 'codex',
label: 'Codex',
available: true,
command: 'codex --version',
error: null,
});
},
);

assert.deepEqual(calls, [
{ cmd: 'codex', args: ['--version'], options: { stdio: 'pipe' } },
]);
});

test('detectAgent reports command failures as unavailable with error text', () => {
const registry = {
agents: [
{ id: 'claude', label: 'Claude', detectCommand: { command: 'claude', args: ['--version'] } },
],
};

withMockedDetection(
{
registry,
run: () => ({ status: 127, stdout: '', stderr: 'claude: command not found\n' }),
},
({ detectAgent }) => {
assert.deepEqual(detectAgent('claude'), {
id: 'claude',
label: 'Claude',
available: false,
command: 'claude --version',
error: 'claude: command not found',
});
},
);
});

test('detectAgents preserves requested order and supports registry maps', () => {
const registry = {
codex: { label: 'Codex', detectCommand: 'codex --version' },
gemini: { label: 'Gemini', detectCommand: ['gemini', '--version'] },
};

withMockedDetection(
{
registry,
run: (cmd) => ({ status: cmd === 'gemini' ? 1 : 0, stdout: '', stderr: '' }),
},
({ detectAgents }) => {
assert.deepEqual(detectAgents(['gemini', 'codex']).map((agent) => agent.id), ['gemini', 'codex']);
assert.deepEqual(detectAgents(['gemini', 'codex']).map((agent) => agent.available), [false, true]);
},
);
});

test('detectAvailableAgents returns only successful detections', () => {
const registry = {
agents: [
{ id: 'codex', label: 'Codex', detectCommand: ['codex', '--version'] },
{ id: 'missing', label: 'Missing', detectCommand: ['missing-agent', '--version'] },
{ id: 'broken', label: 'Broken' },
],
};

withMockedDetection(
{
registry,
run: (cmd) => ({ status: cmd === 'codex' ? 0 : 127, stdout: '', stderr: '' }),
},
({ detectAvailableAgents }) => {
assert.deepEqual(detectAvailableAgents(), [
{
id: 'codex',
label: 'Codex',
available: true,
command: 'codex --version',
error: null,
},
]);
},
);
});

test('detectAgent reports unknown agents without running commands', () => {
let callCount = 0;

withMockedDetection(
{
registry: { agents: [] },
run: () => {
callCount += 1;
return { status: 0, stdout: '', stderr: '' };
},
},
({ detectAgent }) => {
assert.deepEqual(detectAgent('ghost'), {
id: 'ghost',
label: 'ghost',
available: false,
command: null,
error: 'unknown agent: ghost',
});
},
);

assert.equal(callCount, 0);
});
Loading