From c77c1c8380925dbfda169f79f2bd26f5df62baf4 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Wed, 29 Apr 2026 21:28:24 +0200 Subject: [PATCH] Centralize supported agent CLI metadata GitGuardex needs one source of truth for supported AI agent launch metadata before UI and launcher surfaces can consume provider capabilities consistently. This adds a frozen CommonJS registry and focused helper tests without wiring callers yet. Constraint: Requested foundation only, limited to src/agents/registry.js and test/agents-registry.test.js Rejected: Wire registry into existing launch flows | outside requested file scope and higher regression risk Confidence: high Scope-risk: narrow Tested: node --test test/agents-registry.test.js Tested: node --check src/agents/registry.js Tested: npm test --- src/agents/registry.js | 108 +++++++++++++++++++++++++++++++++++ test/agents-registry.test.js | 101 ++++++++++++++++++++++++++++++++ 2 files changed, 209 insertions(+) create mode 100644 src/agents/registry.js create mode 100644 test/agents-registry.test.js diff --git a/src/agents/registry.js b/src/agents/registry.js new file mode 100644 index 00000000..eab90b4b --- /dev/null +++ b/src/agents/registry.js @@ -0,0 +1,108 @@ +const AGENT_DEFINITIONS = [ + { + id: 'codex', + label: 'Codex', + shortLabel: 'CX', + description: 'OpenAI Codex CLI for repository-aware coding tasks.', + command: 'codex', + detectCommand: 'codex --version', + defaultEnabled: true, + promptMode: 'argument', + resumeCommandTemplate: 'codex resume {sessionId}', + }, + { + id: 'claude', + label: 'Claude Code', + shortLabel: 'CC', + description: 'Anthropic Claude Code CLI for interactive coding sessions.', + command: 'claude', + detectCommand: 'claude --version', + defaultEnabled: true, + promptMode: 'argument', + resumeCommandTemplate: 'claude --resume {sessionId}', + }, + { + id: 'opencode', + label: 'OpenCode', + shortLabel: 'OC', + description: 'OpenCode CLI for terminal-native AI coding workflows.', + command: 'opencode', + detectCommand: 'opencode --version', + defaultEnabled: true, + promptMode: 'argument', + }, + { + id: 'cursor', + label: 'Cursor Agent', + shortLabel: 'CA', + description: 'Cursor command-line agent for AI coding tasks.', + command: 'cursor-agent', + detectCommand: 'cursor-agent --version', + defaultEnabled: true, + promptMode: 'argument', + }, + { + id: 'gemini', + label: 'Gemini CLI', + shortLabel: 'GM', + description: 'Google Gemini CLI for AI-assisted development workflows.', + command: 'gemini', + detectCommand: 'gemini --version', + defaultEnabled: true, + promptMode: 'argument', + }, +]; + +function validateAgentDefinitions(definitions) { + const ids = new Set(); + const shortLabels = new Set(); + + for (const definition of definitions) { + if (ids.has(definition.id)) { + throw new Error(`Duplicate agent id: ${definition.id}`); + } + ids.add(definition.id); + + if (shortLabels.has(definition.shortLabel)) { + throw new Error(`Duplicate agent short label: ${definition.shortLabel}`); + } + shortLabels.add(definition.shortLabel); + } +} + +validateAgentDefinitions(AGENT_DEFINITIONS); + +const AGENT_IDS = Object.freeze(AGENT_DEFINITIONS.map((definition) => definition.id)); +const AGENT_REGISTRY = Object.freeze( + Object.fromEntries( + AGENT_DEFINITIONS.map((definition) => [ + definition.id, + Object.freeze({ ...definition }), + ]), + ), +); + +function isAgentId(value) { + return Object.prototype.hasOwnProperty.call(AGENT_REGISTRY, value); +} + +function getAgentDefinition(id) { + return AGENT_REGISTRY[id]; +} + +function getAgentDefinitions() { + return AGENT_IDS.map((id) => AGENT_REGISTRY[id]); +} + +function getDefaultEnabledAgents() { + return getAgentDefinitions().filter((definition) => definition.defaultEnabled); +} + +module.exports = { + AGENT_IDS, + AGENT_REGISTRY, + isAgentId, + getAgentDefinition, + getAgentDefinitions, + getDefaultEnabledAgents, +}; diff --git a/test/agents-registry.test.js b/test/agents-registry.test.js new file mode 100644 index 00000000..a758942b --- /dev/null +++ b/test/agents-registry.test.js @@ -0,0 +1,101 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +const { + AGENT_IDS, + AGENT_REGISTRY, + isAgentId, + getAgentDefinition, + getAgentDefinitions, + getDefaultEnabledAgents, +} = require('../src/agents/registry'); + +const repoRoot = path.resolve(__dirname, '..'); +const registryPath = path.join(repoRoot, 'src', 'agents', 'registry.js'); + +test('AGENT_IDS lists the supported agent ids in registry order', () => { + assert.deepEqual(AGENT_IDS, ['codex', 'claude', 'opencode', 'cursor', 'gemini']); +}); + +test('AGENT_REGISTRY exposes complete definitions keyed by id', () => { + for (const id of AGENT_IDS) { + const definition = AGENT_REGISTRY[id]; + + assert.equal(definition.id, id); + assert.equal(typeof definition.label, 'string'); + assert.equal(typeof definition.shortLabel, 'string'); + assert.equal(typeof definition.description, 'string'); + assert.equal(typeof definition.command, 'string'); + assert.equal(typeof definition.detectCommand, 'string'); + assert.equal(typeof definition.defaultEnabled, 'boolean'); + assert.equal(typeof definition.promptMode, 'string'); + } + + assert.equal(AGENT_REGISTRY.codex.command, 'codex'); + assert.equal(AGENT_REGISTRY.claude.command, 'claude'); + assert.equal(AGENT_REGISTRY.opencode.command, 'opencode'); + assert.equal(AGENT_REGISTRY.cursor.command, 'cursor-agent'); + assert.equal(AGENT_REGISTRY.gemini.command, 'gemini'); +}); + +test('isAgentId recognizes only supported agent ids', () => { + assert.equal(isAgentId('codex'), true); + assert.equal(isAgentId('claude'), true); + assert.equal(isAgentId('missing'), false); + assert.equal(isAgentId(null), false); +}); + +test('getAgentDefinition returns matching definitions', () => { + assert.equal(getAgentDefinition('codex'), AGENT_REGISTRY.codex); + assert.equal(getAgentDefinition('gemini'), AGENT_REGISTRY.gemini); + assert.equal(getAgentDefinition('missing'), undefined); +}); + +test('getAgentDefinitions returns all definitions in AGENT_IDS order', () => { + assert.deepEqual( + getAgentDefinitions().map((definition) => definition.id), + AGENT_IDS, + ); +}); + +test('getDefaultEnabledAgents returns only default-enabled definitions', () => { + assert.deepEqual( + getDefaultEnabledAgents().map((definition) => definition.id), + AGENT_IDS.filter((id) => AGENT_REGISTRY[id].defaultEnabled), + ); +}); + +test('registry load validates duplicate ids', () => { + assert.throws( + () => loadRegistrySourceWith({ + from: "id: 'claude'", + to: "id: 'codex'", + }), + /Duplicate agent id: codex/, + ); +}); + +test('registry load validates duplicate short labels', () => { + assert.throws( + () => loadRegistrySourceWith({ + from: "shortLabel: 'CC'", + to: "shortLabel: 'CX'", + }), + /Duplicate agent short label: CX/, + ); +}); + +function loadRegistrySourceWith({ from, to }) { + const source = fs.readFileSync(registryPath, 'utf8').replace(from, to); + const context = { + module: { exports: {} }, + exports: {}, + require, + }; + + vm.runInNewContext(source, context, { filename: registryPath }); + return context.module.exports; +}