Skip to content

Commit 617b605

Browse files
hbrooksclaude
andauthored
CLI polish: v1 ping, YAML config get, table output, run --watch, config init (#3)
- ping: hit /v1/me (no health endpoint exists); distinguish unreachable from unauthenticated instead of a bare 404. - config get: render as YAML by default; `-o/--output json` for JSON. - config list / run list: aligned column tables. config list shows the GitHub source (path@branch) and last editor and flags sync errors; run list shows status, source, created, and cost. - run: replace the non-functional `run view` (WS streaming is deferred server-side) with `run get --watch`, which polls run status to a terminal state. Spec for real token-level streaming added at docs/RUN_STREAMING_SPEC.md. - config init [path]: scaffold a minimal valid agent config YAML (default agents/my_agent.yaml); verified it parses with the backend config parser. - CI: add a `bun build --compile` smoke step so the release's single-binary build can't regress the way v0.1.0's first tag did. - Bump to 0.1.1. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6234032 commit 617b605

11 files changed

Lines changed: 425 additions & 60 deletions

File tree

.github/workflows/ci.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,8 @@ jobs:
1717
- run: bun run typecheck
1818
- run: bun run test
1919
- run: bun run build
20+
# Compile-smoke: the release Bun-compiles a single binary, which bundles
21+
# everything (unlike tsup) and can fail where `build` passes. Catch that
22+
# here so a release tag never breaks on it.
23+
- run: bun build src/cli.tsx --compile --outfile /tmp/agent
24+
- run: /tmp/agent --version

README.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,19 +25,25 @@ 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
2626
agent run list --limit 20 # list recent runs (filter by --source, --days, …)
2727
agent run get <run-id> # inspect one run
28-
agent run view # attach to the latest run and stream output
28+
agent run get <run-id> --watch # follow a run until it finishes
2929

3030
agent config list # list saved agent configs
31-
agent config get <config-id> # inspect one config
31+
agent config get <config-id> # show one config as YAML (-o json for JSON)
32+
agent config init [path] # scaffold a starter config (default: agents/my_agent.yaml)
3233

3334
agent budget # current budget summary
3435
agent usage # usage dashboard for the period
35-
agent ping # check API connectivity
36+
agent ping # check authenticated /v1 connectivity
3637
```
3738

3839
Most commands accept `--json` to print the raw API response. The CLI talks to
3940
the public `/v1` REST API; point it elsewhere with `ELLIPSIS_API_BASE`.
4041

42+
`agent run get --watch` polls run status until the run finishes. Token-level
43+
output streaming over WebSocket is specified in
44+
[`docs/RUN_STREAMING_SPEC.md`](docs/RUN_STREAMING_SPEC.md) and will slot in
45+
behind the same `--watch` flag.
46+
4147
### Auth
4248

4349
`agent login` uses the device-code flow: it requests a code pair, prints a

docs/RUN_STREAMING_SPEC.md

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# Requirements: live run streaming for `agent run get --watch`
2+
3+
**Status:** proposed — not yet implemented.
4+
**Audience:** the engineer/agent implementing WebSocket streaming, server + client.
5+
6+
## 1. Background
7+
8+
The CLI can start runs and read their state over the public `/v1` REST API, but
9+
it cannot stream a run's output. `agent run get --watch` exists today and gives a
10+
**status-level** live view by polling `GET /v1/agents/runs/{id}` until the run
11+
reaches a terminal status (`completed`/`error`/`cancelled`/`stopped`). It shows
12+
status transitions and the final summary — not the step-by-step stdout/stderr or
13+
token stream.
14+
15+
Token-level streaming over `/v1` is explicitly deferred in the backend design
16+
doc (`documents/eng/ELLIPSIS_API_AND_CLI.md` §7, "Deferred → Streaming run steps
17+
through `/v1` for CLI clients"). This spec defines the work to close that gap.
18+
19+
Scaffolding already in this repo (currently unused, kept as a starting point):
20+
- `src/lib/ws.ts` — a `streamRun()` WebSocket client and a `StreamFrame` type
21+
(`stdout`/`stderr`/`status`/`done`/`error`). It connects to
22+
`${wsBase}/v1/runs/{id}/stream` with a bearer token. No reconnect/resume/heartbeat.
23+
- `src/ui/RunView.tsx` — an Ink component that renders frames from `streamRun()`.
24+
- `DEFAULT_WS_BASE` in `src/lib/constants.ts` (`wss://api.ellipsis.dev`).
25+
26+
The reported `error: [object ErrorEvent]` came from this scaffolding connecting to
27+
a non-existent server endpoint; `ws.ts` stringifies the raw `ErrorEvent`. Fix the
28+
error rendering as part of this work (surface `err.message`).
29+
30+
## 2. Goal
31+
32+
`agent run get <id> --watch` streams a run's output live, in real time, and
33+
falls back to REST polling when streaming is unavailable. The same flag covers
34+
both modes — no new top-level command.
35+
36+
## 3. Server-side requirements (`/v1`)
37+
38+
1. **Endpoint:** `GET /v1/runs/{run_id}/stream`, upgraded to WebSocket.
39+
2. **Auth:** `Authorization: Bearer <token>`, resolved by the same `V1Auth`
40+
path as the REST API (user/API/sandbox tokens), authorizing the run's
41+
customer. Reject with a close code on auth failure (see §5).
42+
3. **Frame protocol (server → client), one JSON object per WS message:**
43+
- `{ "type": "status", "status": "<AgentRunStatus>", "ts": "<iso8601>" }`
44+
- `{ "type": "stdout", "data": "<chunk>", "seq": <int>, "ts": "<iso8601>" }`
45+
- `{ "type": "stderr", "data": "<chunk>", "seq": <int>, "ts": "<iso8601>" }`
46+
- `{ "type": "done", "status": "<terminal status>", "exit_status": "<...>" }`
47+
- `{ "type": "error", "message": "<human-readable>" }`
48+
- `seq` is a monotonic per-run cursor used for resume.
49+
4. **Backfill + resume:** accept `?after_seq=<int>` (query or first client
50+
message). On connect, replay buffered frames with `seq > after_seq`, then
51+
stream live. This makes reconnects lossless.
52+
5. **Heartbeat:** server sends WS ping (or a `status` keepalive) at a fixed
53+
interval (e.g. 20s) so dead connections are detectable.
54+
6. **Termination:** send a final `done` frame, then close with a normal code.
55+
For an already-terminal run, replay buffered output then `done` immediately.
56+
7. **Retention:** define how long run output is buffered for backfill (at least
57+
the run's lifetime + a grace window). Document the limit.
58+
59+
## 4. Client-side requirements (this repo)
60+
61+
1. `agent run get <id> --watch` connects to the stream and renders frames:
62+
`stdout`/`stderr` as output, `status` as transition lines, `done`/`error`
63+
to finish. Exit 0 on `done` with a successful terminal status, non-zero on
64+
`error` or a failed terminal status.
65+
2. **Reconnect with backoff** and resume from the last seen `seq` via
66+
`after_seq`, so a dropped socket doesn't lose or duplicate output.
67+
3. **Fallback:** if the WebSocket can't connect (e.g. server without streaming,
68+
or a `1003`/unsupported close), fall back to the existing REST polling
69+
`watchRun()` automatically, with a one-line notice. `--watch` must keep
70+
working against a backend that lacks the endpoint.
71+
4. **Heartbeat:** respond to/expect pings; treat a missed heartbeat as a dropped
72+
connection and reconnect.
73+
5. `--json` with `--watch`: emit one JSON object per frame (NDJSON) for piping.
74+
6. Fix `ws.ts` error handling to surface a readable message, not
75+
`[object ErrorEvent]`.
76+
77+
## 5. WebSocket close codes (suggested)
78+
79+
| Code | Meaning |
80+
|------|---------|
81+
| 1000 | normal — run reached a terminal state |
82+
| 1008 | auth failed / not authorized for this run |
83+
| 1003 | streaming unsupported (client should fall back to polling) |
84+
| 1011 | server error |
85+
86+
## 6. Acceptance criteria
87+
88+
- Streaming a live run shows stdout/stderr in near real time end to end.
89+
- Killing the socket mid-run and reconnecting resumes with no lost or duplicated
90+
frames (verified via `seq`/`after_seq`).
91+
- `--watch` against a backend without the endpoint transparently falls back to
92+
REST polling and still completes.
93+
- `--json --watch` emits valid NDJSON, one frame per line.
94+
- Unit tests for the client frame handler, reconnect/resume cursor, and fallback
95+
trigger (mirror the fake-timer style in `test/auth.test.ts` / `test/run.test.ts`).
96+
- No `[object ErrorEvent]`; connection errors print a real message.
97+
98+
## 7. Out of scope
99+
100+
- Bidirectional control over the stream (stop/input). `run stop` is tracked
101+
separately and also has no `/v1` endpoint yet.
102+
- Multiplexing multiple runs over one socket.

package-lock.json

Lines changed: 17 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@ellipsis/cli",
3-
"version": "0.1.0",
3+
"version": "0.1.1",
44
"description": "Ellipsis agent CLI — drive the Ellipsis cloud from your terminal",
55
"license": "MIT",
66
"type": "module",
@@ -30,7 +30,8 @@
3030
"ink": "^5.0.1",
3131
"ink-spinner": "^5.0.0",
3232
"react": "^18.3.1",
33-
"ws": "^8.18.0"
33+
"ws": "^8.18.0",
34+
"yaml": "^2.9.0"
3435
},
3536
"devDependencies": {
3637
"@types/node": "^22.5.0",

src/commands/config.ts

Lines changed: 92 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
1-
import type { Command } from 'commander'
1+
import { InvalidArgumentError, type Command } from 'commander'
2+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
3+
import { basename, dirname, extname } from 'node:path'
24
import { ApiClient } from '../lib/api'
3-
import { printJson, runAction } from '../lib/output'
5+
import { formatTs, printJson, printTable, printYaml, runAction } from '../lib/output'
6+
import type { SavedAgentConfig } from '../lib/types'
7+
8+
const DEFAULT_CONFIG_PATH = 'agents/my_agent.yaml'
49

510
export function registerConfig(program: Command): void {
611
const config = program.command('config').description('Inspect saved agent configurations')
@@ -20,37 +25,102 @@ export function registerConfig(program: Command): void {
2025
console.log('No configs found.')
2126
return
2227
}
23-
for (const c of configs) {
24-
const flags = [c.deleted ? 'deleted' : null, c.last_sync_error ? 'sync-error' : null]
25-
.filter(Boolean)
26-
.join(',')
27-
console.log(`${c.id} ${c.updated_at}${flags ? ` [${flags}]` : ''}`)
28-
}
28+
printTable(
29+
['ID', 'SOURCE', 'UPDATED', 'EDITED BY'],
30+
configs.map((c) => [
31+
c.id,
32+
configSource(c),
33+
formatTs(c.updated_at),
34+
editedBy(c),
35+
]),
36+
)
2937
})
3038
})
3139

3240
config
3341
.command('get <configId>')
3442
.description('Get a single agent configuration (GET /v1/agents/configs/{id})')
35-
.option('--json', 'output raw JSON')
36-
.action(async (configId: string, opts: { json?: boolean }) => {
43+
.option('-o, --output <format>', 'output format: yaml (default) or json', parseFormat, 'yaml')
44+
.action(async (configId: string, opts: { output: 'yaml' | 'json' }) => {
3745
await runAction(async () => {
3846
const c = await new ApiClient().getAgentConfig(configId)
39-
if (opts.json) {
47+
if (opts.output === 'json') {
4048
printJson(c)
41-
return
49+
} else {
50+
printYaml(c)
4251
}
43-
console.log(`id: ${c.id}`)
44-
console.log(`created: ${c.created_at}`)
45-
console.log(`updated: ${c.updated_at}`)
46-
if (c.last_synced_commit_sha) console.log(`synced: ${c.last_synced_commit_sha}`)
47-
if (c.last_sync_error) console.log(`sync error: ${c.last_sync_error}`)
48-
console.log('config:')
49-
printJson(c.agent_config)
5052
})
5153
})
5254

53-
// Note: agent configs are sourced from YAML in GitHub, not created/deployed
54-
// through the API (see documents/eng/ELLIPSIS_API_AND_CLI.md). There is no
55-
// /v1 create/deploy endpoint; edit the config file in your repo instead.
55+
config
56+
.command('init [path]')
57+
.description(`Scaffold a starter agent config YAML (default: ${DEFAULT_CONFIG_PATH})`)
58+
.option('-f, --force', 'overwrite the file if it already exists')
59+
.action((path: string | undefined, opts: { force?: boolean }) => {
60+
// Configs are sourced from YAML in GitHub, not created through the API
61+
// (see documents/eng/ELLIPSIS_API_AND_CLI.md), so `init` is a local
62+
// scaffold the user commits to a path Ellipsis syncs from.
63+
const target = path ?? DEFAULT_CONFIG_PATH
64+
if (existsSync(target) && !opts.force) {
65+
console.error(`error: ${target} already exists (use --force to overwrite)`)
66+
process.exitCode = 1
67+
return
68+
}
69+
const name = basename(target, extname(target))
70+
mkdirSync(dirname(target), { recursive: true })
71+
writeFileSync(target, starterConfig(name))
72+
console.log(`✓ wrote ${target}`)
73+
console.log(
74+
'Commit it to your default branch — Ellipsis syncs agent configs from GitHub.',
75+
)
76+
})
77+
}
78+
79+
// A minimal valid agent config. `claude.system` is the only required field;
80+
// everything else has a server-side default. Roots Ellipsis syncs from:
81+
// agents/, .agents/, ellipsis/, .ellipsis/ (any depth), as .yaml/.yml.
82+
function starterConfig(name: string): string {
83+
return `# Ellipsis agent config — commit this to your default branch; Ellipsis syncs it
84+
# from GitHub. Valid locations: agents/, .agents/, ellipsis/, .ellipsis/ (any depth).
85+
ellipsis:
86+
version: v1
87+
name: ${name}
88+
description: What this agent does.
89+
90+
claude:
91+
# System prompt defining the agent's behavior (required).
92+
system: |
93+
You are an Ellipsis agent. Describe the task you want it to perform here.
94+
# model: claude-opus-4-8 # optional; defaults to the account default
95+
96+
# Optional — uncomment and fill in as needed:
97+
# triggers:
98+
# - type: cron
99+
# schedule: "0 9 * * 1-5" # weekdays at 09:00
100+
# tools: []
101+
# repositories: []
102+
`
103+
}
104+
105+
// GitHub source as `path@branch` (repo is only an opaque numeric id in the API).
106+
// Prefixed with ⚠ when the last sync failed so it stands out in the list.
107+
function configSource(c: SavedAgentConfig): string {
108+
const s = c.agent_config_source_details as
109+
| { repo_id: number; path: string; branch: string }
110+
| null
111+
| undefined
112+
const base = s ? `${s.path}@${s.branch}` : '—'
113+
return c.last_sync_error ? `⚠ ${base}` : base
114+
}
115+
116+
function editedBy(c: SavedAgentConfig): string {
117+
const by = c.edited_by as { login?: string } | null | undefined
118+
return by?.login ?? '—'
119+
}
120+
121+
function parseFormat(value: string): 'yaml' | 'json' {
122+
if (value !== 'yaml' && value !== 'json') {
123+
throw new InvalidArgumentError("output format must be 'yaml' or 'json'")
124+
}
125+
return value
56126
}

src/commands/ping.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,28 @@
11
import type { Command } from 'commander'
2-
import { ApiClient } from '../lib/api'
2+
import { ApiClient, ApiError } from '../lib/api'
33

44
export function registerPing(program: Command): void {
55
program
66
.command('ping')
7-
.description('Check connectivity to the Ellipsis API')
7+
.description('Check authenticated connectivity to the Ellipsis /v1 API')
88
.action(async () => {
9+
// There's no unauthenticated health route on the public API, so we probe
10+
// the lightest authenticated endpoint (/v1/me): a 200 proves the API is
11+
// reachable AND the stored token is valid.
912
const api = new ApiClient()
1013
try {
11-
await api.request('GET', '/health')
12-
console.log('ok')
14+
const me = await api.whoami()
15+
console.log(`ok — ${me.customer_login} (${me.customer_id})`)
1316
} catch (err) {
14-
console.error(`ping failed: ${(err as Error).message}`)
17+
if (err instanceof ApiError && err.status === 401) {
18+
// Reachable, just not authenticated — point the user at login.
19+
console.error('reachable, but not authenticated. Run `agent login` first.')
20+
} else if (err instanceof ApiError) {
21+
console.error(`ping failed: ${err.status} ${err.message}`)
22+
} else {
23+
// Network/DNS/connection error: never got an HTTP response.
24+
console.error(`cannot reach the API: ${(err as Error).message}`)
25+
}
1526
process.exitCode = 1
1627
}
1728
})

0 commit comments

Comments
 (0)