Skip to content

Commit abe6895

Browse files
hbrooksclaude
andauthored
feat: run templates, watch-on-start, and dashboard links (#8)
* feat: run templates, watch-on-start, and dashboard links Wire the backend's new `template_id` and add clickable dashboard links. - `run start`: add `--template <slug>` (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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * chore: set --watch fallback poll interval to 2s Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8fd446b commit abe6895

9 files changed

Lines changed: 147 additions & 24 deletions

File tree

README.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,10 @@ agent me # show the current credential's identity
2323

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

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

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

5255
### Auth
5356

src/commands/config.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ import { InvalidArgumentError, type Command } from 'commander'
22
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
33
import { basename, dirname, extname } from 'node:path'
44
import { ApiClient } from '../lib/api'
5+
import { resolveAppBase } from '../lib/config'
56
import { formatTs, printJson, printTable, printYaml, runAction } from '../lib/output'
7+
import { configUrl } from '../lib/urls'
68
import type { SavedAgentConfig } from '../lib/types'
79

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

src/commands/run.tsx

Lines changed: 47 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import type { Command } from 'commander'
22
import { readFileSync } from 'node:fs'
33
import { ApiClient } from '../lib/api'
4-
import { requireToken, resolveApiBase } from '../lib/config'
4+
import { requireToken, resolveApiBase, resolveAppBase } from '../lib/config'
55
import { formatTs, printJson, printTable, runAction, usdFromMillicents } from '../lib/output'
66
import { collect, collectKeyValue, toInt } from '../lib/args'
7+
import { runUrl } from '../lib/urls'
78
import {
89
resolveWsBase,
910
streamRun,
@@ -18,9 +19,9 @@ import type {
1819
StartAgentRunRequest,
1920
} from '../lib/types'
2021

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

2526
// Statuses past which a run no longer changes — `--watch` stops here.
2627
const TERMINAL_STATUSES: ReadonlySet<AgentRunStatus> = new Set<AgentRunStatus>([
@@ -38,6 +39,10 @@ export function registerRun(program: Command): void {
3839
.description('Start a new agent run (POST /v1/agents/runs)')
3940
.option('-c, --config <id>', 'start from a saved agent config id')
4041
.option('-f, --config-file <path>', 'start from an inline agent config (JSON file)')
42+
.option(
43+
'-t, --template <slug>',
44+
'start from a maintained run template (e.g. welcome-to-ellipsis)',
45+
)
4146
.option('-b, --budget <usd>', 'per-run budget override in USD', parseFloat)
4247
.option(
4348
'-m, --metadata <key=value>',
@@ -46,37 +51,56 @@ export function registerRun(program: Command): void {
4651
{} as Record<string, string>,
4752
)
4853
.option('-s, --source <source>', 'run source', 'cli')
54+
.option('-w, --watch', 'stream the run live until it reaches a terminal status')
4955
.option('--json', 'output raw JSON')
5056
.action(
5157
async (opts: {
5258
config?: string
5359
configFile?: string
60+
template?: string
5461
budget?: number
5562
metadata: Record<string, string>
5663
source: string
64+
watch?: boolean
5765
json?: boolean
5866
}) => {
5967
await runAction(async () => {
60-
if (!opts.config && !opts.configFile) {
61-
throw new Error('provide --config <id> or --config-file <path>')
68+
// The server enforces "exactly one of config / config_id / template_id";
69+
// pre-check locally for a clearer error than a bare 400.
70+
const sources = [opts.config, opts.configFile, opts.template].filter(Boolean)
71+
if (sources.length === 0) {
72+
throw new Error('provide one of --config <id>, --config-file <path>, or --template <slug>')
6273
}
63-
if (opts.config && opts.configFile) {
64-
throw new Error('provide only one of --config / --config-file')
74+
if (sources.length > 1) {
75+
throw new Error('provide only one of --config / --config-file / --template')
6576
}
6677
const req: StartAgentRunRequest = {
6778
source: opts.source as AgentRunSource,
6879
metadata: opts.metadata,
6980
}
7081
if (opts.config) req.config_id = opts.config
7182
if (opts.configFile) req.config = readJsonFile(opts.configFile)
83+
if (opts.template) req.template_id = opts.template
7284
if (opts.budget !== undefined) req.budget_usd = opts.budget
7385

74-
const run = await new ApiClient().startAgentRun(req)
86+
const api = new ApiClient()
87+
const run = await api.startAgentRun(req)
88+
89+
if (opts.watch) {
90+
if (!opts.json) {
91+
console.log(`✓ started run ${run.id}`)
92+
await printRunUrl(api, run.id)
93+
}
94+
await watchRunStreaming(api, run.id, FALLBACK_POLL_INTERVAL_SECONDS, opts.json)
95+
return
96+
}
97+
7598
if (opts.json) {
7699
printJson(run)
77100
return
78101
}
79102
console.log(`✓ started run ${run.id} (${run.status})`)
103+
await printRunUrl(api, run.id)
80104
console.log(` follow with: agent run get ${run.id} --watch`)
81105
})
82106
},
@@ -138,21 +162,24 @@ export function registerRun(program: Command): void {
138162
run
139163
.command('get <runId>')
140164
.description('Get a single agent run (GET /v1/agents/runs/{id})')
141-
.option('-w, --watch', 'poll until the run reaches a terminal status')
165+
.option('-w, --watch', 'stream the run live until it reaches a terminal status')
142166
.option('--json', 'output raw JSON')
143167
.action(async (runId: string, opts: { watch?: boolean; json?: boolean }) => {
144168
await runAction(async () => {
145169
const api = new ApiClient()
146170
if (opts.watch) {
147-
await watchRunStreaming(api, runId, WATCH_POLL_INTERVAL_SECONDS, opts.json)
171+
if (!opts.json) await printRunUrl(api, runId)
172+
await watchRunStreaming(api, runId, FALLBACK_POLL_INTERVAL_SECONDS, opts.json)
148173
return
149174
}
150-
const r = await api.getAgentRun(runId)
151175
if (opts.json) {
152-
printJson(r)
176+
printJson(await api.getAgentRun(runId))
153177
return
154178
}
179+
// Fetch the run and the login (for the link) together — no added latency.
180+
const [r, me] = await Promise.all([api.getAgentRun(runId), api.whoami()])
155181
printRunSummary(r)
182+
console.log(`url: ${runUrl(resolveAppBase(), me.customer_login, runId)}`)
156183
})
157184
})
158185

@@ -307,6 +334,13 @@ function printRunSummary(r: AgentRun): void {
307334
}
308335
}
309336

337+
// Print a clickable dashboard link for a run. The route is scoped by account
338+
// login, which isn't on the run object, so resolve it from /v1/me.
339+
async function printRunUrl(api: ApiClient, runId: string): Promise<void> {
340+
const me = await api.whoami()
341+
console.log(` ${runUrl(resolveAppBase(), me.customer_login, runId)}`)
342+
}
343+
310344
function readJsonFile(path: string): Record<string, unknown> {
311345
try {
312346
return JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>

src/lib/config.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,19 @@ export function resolveApiBase(explicit?: string): string {
5656
return explicit ?? envApiBase() ?? loadConfig().apiBase ?? DEFAULT_API_BASE
5757
}
5858

59+
// Resolve the dashboard (web app) base URL for building clickable links.
60+
// ELLIPSIS_APP_BASE wins; otherwise derive it from the API base by swapping the
61+
// `api` host label for `app` (api.ellipsis.dev -> app.ellipsis.dev,
62+
// beta-api.ellipsis.dev -> beta-app.ellipsis.dev), so a beta API base yields a
63+
// beta dashboard link. An unrecognized base (e.g. a custom host without `api`)
64+
// is returned unchanged — set ELLIPSIS_APP_BASE for those.
65+
export function resolveAppBase(apiBase?: string): string {
66+
const explicit = process.env.ELLIPSIS_APP_BASE
67+
if (explicit) return explicit.replace(/\/+$/, '')
68+
const base = (apiBase ?? resolveApiBase()).replace(/\/+$/, '')
69+
return base.replace('://api.', '://app.').replace('-api.', '-app.')
70+
}
71+
5972
export function requireToken(): string {
6073
const token = resolveToken()
6174
if (!token) {

src/lib/constants.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,3 @@ export const DEFAULT_API_BASE = 'https://api.ellipsis.dev'
66
// The bare default. Env (ELLIPSIS_WS_BASE) and derivation from the API base are
77
// layered in resolveWsBase() (ws.ts).
88
export const DEFAULT_WS_BASE = 'wss://api.ellipsis.dev'
9-
export const APP_BASE = process.env.ELLIPSIS_APP_BASE ?? 'https://app.ellipsis.dev'

src/lib/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ export type AgentConfig = Record<string, unknown>
119119
export interface StartAgentRunRequest {
120120
config_id?: string
121121
config?: AgentConfig
122+
template_id?: string
122123
source?: AgentRunSource
123124
metadata?: Record<string, string>
124125
budget_usd?: number

src/lib/urls.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// Builders for clickable dashboard (web app) links. Pure string functions so
2+
// they're unit-testable; callers pass the resolved app base (resolveAppBase)
3+
// and the customer's account login (from GET /v1/me — the routes are scoped by
4+
// login). Mirrors the backend's link format in github_brand.py.
5+
6+
export function runUrl(appBase: string, accountLogin: string, runId: string): string {
7+
return `${appBase}/${encodeURIComponent(accountLogin)}/agents/runs/${encodeURIComponent(runId)}`
8+
}
9+
10+
// The agent (config) detail page is keyed by the config id (agent_id == config_id).
11+
export function configUrl(appBase: string, accountLogin: string, configId: string): string {
12+
return `${appBase}/${encodeURIComponent(accountLogin)}/agents/${encodeURIComponent(configId)}`
13+
}

test/config.test.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
33
import { tmpdir } from 'node:os'
44
import { join } from 'node:path'
55
import { DEFAULT_API_BASE } from '../src/lib/constants'
6-
import { resolveApiBase, resolveToken, requireToken } from '../src/lib/config'
6+
import { resolveApiBase, resolveAppBase, resolveToken, requireToken } from '../src/lib/config'
77

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

2223
beforeEach(() => {
@@ -104,3 +105,31 @@ describe('resolveApiBase precedence', () => {
104105
expect(resolveApiBase()).toBe(DEFAULT_API_BASE)
105106
})
106107
})
108+
109+
describe('resolveAppBase', () => {
110+
it('derives the prod app base from the prod api base', () => {
111+
expect(resolveAppBase('https://api.ellipsis.dev')).toBe('https://app.ellipsis.dev')
112+
})
113+
114+
it('derives the beta app base from the beta api base', () => {
115+
expect(resolveAppBase('https://beta-api.ellipsis.dev')).toBe('https://beta-app.ellipsis.dev')
116+
})
117+
118+
it('strips a trailing slash', () => {
119+
expect(resolveAppBase('https://api.ellipsis.dev/')).toBe('https://app.ellipsis.dev')
120+
})
121+
122+
it('ELLIPSIS_APP_BASE overrides derivation', () => {
123+
process.env.ELLIPSIS_APP_BASE = 'http://localhost:3000/'
124+
expect(resolveAppBase('https://api.ellipsis.dev')).toBe('http://localhost:3000')
125+
})
126+
127+
it('returns an unrecognized base unchanged', () => {
128+
expect(resolveAppBase('http://localhost:5000')).toBe('http://localhost:5000')
129+
})
130+
131+
it('falls back to the resolved api base when no arg is given', () => {
132+
process.env.ELLIPSIS_API_BASE_URL = 'https://beta-api.ellipsis.dev'
133+
expect(resolveAppBase()).toBe('https://beta-app.ellipsis.dev')
134+
})
135+
})

test/urls.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { configUrl, runUrl } from '../src/lib/urls'
3+
4+
describe('runUrl', () => {
5+
it('builds the run detail path scoped by account login', () => {
6+
expect(runUrl('https://app.ellipsis.dev', 'octocat', 'run_8f2c')).toBe(
7+
'https://app.ellipsis.dev/octocat/agents/runs/run_8f2c',
8+
)
9+
})
10+
11+
it('encodes the login and run id', () => {
12+
expect(runUrl('https://app.ellipsis.dev', 'a/b', 'r d')).toBe(
13+
'https://app.ellipsis.dev/a%2Fb/agents/runs/r%20d',
14+
)
15+
})
16+
})
17+
18+
describe('configUrl', () => {
19+
it('builds the agent (config) detail path scoped by account login', () => {
20+
expect(configUrl('https://app.ellipsis.dev', 'octocat', 'cfg_123')).toBe(
21+
'https://app.ellipsis.dev/octocat/agents/cfg_123',
22+
)
23+
})
24+
})

0 commit comments

Comments
 (0)