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
13 changes: 8 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,10 @@ agent me # show the current credential's identity

agent run start --config <id> # start a run from a saved config
agent run start --config-file f.json # ...or from an inline config
agent run start --template welcome-to-ellipsis # ...or from a maintained template
agent run start --config <id> --watch # start and immediately stream it
agent run list --limit 20 # list recent runs (filter by --source, --days, …)
agent run get <run-id> # inspect one run
agent run get <run-id> # inspect one run (prints a dashboard link)
agent run get <run-id> --watch # follow a run until it finishes

agent config list # list saved agent configs
Expand All @@ -40,10 +42,11 @@ Most commands accept `--json` to print the raw API response. The CLI talks to
the public `/v1` REST API; point it elsewhere with `ELLIPSIS_API_BASE_URL`
(or the legacy `ELLIPSIS_API_BASE`).

`agent run get --watch` polls run status until the run finishes. Token-level
output streaming over WebSocket is specified in
[`docs/RUN_STREAMING_SPEC.md`](docs/RUN_STREAMING_SPEC.md) and will slot in
behind the same `--watch` flag.
`--watch` (on both `run start` and `run get`) streams the run's output live over
WebSocket until it reaches a terminal status, falling back to periodic status
polling if the live stream is unavailable. Either way it first prints a
clickable dashboard link. The stream protocol is specified in
[`docs/RUN_STREAMING_SPEC.md`](docs/RUN_STREAMING_SPEC.md).

### Auth

Expand Down
15 changes: 11 additions & 4 deletions src/commands/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import { InvalidArgumentError, type Command } from 'commander'
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
import { basename, dirname, extname } from 'node:path'
import { ApiClient } from '../lib/api'
import { resolveAppBase } from '../lib/config'
import { formatTs, printJson, printTable, printYaml, runAction } from '../lib/output'
import { configUrl } from '../lib/urls'
import type { SavedAgentConfig } from '../lib/types'

const DEFAULT_CONFIG_PATH = 'agents/my_agent.yaml'
Expand Down Expand Up @@ -43,12 +45,17 @@ export function registerConfig(program: Command): void {
.option('-o, --output <format>', 'output format: yaml (default) or json', parseFormat, 'yaml')
.action(async (configId: string, opts: { output: 'yaml' | 'json' }) => {
await runAction(async () => {
const c = await new ApiClient().getAgentConfig(configId)
const api = new ApiClient()
// -o json is the machine-readable mode: emit only the raw config.
if (opts.output === 'json') {
printJson(c)
} else {
printYaml(c)
printJson(await api.getAgentConfig(configId))
return
}
// Fetch the config and the login (for the link) together. The link goes
// to stderr so the YAML on stdout stays clean for piping/redirecting.
const [c, me] = await Promise.all([api.getAgentConfig(configId), api.whoami()])
printYaml(c)
console.error(`\nview: ${configUrl(resolveAppBase(), me.customer_login, configId)}`)
})
})

Expand Down
60 changes: 47 additions & 13 deletions src/commands/run.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import type { Command } from 'commander'
import { readFileSync } from 'node:fs'
import { ApiClient } from '../lib/api'
import { requireToken, resolveApiBase } from '../lib/config'
import { requireToken, resolveApiBase, resolveAppBase } from '../lib/config'
import { formatTs, printJson, printTable, runAction, usdFromMillicents } from '../lib/output'
import { collect, collectKeyValue, toInt } from '../lib/args'
import { runUrl } from '../lib/urls'
import {
resolveWsBase,
streamRun,
Expand All @@ -18,9 +19,9 @@ import type {
StartAgentRunRequest,
} from '../lib/types'

// Poll interval (seconds) for the REST status-polling fallback used when live
// streaming is unavailable. Not user-configurable — streaming is the norm.
const WATCH_POLL_INTERVAL_SECONDS = 3
// Poll cadence for the `--watch` REST fallback (used only when live WebSocket
// streaming is unavailable). Not user-configurable — the fallback is rare.
const FALLBACK_POLL_INTERVAL_SECONDS = 2

// Statuses past which a run no longer changes — `--watch` stops here.
const TERMINAL_STATUSES: ReadonlySet<AgentRunStatus> = new Set<AgentRunStatus>([
Expand All @@ -38,6 +39,10 @@ export function registerRun(program: Command): void {
.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(
'-t, --template <slug>',
'start from a maintained run template (e.g. welcome-to-ellipsis)',
)
.option('-b, --budget <usd>', 'per-run budget override in USD', parseFloat)
.option(
'-m, --metadata <key=value>',
Expand All @@ -46,37 +51,56 @@ export function registerRun(program: Command): void {
{} as Record<string, string>,
)
.option('-s, --source <source>', 'run source', 'cli')
.option('-w, --watch', 'stream the run live until it reaches a terminal status')
.option('--json', 'output raw JSON')
.action(
async (opts: {
config?: string
configFile?: string
template?: string
budget?: number
metadata: Record<string, string>
source: string
watch?: boolean
json?: boolean
}) => {
await runAction(async () => {
if (!opts.config && !opts.configFile) {
throw new Error('provide --config <id> or --config-file <path>')
// The server enforces "exactly one of config / config_id / template_id";
// pre-check locally for a clearer error than a bare 400.
const sources = [opts.config, opts.configFile, opts.template].filter(Boolean)
if (sources.length === 0) {
throw new Error('provide one of --config <id>, --config-file <path>, or --template <slug>')
}
if (opts.config && opts.configFile) {
throw new Error('provide only one of --config / --config-file')
if (sources.length > 1) {
throw new Error('provide only one of --config / --config-file / --template')
}
const req: StartAgentRunRequest = {
source: opts.source as AgentRunSource,
metadata: opts.metadata,
}
if (opts.config) req.config_id = opts.config
if (opts.configFile) req.config = readJsonFile(opts.configFile)
if (opts.template) req.template_id = opts.template
if (opts.budget !== undefined) req.budget_usd = opts.budget

const run = await new ApiClient().startAgentRun(req)
const api = new ApiClient()
const run = await api.startAgentRun(req)

if (opts.watch) {
if (!opts.json) {
console.log(`✓ started run ${run.id}`)
await printRunUrl(api, run.id)
}
await watchRunStreaming(api, run.id, FALLBACK_POLL_INTERVAL_SECONDS, opts.json)
return
}

if (opts.json) {
printJson(run)
return
}
console.log(`✓ started run ${run.id} (${run.status})`)
await printRunUrl(api, run.id)
console.log(` follow with: agent run get ${run.id} --watch`)
})
},
Expand Down Expand Up @@ -138,21 +162,24 @@ export function registerRun(program: Command): void {
run
.command('get <runId>')
.description('Get a single agent run (GET /v1/agents/runs/{id})')
.option('-w, --watch', 'poll until the run reaches a terminal status')
.option('-w, --watch', 'stream the run live until it reaches a terminal status')
.option('--json', 'output raw JSON')
.action(async (runId: string, opts: { watch?: boolean; json?: boolean }) => {
await runAction(async () => {
const api = new ApiClient()
if (opts.watch) {
await watchRunStreaming(api, runId, WATCH_POLL_INTERVAL_SECONDS, opts.json)
if (!opts.json) await printRunUrl(api, runId)
await watchRunStreaming(api, runId, FALLBACK_POLL_INTERVAL_SECONDS, opts.json)
return
}
const r = await api.getAgentRun(runId)
if (opts.json) {
printJson(r)
printJson(await api.getAgentRun(runId))
return
}
// Fetch the run and the login (for the link) together — no added latency.
const [r, me] = await Promise.all([api.getAgentRun(runId), api.whoami()])
printRunSummary(r)
console.log(`url: ${runUrl(resolveAppBase(), me.customer_login, runId)}`)
})
})

Expand Down Expand Up @@ -307,6 +334,13 @@ function printRunSummary(r: AgentRun): void {
}
}

// Print a clickable dashboard link for a run. The route is scoped by account
// login, which isn't on the run object, so resolve it from /v1/me.
async function printRunUrl(api: ApiClient, runId: string): Promise<void> {
const me = await api.whoami()
console.log(` ${runUrl(resolveAppBase(), me.customer_login, runId)}`)
}

function readJsonFile(path: string): Record<string, unknown> {
try {
return JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>
Expand Down
13 changes: 13 additions & 0 deletions src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,19 @@ export function resolveApiBase(explicit?: string): string {
return explicit ?? envApiBase() ?? loadConfig().apiBase ?? DEFAULT_API_BASE
}

// Resolve the dashboard (web app) base URL for building clickable links.
// ELLIPSIS_APP_BASE wins; otherwise derive it from the API base by swapping the
// `api` host label for `app` (api.ellipsis.dev -> app.ellipsis.dev,
// beta-api.ellipsis.dev -> beta-app.ellipsis.dev), so a beta API base yields a
// beta dashboard link. An unrecognized base (e.g. a custom host without `api`)
// is returned unchanged — set ELLIPSIS_APP_BASE for those.
export function resolveAppBase(apiBase?: string): string {
const explicit = process.env.ELLIPSIS_APP_BASE
if (explicit) return explicit.replace(/\/+$/, '')
const base = (apiBase ?? resolveApiBase()).replace(/\/+$/, '')
return base.replace('://api.', '://app.').replace('-api.', '-app.')
}

export function requireToken(): string {
const token = resolveToken()
if (!token) {
Expand Down
1 change: 0 additions & 1 deletion src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,3 @@ export const DEFAULT_API_BASE = 'https://api.ellipsis.dev'
// The bare default. Env (ELLIPSIS_WS_BASE) and derivation from the API base are
// layered in resolveWsBase() (ws.ts).
export const DEFAULT_WS_BASE = 'wss://api.ellipsis.dev'
export const APP_BASE = process.env.ELLIPSIS_APP_BASE ?? 'https://app.ellipsis.dev'
1 change: 1 addition & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ export type AgentConfig = Record<string, unknown>
export interface StartAgentRunRequest {
config_id?: string
config?: AgentConfig
template_id?: string
source?: AgentRunSource
metadata?: Record<string, string>
budget_usd?: number
Expand Down
13 changes: 13 additions & 0 deletions src/lib/urls.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Builders for clickable dashboard (web app) links. Pure string functions so
// they're unit-testable; callers pass the resolved app base (resolveAppBase)
// and the customer's account login (from GET /v1/me — the routes are scoped by
// login). Mirrors the backend's link format in github_brand.py.

export function runUrl(appBase: string, accountLogin: string, runId: string): string {
return `${appBase}/${encodeURIComponent(accountLogin)}/agents/runs/${encodeURIComponent(runId)}`
}

// The agent (config) detail page is keyed by the config id (agent_id == config_id).
export function configUrl(appBase: string, accountLogin: string, configId: string): string {
return `${appBase}/${encodeURIComponent(accountLogin)}/agents/${encodeURIComponent(configId)}`
}
31 changes: 30 additions & 1 deletion test/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { DEFAULT_API_BASE } from '../src/lib/constants'
import { resolveApiBase, resolveToken, requireToken } from '../src/lib/config'
import { resolveApiBase, resolveAppBase, resolveToken, requireToken } from '../src/lib/config'

// Each test gets a throwaway ELLIPSIS_CONFIG_DIR so resolveToken/resolveApiBase
// read a known config file (or none) without touching the real ~/.config.
Expand All @@ -17,6 +17,7 @@ const ENV_KEYS = [
'ELLIPSIS_API_TOKEN',
'ELLIPSIS_API_BASE_URL',
'ELLIPSIS_API_BASE',
'ELLIPSIS_APP_BASE',
] as const

beforeEach(() => {
Expand Down Expand Up @@ -104,3 +105,31 @@ describe('resolveApiBase precedence', () => {
expect(resolveApiBase()).toBe(DEFAULT_API_BASE)
})
})

describe('resolveAppBase', () => {
it('derives the prod app base from the prod api base', () => {
expect(resolveAppBase('https://api.ellipsis.dev')).toBe('https://app.ellipsis.dev')
})

it('derives the beta app base from the beta api base', () => {
expect(resolveAppBase('https://beta-api.ellipsis.dev')).toBe('https://beta-app.ellipsis.dev')
})

it('strips a trailing slash', () => {
expect(resolveAppBase('https://api.ellipsis.dev/')).toBe('https://app.ellipsis.dev')
})

it('ELLIPSIS_APP_BASE overrides derivation', () => {
process.env.ELLIPSIS_APP_BASE = 'http://localhost:3000/'
expect(resolveAppBase('https://api.ellipsis.dev')).toBe('http://localhost:3000')
})

it('returns an unrecognized base unchanged', () => {
expect(resolveAppBase('http://localhost:5000')).toBe('http://localhost:5000')
})

it('falls back to the resolved api base when no arg is given', () => {
process.env.ELLIPSIS_API_BASE_URL = 'https://beta-api.ellipsis.dev'
expect(resolveAppBase()).toBe('https://beta-app.ellipsis.dev')
})
})
24 changes: 24 additions & 0 deletions test/urls.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { configUrl, runUrl } from '../src/lib/urls'

describe('runUrl', () => {
it('builds the run detail path scoped by account login', () => {
expect(runUrl('https://app.ellipsis.dev', 'octocat', 'run_8f2c')).toBe(
'https://app.ellipsis.dev/octocat/agents/runs/run_8f2c',
)
})

it('encodes the login and run id', () => {
expect(runUrl('https://app.ellipsis.dev', 'a/b', 'r d')).toBe(
'https://app.ellipsis.dev/a%2Fb/agents/runs/r%20d',
)
})
})

describe('configUrl', () => {
it('builds the agent (config) detail path scoped by account login', () => {
expect(configUrl('https://app.ellipsis.dev', 'octocat', 'cfg_123')).toBe(
'https://app.ellipsis.dev/octocat/agents/cfg_123',
)
})
})
Loading