#!/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: 'minimax', apiKeyEnv: ['MINIMAX_API_KEY'], baseUrlSupported: true, notes: 'Global endpoint: https://api.minimax.io/v1 (default). China endpoint: https://api.minimaxi.com/v1. Set MINIMAX_BASE_URL to choose, or pass baseURL in agent config.' }, { id: 'deepseek', apiKeyEnv: ['DEEPSEEK_API_KEY'], baseUrlSupported: true, notes: 'OpenAI-compatible endpoint at https://api.deepseek.com/v1. Models: deepseek-chat (V3), deepseek-reasoner (thinking).' }, { 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', minimax: 'MiniMax-M2.7', deepseek: 'deepseek-chat', } 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) }) }