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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
28 changes: 24 additions & 4 deletions src/commands/run.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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 <id>', 'start from a saved agent config id')
.option('-f, --config-file <path>', 'start from an inline agent config (JSON file)')
.option(
'-f, --config-file <path>',
'start from an inline agent config (.yaml/.yml or .json file)',
)
.option(
'-t, --template <slug>',
'start from a maintained run template (e.g. welcome-to-ellipsis)',
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -349,12 +354,27 @@ async function printRunUrl(api: ApiClient, runId: string): Promise<void> {
console.log(` ${runUrl(resolveAppBase(), me.customer_login, runId)}`)
}

function readJsonFile(path: string): Record<string, unknown> {
// 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<string, unknown> {
let text: string
try {
return JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>
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<string, unknown>
} 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<void> {
Expand Down
2 changes: 1 addition & 1 deletion src/lib/constants.ts
Original file line number Diff line number Diff line change
@@ -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).
Expand Down
48 changes: 47 additions & 1 deletion test/run.test.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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/)
})
})
Loading