diff --git a/package.json b/package.json index d0293d3..c46a54e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ellipsis/cli", - "version": "0.1.2", + "version": "0.1.3", "description": "Ellipsis agent CLI — drive the Ellipsis cloud from your terminal", "license": "MIT", "type": "module", diff --git a/src/commands/run.tsx b/src/commands/run.tsx index 7657037..72d416c 100644 --- a/src/commands/run.tsx +++ b/src/commands/run.tsx @@ -1,5 +1,7 @@ import type { Command } from 'commander' import { readFileSync } from 'node:fs' +import { extname } from 'node:path' +import { parse as parseYaml } from 'yaml' import { ApiClient } from '../lib/api' import { requireToken, resolveApiBase, resolveAppBase } from '../lib/config' import { formatTs, printJson, printTable, runAction, usdFromMillicents } from '../lib/output' @@ -38,7 +40,10 @@ export function registerRun(program: Command): void { .command('start') .description('Start a new agent run (POST /v1/agents/runs)') .option('-c, --config ', 'start from a saved agent config id') - .option('-f, --config-file ', 'start from an inline agent config (JSON file)') + .option( + '-f, --config-file ', + 'start from an inline agent config (.yaml/.yml or .json file)', + ) .option( '-t, --template ', 'start from a maintained run template (e.g. welcome-to-ellipsis)', @@ -83,7 +88,7 @@ export function registerRun(program: Command): void { metadata: opts.metadata, } if (opts.config) req.config_id = opts.config - if (opts.configFile) req.config = readJsonFile(opts.configFile) + if (opts.configFile) req.config = readConfigFile(opts.configFile) if (opts.template) req.template_id = opts.template // A budget is expressed as a config override of limits.run (the server // merges it onto the chosen config and re-validates). @@ -349,12 +354,27 @@ async function printRunUrl(api: ApiClient, runId: string): Promise { console.log(` ${runUrl(resolveAppBase(), me.customer_login, runId)}`) } -function readJsonFile(path: string): Record { +// Parse an inline agent config from disk, choosing the parser by file +// extension: .yaml/.yml as YAML, .json as JSON. (YAML is a JSON superset, so +// unknown extensions fall back to YAML, which still accepts JSON input.) +export function readConfigFile(path: string): Record { + let text: string try { - return JSON.parse(readFileSync(path, 'utf8')) as Record + text = readFileSync(path, 'utf8') } catch (err) { throw new Error(`could not read config file ${path}: ${(err as Error).message}`) } + const ext = extname(path).toLowerCase() + try { + const parsed = ext === '.json' ? JSON.parse(text) : parseYaml(text) + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('config must be a mapping of fields') + } + return parsed as Record + } catch (err) { + const kind = ext === '.json' ? 'JSON' : 'YAML' + throw new Error(`could not parse ${kind} config file ${path}: ${(err as Error).message}`) + } } function sleep(ms: number): Promise { diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 8eade0d..e671385 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -1,4 +1,4 @@ -export const VERSION = '0.1.2' +export const VERSION = '0.1.3' // The bare default; env (ELLIPSIS_API_BASE_URL / ELLIPSIS_API_BASE) and the // config file take precedence and are layered in resolveApiBase() (config.ts). diff --git a/test/run.test.ts b/test/run.test.ts index 2753aa2..aabc217 100644 --- a/test/run.test.ts +++ b/test/run.test.ts @@ -1,5 +1,8 @@ +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { watchRun } from '../src/commands/run' +import { readConfigFile, watchRun } from '../src/commands/run' import type { ApiClient } from '../src/lib/api' import type { AgentRun, AgentRunStatus } from '../src/lib/types' @@ -65,3 +68,46 @@ describe('watchRun', () => { } }) }) + +describe('readConfigFile', () => { + const dir = mkdtempSync(join(tmpdir(), 'agent-cfg-')) + const write = (name: string, body: string): string => { + const path = join(dir, name) + writeFileSync(path, body) + return path + } + + it('parses a .yaml file', () => { + const path = write('cfg.yaml', 'name: demo\nlimits:\n run: 5\n') + expect(readConfigFile(path)).toEqual({ name: 'demo', limits: { run: 5 } }) + }) + + it('parses a .yml file', () => { + const path = write('cfg.yml', 'name: demo\n') + expect(readConfigFile(path)).toEqual({ name: 'demo' }) + }) + + it('parses a .json file', () => { + const path = write('cfg.json', '{"name":"demo","limits":{"run":5}}') + expect(readConfigFile(path)).toEqual({ name: 'demo', limits: { run: 5 } }) + }) + + it('falls back to YAML for unknown extensions (JSON is valid YAML)', () => { + const path = write('cfg.txt', '{"name":"demo"}') + expect(readConfigFile(path)).toEqual({ name: 'demo' }) + }) + + it('rejects a JSON file containing invalid JSON', () => { + const path = write('bad.json', 'name: demo') + expect(() => readConfigFile(path)).toThrow(/could not parse JSON config file/) + }) + + it('rejects a non-mapping top-level value', () => { + const path = write('list.yaml', '- a\n- b\n') + expect(() => readConfigFile(path)).toThrow(/could not parse YAML config file/) + }) + + it('errors clearly when the file is missing', () => { + expect(() => readConfigFile(join(dir, 'nope.yaml'))).toThrow(/could not read config file/) + }) +})