diff --git a/README.md b/README.md index eac9afa..29c19c9 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,10 @@ agent me # show the current credential's identity agent run start --config # 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 --watch # start and immediately stream it agent run list --limit 20 # list recent runs (filter by --source, --days, …) -agent run get # inspect one run +agent run get # inspect one run (prints a dashboard link) agent run get --watch # follow a run until it finishes agent config list # list saved agent configs @@ -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 diff --git a/src/commands/config.ts b/src/commands/config.ts index 3b029cd..6a74f69 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -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' @@ -43,12 +45,17 @@ export function registerConfig(program: Command): void { .option('-o, --output ', '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)}`) }) }) diff --git a/src/commands/run.tsx b/src/commands/run.tsx index 7d170fc..457a5ab 100644 --- a/src/commands/run.tsx +++ b/src/commands/run.tsx @@ -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, @@ -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 = new Set([ @@ -38,6 +39,10 @@ export function registerRun(program: Command): void { .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( + '-t, --template ', + 'start from a maintained run template (e.g. welcome-to-ellipsis)', + ) .option('-b, --budget ', 'per-run budget override in USD', parseFloat) .option( '-m, --metadata ', @@ -46,22 +51,28 @@ export function registerRun(program: Command): void { {} as Record, ) .option('-s, --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 source: string + watch?: boolean json?: boolean }) => { await runAction(async () => { - if (!opts.config && !opts.configFile) { - throw new Error('provide --config or --config-file ') + // 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 , --config-file , or --template ') } - 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, @@ -69,14 +80,27 @@ export function registerRun(program: Command): void { } 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`) }) }, @@ -138,21 +162,24 @@ export function registerRun(program: Command): void { run .command('get ') .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)}`) }) }) @@ -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 { + const me = await api.whoami() + console.log(` ${runUrl(resolveAppBase(), me.customer_login, runId)}`) +} + function readJsonFile(path: string): Record { try { return JSON.parse(readFileSync(path, 'utf8')) as Record diff --git a/src/lib/config.ts b/src/lib/config.ts index 3d1e680..076eadf 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -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) { diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 4fb7e4a..8eade0d 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -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' diff --git a/src/lib/types.ts b/src/lib/types.ts index 9bcfcde..d3eb042 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -119,6 +119,7 @@ export type AgentConfig = Record export interface StartAgentRunRequest { config_id?: string config?: AgentConfig + template_id?: string source?: AgentRunSource metadata?: Record budget_usd?: number diff --git a/src/lib/urls.ts b/src/lib/urls.ts new file mode 100644 index 0000000..ae8f92a --- /dev/null +++ b/src/lib/urls.ts @@ -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)}` +} diff --git a/test/config.test.ts b/test/config.test.ts index d3c807e..236020d 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -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. @@ -17,6 +17,7 @@ const ENV_KEYS = [ 'ELLIPSIS_API_TOKEN', 'ELLIPSIS_API_BASE_URL', 'ELLIPSIS_API_BASE', + 'ELLIPSIS_APP_BASE', ] as const beforeEach(() => { @@ -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') + }) +}) diff --git a/test/urls.test.ts b/test/urls.test.ts new file mode 100644 index 0000000..74c69c0 --- /dev/null +++ b/test/urls.test.ts @@ -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', + ) + }) +})