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
108 changes: 108 additions & 0 deletions src/agents/registry.js
Original file line number Diff line number Diff line change
@@ -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,
};
101 changes: 101 additions & 0 deletions test/agents-registry.test.js
Original file line number Diff line number Diff line change
@@ -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;
}
Loading