From c258a0832e1be45995afda8770c4248d9ab60f85 Mon Sep 17 00:00:00 2001 From: hbrooks Date: Sat, 27 Jun 2026 10:57:12 -0400 Subject: [PATCH 1/3] feat: run templates, watch-on-start, and dashboard links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the backend's new `template_id` and add clickable dashboard links. - `run start`: add `--template ` (maintained run templates, e.g. welcome-to-ellipsis), `--watch`/`--interval` to start and immediately stream a run, and a local "exactly one of --config/--config-file/--template" check for a clearer error than the server's 400. - `run get` and `run start` now print a clickable dashboard link to the run; `config get` prints a link to the agent. Links are scoped by account login (resolved from /v1/me) and built from a new `resolveAppBase`, which derives the web app base from the API base (api -> app, beta-api -> beta-app) with an ELLIPSIS_APP_BASE override. - `config get`: the link goes to stderr so the YAML/JSON on stdout stays clean for piping; skipped entirely in `-o json` machine mode. Adds pure URL builders (urls.ts) with tests and resolveAppBase tests, and updates the README (the run-streaming section was stale — streaming already ships behind --watch). Removes the unused APP_BASE constant. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 13 +++++++---- src/commands/config.ts | 15 ++++++++---- src/commands/run.tsx | 52 +++++++++++++++++++++++++++++++++++------- src/lib/config.ts | 13 +++++++++++ src/lib/constants.ts | 1 - src/lib/types.ts | 1 + src/lib/urls.ts | 13 +++++++++++ test/config.test.ts | 31 ++++++++++++++++++++++++- test/urls.test.ts | 24 +++++++++++++++++++ 9 files changed, 144 insertions(+), 19 deletions(-) create mode 100644 src/lib/urls.ts create mode 100644 test/urls.test.ts diff --git a/README.md b/README.md index eac9afa..054b82b 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 status polling +every `--interval` seconds when streaming 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 5febae9..f5faf5d 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 1198869..b7626df 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, @@ -34,6 +35,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 ', @@ -42,22 +47,30 @@ 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('-i, --interval ', 'poll interval for the --watch fallback', toInt, 3) .option('--json', 'output raw JSON') .action( async (opts: { config?: string configFile?: string + template?: string budget?: number metadata: Record source: string + watch?: boolean + interval: number 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, @@ -65,14 +78,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, opts.interval, 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`) }) }, @@ -141,15 +167,18 @@ export function registerRun(program: Command): void { await runAction(async () => { const api = new ApiClient() if (opts.watch) { + if (!opts.json) await printRunUrl(api, runId) await watchRunStreaming(api, runId, opts.interval, 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)}`) }) }) @@ -304,6 +333,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 9586633..ccfd53c 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', + ) + }) +}) From 69b6c41e4173d9009cd337e328b6c4027860cf05 Mon Sep 17 00:00:00 2001 From: hbrooks Date: Sat, 27 Jun 2026 11:03:31 -0400 Subject: [PATCH 2/3] refactor: drop user-facing --watch poll interval The poll cadence only applies to the rare REST fallback when live WebSocket streaming is unavailable, so it isn't worth a flag. Remove `--interval` from `run start`/`run get` and hardcode the fallback to 3s (FALLBACK_POLL_INTERVAL_SECONDS). The internal watchRun(intervalSeconds) param stays for testability. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 6 +++--- src/commands/run.tsx | 15 ++++++++------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 054b82b..29c19c9 100644 --- a/README.md +++ b/README.md @@ -43,9 +43,9 @@ the public `/v1` REST API; point it elsewhere with `ELLIPSIS_API_BASE_URL` (or the legacy `ELLIPSIS_API_BASE`). `--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 status polling -every `--interval` seconds when streaming is unavailable. Either way it first -prints a clickable dashboard link. The stream protocol is specified in +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/run.tsx b/src/commands/run.tsx index b7626df..fda1fea 100644 --- a/src/commands/run.tsx +++ b/src/commands/run.tsx @@ -19,6 +19,10 @@ import type { StartAgentRunRequest, } from '../lib/types' +// 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 = 3 + // Statuses past which a run no longer changes — `--watch` stops here. const TERMINAL_STATUSES: ReadonlySet = new Set([ 'completed', @@ -48,7 +52,6 @@ export function registerRun(program: Command): void { ) .option('-s, --source ', 'run source', 'cli') .option('-w, --watch', 'stream the run live until it reaches a terminal status') - .option('-i, --interval ', 'poll interval for the --watch fallback', toInt, 3) .option('--json', 'output raw JSON') .action( async (opts: { @@ -59,7 +62,6 @@ export function registerRun(program: Command): void { metadata: Record source: string watch?: boolean - interval: number json?: boolean }) => { await runAction(async () => { @@ -89,7 +91,7 @@ export function registerRun(program: Command): void { console.log(`✓ started run ${run.id}`) await printRunUrl(api, run.id) } - await watchRunStreaming(api, run.id, opts.interval, opts.json) + await watchRunStreaming(api, run.id, FALLBACK_POLL_INTERVAL_SECONDS, opts.json) return } @@ -160,15 +162,14 @@ 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('-i, --interval ', 'poll interval for --watch', toInt, 3) + .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; interval: number; json?: boolean }) => { + .action(async (runId: string, opts: { watch?: boolean; json?: boolean }) => { await runAction(async () => { const api = new ApiClient() if (opts.watch) { if (!opts.json) await printRunUrl(api, runId) - await watchRunStreaming(api, runId, opts.interval, opts.json) + await watchRunStreaming(api, runId, FALLBACK_POLL_INTERVAL_SECONDS, opts.json) return } if (opts.json) { From 246c54e4cfd29002dc3267f39b957025f1b74656 Mon Sep 17 00:00:00 2001 From: hbrooks Date: Sat, 27 Jun 2026 11:05:40 -0400 Subject: [PATCH 3/3] chore: set --watch fallback poll interval to 2s Co-Authored-By: Claude Opus 4.8 (1M context) --- src/commands/run.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/run.tsx b/src/commands/run.tsx index fda1fea..457a5ab 100644 --- a/src/commands/run.tsx +++ b/src/commands/run.tsx @@ -21,7 +21,7 @@ import type { // 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 = 3 +const FALLBACK_POLL_INTERVAL_SECONDS = 2 // Statuses past which a run no longer changes — `--watch` stops here. const TERMINAL_STATUSES: ReadonlySet = new Set([