Compare commits
3 Commits
0f16e81ae6
...
c0ddcfc7aa
| Author | SHA1 | Date |
|---|---|---|
|
|
c0ddcfc7aa | |
|
|
cdec60e7ad | |
|
|
dfe46721a5 |
|
|
@ -3,4 +3,3 @@ dist/
|
|||
coverage/
|
||||
*.tgz
|
||||
.DS_Store
|
||||
docs/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,255 @@
|
|||
# Command-line interface (`oma`)
|
||||
|
||||
The package ships a small binary **`oma`** that exposes the same primitives as the TypeScript API: `runTeam`, `runTasks`, plus a static provider reference. It is meant for **shell scripts and CI** (JSON on stdout, stable exit codes).
|
||||
|
||||
It does **not** provide an interactive REPL, working-directory injection into tools, human approval gates, or session persistence. Those stay in application code.
|
||||
|
||||
## Installation and invocation
|
||||
|
||||
After installing the package, the binary is on `PATH` when using `npx` or a local `node_modules/.bin`:
|
||||
|
||||
```bash
|
||||
npm install @jackchen_me/open-multi-agent
|
||||
npx oma help
|
||||
```
|
||||
|
||||
From a clone of the repository you need a build first:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
node dist/cli/oma.js help
|
||||
```
|
||||
|
||||
Set the usual provider API keys in the environment (see [README](../README.md#quick-start)); the CLI does not read secrets from flags.
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
### `oma run`
|
||||
|
||||
Runs **`OpenMultiAgent.runTeam(team, goal)`**: coordinator decomposition, task queue, optional synthesis.
|
||||
|
||||
| Argument | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `--goal` | Yes | Natural-language goal passed to the team run. |
|
||||
| `--team` | Yes | Path to JSON (see [Team file](#team-file)). |
|
||||
| `--orchestrator` | No | Path to JSON merged into `new OpenMultiAgent(...)` after any orchestrator fragment from the team file. |
|
||||
| `--coordinator` | No | Path to JSON passed as `runTeam(..., { coordinator })` (`CoordinatorConfig`). |
|
||||
|
||||
Global flags: [`--pretty`](#output-flags), [`--include-messages`](#output-flags).
|
||||
|
||||
### `oma task`
|
||||
|
||||
Runs **`OpenMultiAgent.runTasks(team, tasks)`** with a fixed task list (no coordinator decomposition).
|
||||
|
||||
| Argument | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `--file` | Yes | Path to [tasks file](#tasks-file). |
|
||||
| `--team` | No | Path to JSON `TeamConfig`. When set, overrides the `team` object inside `--file`. |
|
||||
|
||||
Global flags: [`--pretty`](#output-flags), [`--include-messages`](#output-flags).
|
||||
|
||||
### `oma provider`
|
||||
|
||||
Read-only helper for wiring JSON configs and env vars.
|
||||
|
||||
- **`oma provider`** or **`oma provider list`** — Prints JSON: built-in provider ids, API key environment variable names, whether `baseURL` is supported, and short notes (e.g. OpenAI-compatible servers, Copilot in CI).
|
||||
- **`oma provider template <provider>`** — Prints a JSON object with example `orchestrator` and `agent` fields plus placeholder `env` entries. `<provider>` is one of: `anthropic`, `openai`, `gemini`, `grok`, `copilot`.
|
||||
|
||||
Supports `--pretty`.
|
||||
|
||||
### `oma`, `oma help`, `oma -h`, `oma --help`
|
||||
|
||||
Prints usage text to stdout and exits **0**.
|
||||
|
||||
---
|
||||
|
||||
## Configuration files
|
||||
|
||||
Shapes match the library types `TeamConfig`, `OrchestratorConfig`, `CoordinatorConfig`, and the task objects accepted by `runTasks()`.
|
||||
|
||||
### Team file
|
||||
|
||||
Used with **`oma run --team`** (and optionally **`oma task --team`**).
|
||||
|
||||
**Option A — plain `TeamConfig`**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "api-team",
|
||||
"agents": [
|
||||
{
|
||||
"name": "architect",
|
||||
"model": "claude-sonnet-4-6",
|
||||
"provider": "anthropic",
|
||||
"systemPrompt": "You design APIs.",
|
||||
"tools": ["file_read", "file_write"],
|
||||
"maxTurns": 6
|
||||
}
|
||||
],
|
||||
"sharedMemory": true
|
||||
}
|
||||
```
|
||||
|
||||
**Option B — team plus default orchestrator settings**
|
||||
|
||||
```json
|
||||
{
|
||||
"team": {
|
||||
"name": "api-team",
|
||||
"agents": [{ "name": "worker", "model": "claude-sonnet-4-6", "systemPrompt": "…" }]
|
||||
},
|
||||
"orchestrator": {
|
||||
"defaultModel": "claude-sonnet-4-6",
|
||||
"defaultProvider": "anthropic",
|
||||
"maxConcurrency": 3
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Validation rules enforced by the CLI:
|
||||
|
||||
- Root (or `team`) must be an object.
|
||||
- `team.name` is a non-empty string.
|
||||
- `team.agents` is a non-empty array; each agent must have non-empty `name` and `model`.
|
||||
|
||||
Any other fields are passed through to the library as in TypeScript.
|
||||
|
||||
### Tasks file
|
||||
|
||||
Used with **`oma task --file`**.
|
||||
|
||||
```json
|
||||
{
|
||||
"orchestrator": {
|
||||
"defaultModel": "claude-sonnet-4-6"
|
||||
},
|
||||
"team": {
|
||||
"name": "pipeline",
|
||||
"agents": [
|
||||
{ "name": "designer", "model": "claude-sonnet-4-6", "systemPrompt": "…" },
|
||||
{ "name": "builder", "model": "claude-sonnet-4-6", "systemPrompt": "…" }
|
||||
],
|
||||
"sharedMemory": true
|
||||
},
|
||||
"tasks": [
|
||||
{
|
||||
"title": "Design",
|
||||
"description": "Produce a short spec for the feature.",
|
||||
"assignee": "designer"
|
||||
},
|
||||
{
|
||||
"title": "Implement",
|
||||
"description": "Implement from the design.",
|
||||
"assignee": "builder",
|
||||
"dependsOn": ["Design"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- **`dependsOn`** — Task titles (not internal ids), same convention as the coordinator output in the library.
|
||||
- Optional per-task fields: `memoryScope` (`"dependencies"` \| `"all"`), `maxRetries`, `retryDelayMs`, `retryBackoff`.
|
||||
- **`tasks`** must be a non-empty array; each item needs string `title` and `description`.
|
||||
|
||||
If **`--team path.json`** is passed, the file’s top-level `team` property is ignored and the external file is used instead (useful when the same team definition is shared across several pipeline files).
|
||||
|
||||
### Orchestrator and coordinator JSON
|
||||
|
||||
These files are arbitrary JSON objects merged into **`OrchestratorConfig`** and **`CoordinatorConfig`**. Function-valued options (`onProgress`, `onApproval`, etc.) cannot appear in JSON and are not supported by the CLI.
|
||||
|
||||
---
|
||||
|
||||
## Output
|
||||
|
||||
### Stdout
|
||||
|
||||
Every invocation prints **one JSON document** to stdout, followed by a newline.
|
||||
|
||||
**Successful `run` / `task`**
|
||||
|
||||
```json
|
||||
{
|
||||
"command": "run",
|
||||
"success": true,
|
||||
"totalTokenUsage": { "input_tokens": 0, "output_tokens": 0 },
|
||||
"agentResults": {
|
||||
"architect": {
|
||||
"success": true,
|
||||
"output": "…",
|
||||
"tokenUsage": { "input_tokens": 0, "output_tokens": 0 },
|
||||
"toolCalls": [],
|
||||
"structured": null,
|
||||
"loopDetected": false,
|
||||
"budgetExceeded": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`agentResults` keys are agent names. When an agent ran multiple tasks, the library merges results; the CLI mirrors the merged `AgentRunResult` fields.
|
||||
|
||||
**Errors (usage, validation, I/O, runtime)**
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"kind": "usage",
|
||||
"message": "--goal and --team are required"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`kind` is one of: `usage`, `validation`, `io`, `runtime`, or `internal` (uncaught errors in the outer handler).
|
||||
|
||||
### Output flags
|
||||
|
||||
| Flag | Effect |
|
||||
|------|--------|
|
||||
| `--pretty` | Pretty-print JSON with indentation. |
|
||||
| `--include-messages` | Include each agent’s full `messages` array in `agentResults`. **Very large** for long runs; default is omit. |
|
||||
|
||||
There is no separate progress stream; for rich telemetry use the TypeScript API with `onProgress` / `onTrace`.
|
||||
|
||||
---
|
||||
|
||||
## Exit codes
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| **0** | Success: `run`/`task` finished with `success === true`, or help / `provider` completed normally. |
|
||||
| **1** | Run finished but **`success === false`** (agent or task failure as reported by the library). |
|
||||
| **2** | Usage, validation, readable JSON errors, or file access issues (e.g. missing file). |
|
||||
| **3** | Unexpected error, including typical LLM/API failures surfaced as thrown errors. |
|
||||
|
||||
In scripts:
|
||||
|
||||
```bash
|
||||
npx oma run --goal "Summarize README" --team team.json > result.json
|
||||
code=$?
|
||||
case $code in
|
||||
0) echo "OK" ;;
|
||||
1) echo "Run reported failure — inspect result.json" ;;
|
||||
2) echo "Bad inputs or files" ;;
|
||||
3) echo "Crash or API error" ;;
|
||||
esac
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Argument parsing
|
||||
|
||||
- Long options only: `--goal`, `--team`, `--file`, etc.
|
||||
- Values may be attached with `=`: `--team=./team.json`.
|
||||
- Boolean-style flags (`--pretty`, `--include-messages`) take no value; if the next token does not start with `--`, it is treated as the value of the previous option (standard `getopt`-style pairing).
|
||||
|
||||
---
|
||||
|
||||
## Limitations (by design)
|
||||
|
||||
- No TTY session, history, or `stdin` goal input.
|
||||
- No built-in **`cwd`** or metadata passed into `ToolUseContext` (tools use process cwd unless the library adds other hooks later).
|
||||
- No **`onApproval`** from JSON; non-interactive batch only.
|
||||
- Coordinator **`runTeam`** path still requires network and API keys like any other run.
|
||||
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<string>
|
||||
kv: Map<string, string>
|
||||
} {
|
||||
const _ = argv.slice(2)
|
||||
const flags = new Set<string>()
|
||||
const kv = new Map<string, string>()
|
||||
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<string, string>, flags: Set<string>, 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<string, unknown> {
|
||||
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<string, unknown> {
|
||||
const base: Record<string, unknown> = {
|
||||
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<string, unknown> {
|
||||
const agentResults: Record<string, unknown> = {}
|
||||
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 <text> --team <team.json> [--orchestrator <orch.json>] [--coordinator <coord.json>]',
|
||||
' oma task --file <tasks.json> [--team <team.json>]',
|
||||
' oma provider [list | template <provider>]',
|
||||
'',
|
||||
'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<SupportedProvider, string> = {
|
||||
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<number> {
|
||||
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, `<set ${k} in environment>`])),
|
||||
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<number> {
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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<string, unknown>)['alice']).toMatchObject({
|
||||
success: true,
|
||||
output: 'ok',
|
||||
})
|
||||
expect((json.agentResults as Record<string, unknown>)['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<string, unknown>)['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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue