diff --git a/src/agents/detect.js b/src/agents/detect.js new file mode 100644 index 00000000..f9dc89b8 --- /dev/null +++ b/src/agents/detect.js @@ -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, +}; diff --git a/test/agents-detect.test.js b/test/agents-detect.test.js new file mode 100644 index 00000000..db77b60d --- /dev/null +++ b/test/agents-detect.test.js @@ -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); +});