From dfe46721a5efbf1893cdb641cd67763965689256 Mon Sep 17 00:00:00 2001 From: Ibrahim Kazimov <74775400+ibrahimkzmv@users.noreply.github.com> Date: Sun, 12 Apr 2026 20:12:16 +0300 Subject: [PATCH] feat: add cli --- CLAUDE.md | 3 +- README.md | 2 + package.json | 4 + src/cli/oma.ts | 439 ++++++++++++++++++++++++++++++++++++++++++++++ tests/cli.test.ts | 69 ++++++++ 5 files changed, 516 insertions(+), 1 deletion(-) create mode 100644 src/cli/oma.ts create mode 100644 tests/cli.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 7a74bdb..0b78e16 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,9 +10,10 @@ npm run dev # Watch mode compilation npm run lint # Type-check only (tsc --noEmit) npm test # Run all tests (vitest run) npm run test:watch # Vitest watch mode +node dist/cli/oma.js help # After build: shell/CI CLI (`oma` when installed via npm bin) ``` -Tests live in `tests/` (vitest). Examples in `examples/` are standalone scripts requiring API keys (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`). +Tests live in `tests/` (vitest). Examples in `examples/` are standalone scripts requiring API keys (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`). CLI usage and JSON schemas: `docs/cli.md`. ## Architecture diff --git a/README.md b/README.md index 48d53d7..e9aed5e 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,8 @@ Set the API key for your provider. Local models via Ollama require no API key - `XAI_API_KEY` (for Grok) - `GITHUB_TOKEN` (for Copilot) +**CLI (`oma`).** For shell and CI, the package exposes a JSON-first binary. See [docs/cli.md](./docs/cli.md) for `oma run`, `oma task`, `oma provider`, exit codes, and file formats. + Three agents, one goal — the framework handles the rest: ```typescript diff --git a/package.json b/package.json index 8e1ced7..50b2232 100644 --- a/package.json +++ b/package.json @@ -4,12 +4,16 @@ "description": "TypeScript multi-agent framework — one runTeam() call from goal to result. Auto task decomposition, parallel execution. 3 dependencies, deploys anywhere Node.js runs.", "files": [ "dist", + "docs", "README.md", "LICENSE" ], "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", + "bin": { + "oma": "dist/cli/oma.js" + }, "exports": { ".": { "types": "./dist/index.d.ts", diff --git a/src/cli/oma.ts b/src/cli/oma.ts new file mode 100644 index 0000000..d56304a --- /dev/null +++ b/src/cli/oma.ts @@ -0,0 +1,439 @@ +#!/usr/bin/env node +/** + * Thin shell/CI wrapper over OpenMultiAgent — no interactive session, cwd binding, + * approvals, or persistence. + * + * Exit codes: + * 0 — finished; team run succeeded + * 1 — finished; team run reported failure (agents/tasks) + * 2 — invalid usage, I/O, or JSON validation + * 3 — unexpected runtime error (including LLM errors) + */ + +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { OpenMultiAgent } from '../orchestrator/orchestrator.js' +import type { SupportedProvider } from '../llm/adapter.js' +import type { AgentRunResult, CoordinatorConfig, OrchestratorConfig, TeamConfig, TeamRunResult } from '../types.js' + +// --------------------------------------------------------------------------- +// Exit codes +// --------------------------------------------------------------------------- + +export const EXIT = { + SUCCESS: 0, + RUN_FAILED: 1, + USAGE: 2, + INTERNAL: 3, +} as const + +class OmaValidationError extends Error { + override readonly name = 'OmaValidationError' + constructor(message: string) { + super(message) + } +} + +// --------------------------------------------------------------------------- +// Provider helper (static reference data) +// --------------------------------------------------------------------------- + +const PROVIDER_REFERENCE: ReadonlyArray<{ + id: SupportedProvider + apiKeyEnv: readonly string[] + baseUrlSupported: boolean + notes?: string +}> = [ + { id: 'anthropic', apiKeyEnv: ['ANTHROPIC_API_KEY'], baseUrlSupported: true }, + { id: 'openai', apiKeyEnv: ['OPENAI_API_KEY'], baseUrlSupported: true, notes: 'Set baseURL for Ollama / vLLM / LM Studio; apiKey may be a placeholder.' }, + { id: 'gemini', apiKeyEnv: ['GEMINI_API_KEY', 'GOOGLE_API_KEY'], baseUrlSupported: false }, + { id: 'grok', apiKeyEnv: ['XAI_API_KEY'], baseUrlSupported: true }, + { + id: 'copilot', + apiKeyEnv: ['GITHUB_COPILOT_TOKEN', 'GITHUB_TOKEN'], + baseUrlSupported: false, + notes: 'If no token env is set, Copilot adapter may start an interactive OAuth device flow (avoid in CI).', + }, +] + +// --------------------------------------------------------------------------- +// argv / JSON helpers +// --------------------------------------------------------------------------- + +export function parseArgs(argv: string[]): { + _: string[] + flags: Set + kv: Map +} { + const _ = argv.slice(2) + const flags = new Set() + const kv = new Map() + let i = 0 + while (i < _.length) { + const a = _[i]! + if (a === '--') { + break + } + if (a.startsWith('--')) { + const eq = a.indexOf('=') + if (eq !== -1) { + kv.set(a.slice(2, eq), a.slice(eq + 1)) + i++ + continue + } + const key = a.slice(2) + const next = _[i + 1] + if (next !== undefined && !next.startsWith('--')) { + kv.set(key, next) + i += 2 + } else { + flags.add(key) + i++ + } + continue + } + i++ + } + return { _, flags, kv } +} + +function getOpt(kv: Map, flags: Set, key: string): string | undefined { + if (flags.has(key)) return '' + return kv.get(key) +} + +function readJson(path: string): unknown { + const abs = resolve(path) + const raw = readFileSync(abs, 'utf8') + try { + return JSON.parse(raw) as unknown + } catch (e) { + if (e instanceof SyntaxError) { + throw new Error(`Invalid JSON in ${abs}: ${e.message}`) + } + throw e + } +} + +function isObject(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v) +} + +function asTeamConfig(v: unknown, label: string): TeamConfig { + if (!isObject(v)) throw new OmaValidationError(`${label}: expected a JSON object`) + const name = v['name'] + const agents = v['agents'] + if (typeof name !== 'string' || !name) throw new OmaValidationError(`${label}.name: non-empty string required`) + if (!Array.isArray(agents) || agents.length === 0) { + throw new OmaValidationError(`${label}.agents: non-empty array required`) + } + for (const a of agents) { + if (!isObject(a)) throw new OmaValidationError(`${label}.agents[]: each agent must be an object`) + if (typeof a['name'] !== 'string' || !a['name']) throw new OmaValidationError(`agent.name required`) + if (typeof a['model'] !== 'string' || !a['model']) { + throw new OmaValidationError(`agent.model required for "${String(a['name'])}"`) + } + } + return v as unknown as TeamConfig +} + +function asOrchestratorPartial(v: unknown, label: string): OrchestratorConfig { + if (!isObject(v)) throw new OmaValidationError(`${label}: expected a JSON object`) + return v as OrchestratorConfig +} + +function asCoordinatorPartial(v: unknown, label: string): CoordinatorConfig { + if (!isObject(v)) throw new OmaValidationError(`${label}: expected a JSON object`) + return v as CoordinatorConfig +} + +function asTaskSpecs(v: unknown, label: string): ReadonlyArray<{ + title: string + description: string + assignee?: string + dependsOn?: string[] + memoryScope?: 'dependencies' | 'all' + maxRetries?: number + retryDelayMs?: number + retryBackoff?: number +}> { + if (!Array.isArray(v)) throw new OmaValidationError(`${label}: expected a JSON array`) + const out: Array<{ + title: string + description: string + assignee?: string + dependsOn?: string[] + memoryScope?: 'dependencies' | 'all' + maxRetries?: number + retryDelayMs?: number + retryBackoff?: number + }> = [] + let i = 0 + for (const item of v) { + if (!isObject(item)) throw new OmaValidationError(`${label}[${i}]: object expected`) + if (typeof item['title'] !== 'string' || typeof item['description'] !== 'string') { + throw new OmaValidationError(`${label}[${i}]: title and description strings required`) + } + const row: (typeof out)[0] = { + title: item['title'], + description: item['description'], + } + if (typeof item['assignee'] === 'string') row.assignee = item['assignee'] + if (Array.isArray(item['dependsOn'])) { + row.dependsOn = item['dependsOn'].filter((x): x is string => typeof x === 'string') + } + if (item['memoryScope'] === 'all' || item['memoryScope'] === 'dependencies') { + row.memoryScope = item['memoryScope'] + } + if (typeof item['maxRetries'] === 'number') row.maxRetries = item['maxRetries'] + if (typeof item['retryDelayMs'] === 'number') row.retryDelayMs = item['retryDelayMs'] + if (typeof item['retryBackoff'] === 'number') row.retryBackoff = item['retryBackoff'] + out.push(row) + i++ + } + return out +} + +export interface CliJsonOptions { + readonly pretty: boolean + readonly includeMessages: boolean +} + +export function serializeAgentResult(r: AgentRunResult, includeMessages: boolean): Record { + const base: Record = { + success: r.success, + output: r.output, + tokenUsage: r.tokenUsage, + toolCalls: r.toolCalls, + structured: r.structured, + loopDetected: r.loopDetected, + budgetExceeded: r.budgetExceeded, + } + if (includeMessages) base['messages'] = r.messages + return base +} + +export function serializeTeamRunResult(result: TeamRunResult, opts: CliJsonOptions): Record { + const agentResults: Record = {} + for (const [k, v] of result.agentResults) { + agentResults[k] = serializeAgentResult(v, opts.includeMessages) + } + return { + success: result.success, + totalTokenUsage: result.totalTokenUsage, + agentResults, + } +} + +function printJson(data: unknown, pretty: boolean): void { + const s = pretty ? JSON.stringify(data, null, 2) : JSON.stringify(data) + process.stdout.write(`${s}\n`) +} + +function help(): string { + return [ + 'open-multi-agent CLI (oma)', + '', + 'Usage:', + ' oma run --goal --team [--orchestrator ] [--coordinator ]', + ' oma task --file [--team ]', + ' oma provider [list | template ]', + '', + 'Flags:', + ' --pretty Pretty-print JSON to stdout', + ' --include-messages Include full LLM message arrays in run output (large)', + '', + 'team.json may be a TeamConfig object, or { "team": TeamConfig, "orchestrator": { ... } }.', + 'tasks.json: { "team": TeamConfig, "tasks": [ ... ], "orchestrator"?: { ... } }.', + ' Optional --team overrides the embedded team object.', + '', + 'Exit codes: 0 success, 1 run failed, 2 usage/validation, 3 internal', + ].join('\n') +} + +const DEFAULT_MODEL_HINT: Record = { + anthropic: 'claude-opus-4-6', + openai: 'gpt-4o', + gemini: 'gemini-2.0-flash', + grok: 'grok-2-latest', + copilot: 'gpt-4o', +} + +async function cmdProvider(sub: string | undefined, arg: string | undefined, pretty: boolean): Promise { + if (sub === undefined || sub === 'list') { + printJson({ providers: PROVIDER_REFERENCE }, pretty) + return EXIT.SUCCESS + } + if (sub === 'template') { + const id = arg as SupportedProvider | undefined + const row = PROVIDER_REFERENCE.find((p) => p.id === id) + if (!id || !row) { + printJson( + { + error: { + kind: 'usage', + message: `usage: oma provider template <${PROVIDER_REFERENCE.map((p) => p.id).join('|')}>`, + }, + }, + pretty, + ) + return EXIT.USAGE + } + printJson( + { + orchestrator: { + defaultProvider: id, + defaultModel: DEFAULT_MODEL_HINT[id], + }, + agent: { + name: 'worker', + model: DEFAULT_MODEL_HINT[id], + provider: id, + systemPrompt: 'You are a helpful assistant.', + }, + env: Object.fromEntries(row.apiKeyEnv.map((k) => [k, ``])), + notes: row.notes, + }, + pretty, + ) + return EXIT.SUCCESS + } + printJson({ error: { kind: 'usage', message: `unknown provider subcommand: ${sub}` } }, pretty) + return EXIT.USAGE +} + +function mergeOrchestrator(base: OrchestratorConfig, ...partials: OrchestratorConfig[]): OrchestratorConfig { + let o: OrchestratorConfig = { ...base } + for (const p of partials) { + o = { ...o, ...p } + } + return o +} + +async function main(): Promise { + const argv = parseArgs(process.argv) + const cmd = argv._[0] + const pretty = argv.flags.has('pretty') + const includeMessages = argv.flags.has('include-messages') + + if (cmd === undefined || cmd === 'help' || cmd === '-h' || cmd === '--help') { + process.stdout.write(`${help()}\n`) + return EXIT.SUCCESS + } + + if (cmd === 'provider') { + return cmdProvider(argv._[1], argv._[2], pretty) + } + + const jsonOpts: CliJsonOptions = { pretty, includeMessages } + + try { + if (cmd === 'run') { + const goal = getOpt(argv.kv, argv.flags, 'goal') + const teamPath = getOpt(argv.kv, argv.flags, 'team') + const orchPath = getOpt(argv.kv, argv.flags, 'orchestrator') + const coordPath = getOpt(argv.kv, argv.flags, 'coordinator') + if (!goal || !teamPath) { + printJson({ error: { kind: 'usage', message: '--goal and --team are required' } }, pretty) + return EXIT.USAGE + } + + const teamRaw = readJson(teamPath) + let teamCfg: TeamConfig + let orchParts: OrchestratorConfig[] = [] + if (isObject(teamRaw) && teamRaw['team'] !== undefined) { + teamCfg = asTeamConfig(teamRaw['team'], 'team') + if (teamRaw['orchestrator'] !== undefined) { + orchParts.push(asOrchestratorPartial(teamRaw['orchestrator'], 'orchestrator')) + } + } else { + teamCfg = asTeamConfig(teamRaw, 'team') + } + if (orchPath) { + orchParts.push(asOrchestratorPartial(readJson(orchPath), 'orchestrator file')) + } + + const orchestrator = new OpenMultiAgent(mergeOrchestrator({}, ...orchParts)) + const team = orchestrator.createTeam(teamCfg.name, teamCfg) + let coordinator: CoordinatorConfig | undefined + if (coordPath) { + coordinator = asCoordinatorPartial(readJson(coordPath), 'coordinator file') + } + const result = await orchestrator.runTeam(team, goal, coordinator ? { coordinator } : undefined) + await orchestrator.shutdown() + const payload = { command: 'run' as const, ...serializeTeamRunResult(result, jsonOpts) } + printJson(payload, pretty) + return result.success ? EXIT.SUCCESS : EXIT.RUN_FAILED + } + + if (cmd === 'task') { + const file = getOpt(argv.kv, argv.flags, 'file') + const teamOverride = getOpt(argv.kv, argv.flags, 'team') + if (!file) { + printJson({ error: { kind: 'usage', message: '--file is required' } }, pretty) + return EXIT.USAGE + } + const doc = readJson(file) + if (!isObject(doc)) { + throw new OmaValidationError('tasks file root must be an object') + } + const orchParts: OrchestratorConfig[] = [] + if (doc['orchestrator'] !== undefined) { + orchParts.push(asOrchestratorPartial(doc['orchestrator'], 'orchestrator')) + } + const teamCfg = teamOverride + ? asTeamConfig(readJson(teamOverride), 'team (--team)') + : asTeamConfig(doc['team'], 'team') + + const tasks = asTaskSpecs(doc['tasks'], 'tasks') + if (tasks.length === 0) { + throw new OmaValidationError('tasks array must not be empty') + } + + const orchestrator = new OpenMultiAgent(mergeOrchestrator({}, ...orchParts)) + const team = orchestrator.createTeam(teamCfg.name, teamCfg) + const result = await orchestrator.runTasks(team, tasks) + await orchestrator.shutdown() + const payload = { command: 'task' as const, ...serializeTeamRunResult(result, jsonOpts) } + printJson(payload, pretty) + return result.success ? EXIT.SUCCESS : EXIT.RUN_FAILED + } + + printJson({ error: { kind: 'usage', message: `unknown command: ${cmd}` } }, pretty) + return EXIT.USAGE + } catch (e) { + const message = e instanceof Error ? e.message : String(e) + const { kind, exit } = classifyCliError(e, message) + printJson({ error: { kind, message } }, pretty) + return exit + } +} + +function classifyCliError(e: unknown, message: string): { kind: string; exit: number } { + if (e instanceof OmaValidationError) return { kind: 'validation', exit: EXIT.USAGE } + if (message.includes('Invalid JSON')) return { kind: 'validation', exit: EXIT.USAGE } + if (message.includes('ENOENT') || message.includes('EACCES')) return { kind: 'io', exit: EXIT.USAGE } + return { kind: 'runtime', exit: EXIT.INTERNAL } +} + +const isMain = (() => { + const argv1 = process.argv[1] + if (!argv1) return false + try { + return fileURLToPath(import.meta.url) === resolve(argv1) + } catch { + return false + } +})() + +if (isMain) { + main() + .then((code) => process.exit(code)) + .catch((e) => { + const message = e instanceof Error ? e.message : String(e) + process.stdout.write(`${JSON.stringify({ error: { kind: 'internal', message } })}\n`) + process.exit(EXIT.INTERNAL) + }) +} diff --git a/tests/cli.test.ts b/tests/cli.test.ts new file mode 100644 index 0000000..692efc9 --- /dev/null +++ b/tests/cli.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest' +import { + EXIT, + parseArgs, + serializeAgentResult, + serializeTeamRunResult, +} from '../src/cli/oma.js' +import type { AgentRunResult, TeamRunResult } from '../src/types.js' + +describe('parseArgs', () => { + it('parses flags, key=value, and key value', () => { + const a = parseArgs(['node', 'oma', 'run', '--goal', 'hello', '--team=x.json', '--pretty']) + expect(a._[0]).toBe('run') + expect(a.kv.get('goal')).toBe('hello') + expect(a.kv.get('team')).toBe('x.json') + expect(a.flags.has('pretty')).toBe(true) + }) +}) + +describe('serializeTeamRunResult', () => { + it('maps agentResults to a plain object', () => { + const ar: AgentRunResult = { + success: true, + output: 'ok', + messages: [], + tokenUsage: { input_tokens: 1, output_tokens: 2 }, + toolCalls: [], + } + const tr: TeamRunResult = { + success: true, + agentResults: new Map([['alice', ar]]), + totalTokenUsage: { input_tokens: 1, output_tokens: 2 }, + } + const json = serializeTeamRunResult(tr, { pretty: false, includeMessages: false }) + expect(json.success).toBe(true) + expect((json.agentResults as Record)['alice']).toMatchObject({ + success: true, + output: 'ok', + }) + expect((json.agentResults as Record)['alice']).not.toHaveProperty('messages') + }) + + it('includes messages when requested', () => { + const ar: AgentRunResult = { + success: true, + output: 'x', + messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }], + tokenUsage: { input_tokens: 0, output_tokens: 0 }, + toolCalls: [], + } + const tr: TeamRunResult = { + success: true, + agentResults: new Map([['bob', ar]]), + totalTokenUsage: { input_tokens: 0, output_tokens: 0 }, + } + const json = serializeTeamRunResult(tr, { pretty: false, includeMessages: true }) + expect(serializeAgentResult(ar, true).messages).toHaveLength(1) + expect((json.agentResults as Record)['bob']).toHaveProperty('messages') + }) +}) + +describe('EXIT', () => { + it('uses stable numeric codes', () => { + expect(EXIT.SUCCESS).toBe(0) + expect(EXIT.RUN_FAILED).toBe(1) + expect(EXIT.USAGE).toBe(2) + expect(EXIT.INTERNAL).toBe(3) + }) +})