summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-05 21:20:34 +0900
committerAdam Malczewski <[email protected]>2026-06-05 21:20:34 +0900
commit552c22d74e5df915088d9e9ff4a286c96c2a54d6 (patch)
tree7d9db1052bab91ef994446d80efc3bfc38026cad
parent7fb3269c698ae583ea7997ce206c4ae252fd3218 (diff)
downloaddispatch-552c22d74e5df915088d9e9ff4a286c96c2a54d6.tar.gz
dispatch-552c22d74e5df915088d9e9ff4a286c96c2a54d6.zip
feat(cli): one-shot terminal client (models, chat, --text/--file/--cwd/--conversation)
HTTP client of transport-contract; pure-core arg/render/ndjson + injected fetch/fs shell. Docs: GLOSSARY (credential/key/model name/model catalog), tasks.md milestone, ORCHESTRATOR geography.
-rw-r--r--.gitignore1
-rw-r--r--GLOSSARY.md4
-rw-r--r--ORCHESTRATOR.md7
-rw-r--r--notes/cli-design.md196
-rw-r--r--package.json3
-rw-r--r--packages/cli/package.json11
-rw-r--r--packages/cli/src/args.test.ts140
-rw-r--r--packages/cli/src/args.ts108
-rw-r--r--packages/cli/src/catalog.test.ts18
-rw-r--r--packages/cli/src/catalog.ts11
-rw-r--r--packages/cli/src/http.test.ts164
-rw-r--r--packages/cli/src/http.ts86
-rw-r--r--packages/cli/src/index.ts12
-rw-r--r--packages/cli/src/main.ts74
-rw-r--r--packages/cli/src/message.test.ts81
-rw-r--r--packages/cli/src/message.ts55
-rw-r--r--packages/cli/src/ndjson.test.ts48
-rw-r--r--packages/cli/src/ndjson.ts18
-rw-r--r--packages/cli/src/render.test.ts147
-rw-r--r--packages/cli/src/render.ts45
-rw-r--r--packages/cli/tsconfig.json6
-rw-r--r--tasks.md30
-rw-r--r--tsconfig.json1
23 files changed, 1238 insertions, 28 deletions
diff --git a/.gitignore b/.gitignore
index 2c6bb6d..c40e88f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,6 +9,7 @@ build/
# Agent prompts (orchestrator→subagent) and work reports — local only.
prompts/
reports/
+.dispatch/plans/
# OS / editor noise
.DS_Store
diff --git a/GLOSSARY.md b/GLOSSARY.md
index dade3c2..fa94a36 100644
--- a/GLOSSARY.md
+++ b/GLOSSARY.md
@@ -28,6 +28,10 @@
| **conversation-store** | The core extension persisting the append-only turn/chunk log. | message store |
| **provider** | An extension wrapping an LLM backend (`stream(messages, tools)`), provider-agnostic to the kernel. | — |
| **AgentEvent** | An outward event the runtime emits during a turn (text-delta, tool-call, usage, done, etc.). Carries `conversationId` + `turnId`. | — |
+| **credential** | A named profile binding a name to a provider (and holding its key/baseURL). The unit of model addressing; several may exist, even for the same provider. Defined in config (a TOML, later); the MVP hardcodes one named `opencode`. Owned by the `credential-store` extension. | (not the bare **key**) |
+| **key** | The API key (the secret string) held by a credential. | apiKey / api-key (when meaning the whole credential profile) |
+| **model name** | The selectable identifier in `<credentialName>/<model>` form — what the model catalog lists and what the CLI / `/chat` `model` field take. | model reference, model id |
+| **model catalog** | The list of available model names; served by `GET /models`, aggregated per credential from each provider's `listModels()`. | model list |
## Known vocabulary drift
diff --git a/ORCHESTRATOR.md b/ORCHESTRATOR.md
index c192569..ae0e66f 100644
--- a/ORCHESTRATOR.md
+++ b/ORCHESTRATOR.md
@@ -270,9 +270,14 @@ git status --short # confirm the agent stayed in its lane (no out-of-scope edit
.env (gitignored — DISPATCH_API_KEY [opencode-2 active], _OPENCODE1 backup)
packages/
kernel/ contracts (ABI), bus, runtime (runTurn), host
+ transport-contract/ types-only HTTP API contract (CLI + future web + server share it)
storage-sqlite/ conversation-store/ auth-apikey/ provider-openai-compat/
+ credential-store/ named credentials + model catalog (resolve / listCatalog)
session-orchestrator/ transport-http/ (core extensions)
- host-bin/ composition root (boot + Bun.serve)
+ tool-read-file/ standard tool extension (read_file; cwd-aware)
+ journal-sink/ trace-store/ observability-collector/ trace-replay/ (observability)
+ cli/ bundled one-shot terminal client (HTTP client of transport-contract)
+ host-bin/ composition root (boot + Bun.serve + collector supervisor)
```
The genesis commit deleted all prior source; we rebuilt from scratch. The OLD
diff --git a/notes/cli-design.md b/notes/cli-design.md
index 2ba66d4..00f0e57 100644
--- a/notes/cli-design.md
+++ b/notes/cli-design.md
@@ -1,8 +1,10 @@
# CLI — Design Scratch
-> **Status:** IDEATION / scratch. NOT decided, NOT building yet. This is the HOME for the
-> CLI design pass (per user: **CLI first, then web frontend**). Promote settled parts into
-> `notes/restructure-plan.md` + harness files when we commit to building.
+> **Status:** ✅ **MVP BUILT + verified live** (2026-06-05). All §3 decisions implemented; the
+> §4 unit plan landed (501 tests green; live CLI: models / chat / --file / --cwd / --conversation).
+> Vocab promoted to `GLOSSARY.md`; milestone logged in `tasks.md` (ROADMAP §1). This file
+> remains the CLI design HOME — future CLI work (e.g. `dispatch serve`, conversation listing,
+> richer rendering) starts here.
>
> **Read order (fresh agent picking this up):** `ORCHESTRATOR.md` → `AGENTS.md` (the
> backend methodology we are MIRRORING) → `GLOSSARY.md` → this file.
@@ -34,25 +36,169 @@ the streamed response line-by-line.
- **One owner per unit; asymmetric testing** — strict zero-internal-mock on pure logic;
lenient integration on the shell.
-## 2. Open questions (DECIDE in the design pass)
-- **Pure-core / shell split:** what's pure (message formatting, state reducer, event
- parser) vs. what's the shell (readline, fetch/stream, stdout, ANSI/colour).
-- **Unit boundaries / first units:** transport client, message-loop engine, output
- renderer, conversation store (persisted across sessions?). Granularity = USER's call.
-- **Conversation management:** list recent, pick one, start new — basic selectors (arrow
- keys + enter, or numbered list). In-memory vs. a small local store.
-- **Output rendering:** streaming is incremental (ProviderEvent deltas → printed as they
- arrive); tool calls / thinking — how to render (collapsible? plain text? ANSI
- indentation?).
-- **Transport:** reuse the same `/chat` NDJSON fetch+ReadableStream path the web FE
- would use. `trace-replay` could even feed CLI transport tests hermetically.
-- **Persistence:** remember the active `conversationId` across sessions? history? simple
- JSON file or the conversation-store extension via HTTP.
-- **Testing tools:** vitest for pure logic (already in repo); shell integration tests
- via PTY / spawned process? Or keep it thin-integration only (asymmetric).
-- **Monorepo placement:** `packages/cli/` — run as `bun packages/cli/src/main.ts`.
-- **Harness artifacts:** `.dispatch/rules/cli-*.md`, GLOSSARY terms (no synonym-drift),
- ORCHESTRATOR additions for CLI summons.
-
-## 3. Decisions settled
-- (none yet — IDEATION.)
+## 1.5 Locked inputs (user, 2026-06-05)
+- **Bundled package.** The CLI is `packages/cli/` — shipped so Dispatch is usable at the
+ "bare minimum". The **web frontend lives in a SEPARATE repo** (not in this monorepo).
+- **Command shape (user's stated UX):**
+ - `dispatch models` (working title) → **view the model catalog**: a list shown as
+ `key-name/model`.
+ - `dispatch <model> --text "…" | --file <path> [--text+--file both] [--cwd <dir>]` →
+ send one message. `<model>` is the **required** identifier copied from the catalog.
+ `--text` and/or `--file` supply the message; `--cwd` defaults to the process CWD.
+
+## 1.6 Backend surface audit (grounding — what exists TODAY) + gaps
+
+### What the backend exposes right now
+- **The ONLY client-facing surface is one HTTP route:** `POST /chat`, body
+ `ChatCommand = { conversationId: string; message: string }` (omit `conversationId` →
+ server mints one), response = **NDJSON stream** of `AgentEvent` JSON lines +
+ `X-Conversation-Id` response header. No other routes (no models, no conversations, no
+ cancel — the old `/chat/cancel|stop|warm` were NOT rebuilt).
+- **`AgentEvent`** union (the render contract) = `status | turn-start | text-delta |
+ reasoning-delta | tool-call | tool-result | tool-output | usage | error | done |
+ turn-sealed`, each carrying `conversationId` (+ `turnId` on turn events).
+- **Internal seams (NOT over HTTP):** `SessionOrchestrator.handleMessage({ conversationId,
+ text, onEvent, signal })` — **no model param, no cwd**; `resolveProvider()` is
+ model-agnostic (`selectFirstProvider`). `HostAPI` has `getProviders()/getTools()/
+ getAuthProviders()/getAuthProvider(id)`. `ProviderContract = { id, stream(msgs, tools,
+ opts?) }` — **no model catalog** (a provider can't enumerate its models).
+- **Already model-ready at the kernel layer:** `ProviderStreamOptions.model?` exists and
+ `runTurn` forwards it verbatim — selection just isn't threaded through transport/orch.
+- **`ToolExecuteContext = { toolCallId, onOutput, signal, log }` — no `cwd`.** The one
+ tool (`read_file`) bakes its workdir at activate (`createReadFileTool(workdir)`) → cwd
+ is process-global, not per-request.
+
+### Gaps the CLI's UX requires (each = a decision below)
+| CLI feature | Backend gap | Likely change |
+|---|---|---|
+| `dispatch models` (catalog) | No catalog source; no route | NEW: catalog source (config list / provider method / new ext?) + `GET /models` |
+| `<model>` required param | `/chat` ignores model; orch picks first provider | `ChatCommand.model`; `handleMessage(model)`; `resolveProvider(model)` (contract fan-out) |
+| `--text` | maps to `message` | none |
+| `--file` | — | CLI-side: read file, fold into the message text (no backend change for basic case) |
+| `--cwd` | no per-turn cwd; tools cwd-baked at activate | DEEP: thread cwd `/chat`→orch→tool (`ToolExecuteContext.cwd`? per-turn toolset?) |
+
+## 1.7 Emerging target — new backend surfaces the CLI needs
+Resolution chain for a chat: **model name `<credentialName>/<model>`** → look up the
+`credential` by name → its provider + `key` → run that provider with the key and
+`providerOpts.model = <model>`.
+- **`ProviderContract.listModels(): Promise<ModelInfo[]>`** — additive contract change; each
+ provider extension implements it ITS OWN way (per user Q4), using the credential's key.
+- **Credential registry:** named credentials → `{ provider, key, baseURL? }`. MVP: host-bin
+ hardcodes ONE (from `DISPATCH_API_KEY`); FUTURE: a TOML. Lookup: credential name → key+provider.
+- **`GET /models`** (transport-http) → the model catalog as `<credentialName>/<model>` entries.
+- **`/chat` body gains `model: "<credentialName>/<model>"`**; orchestrator resolves the
+ credential→provider+key and the model→`providerOpts.model`. Fan-out: `ChatCommand`,
+ `SessionOrchestrator.handleMessage`, `SessionOrchestratorDeps.resolveProvider`.
+- **`cwd`** on `RunTurnInput` → `ToolExecuteContext.cwd` (cache-safe; never enters the prompt).
+- **CLI-side only (no backend change):** `--file` (read + fold into the message), arg parse,
+ NDJSON event→terminal render.
+
+## 2. Open questions / FORKS (DECIDE in the design pass)
+All backend-surface forks are RESOLVED (see §3). Remaining = CLI-internal + small UX:
+
+**Secondary (deciding now):**
+- **Pure/shell split:** pure = arg parse, event→render reducer, catalog formatting, state;
+ shell = stdin/readline, fetch/NDJSON stream, stdout/ANSI, fs (file read), spawn.
+- **Conversation continuity:** does the CLI remember/resume a `conversationId` across runs?
+ If so, need a local store and possibly `GET /conversations` to list them.
+- **Output rendering:** how to show tool-call/tool-output/reasoning vs plain text deltas.
+- **Testing:** vitest for pure logic; thin integration via `trace-replay`-style fixtures.
+- **Harness artifacts:** `.dispatch/rules/cli-*.md`, GLOSSARY terms, ORCHESTRATOR additions.
+
+## 3. Decisions settled (user, 2026-06-05)
+- **Placement:** `packages/cli/` (bundled). Web FE = separate repo.
+- **Coupling = HTTP for BOTH clients.** CLI + web are HTTP clients of host-bin's routes —
+ shared endpoints/types, backend is the single source of truth, less total work. Tradeoff:
+ the server must be running → mitigate later with `dispatch serve`/auto-spawn (deferred).
+- **Invocation = one-shot only.** `dispatch <modelName> --text|--file [--cwd]` → stream → exit.
+ Multi-turn by re-passing a `conversationId`.
+- **Vocabulary (user-chosen; promote to GLOSSARY when we build):**
+ - **credential** = a named credential profile `{ name, provider, key, baseURL? }`
+ (config-defined; many allowed, even of the same provider). Its name = the credential name.
+ - **key** = the API key (the secret) held inside a credential.
+ - **model name** = the selectable identifier the catalog lists and the CLI takes, of the
+ form `<credentialName>/<model>`.
+ - **model catalog** = the list of available model names.
+- **Selection.** MVP hardcodes ONE credential (from `DISPATCH_API_KEY`) but is invoked the
+ SAME way (`<credentialName>/<model>`); TOML multi-credential config is FUTURE.
+- **Catalog source = per-provider extension.** Each provider is its own extension and owns
+ its model-listing (`listModels()`); the catalog aggregates credentials × that provider's
+ models. (generic-vs-opencode-go layering = the remaining fork in §2.)
+- **`--cwd` = Option 1: `cwd` on the turn input + `ToolExecuteContext`** (kernel passes it
+ through, never interprets it). **Cache-safe** — `cwd` flows to tool execution only, never
+ into the model prompt (messages/system/tool-defs), so it does NOT bust the prompt cache.
+- **B1 wire contract = new types-only `packages/transport-contract`** (zero runtime): the
+ typed description of the HTTP API (request bodies + `/models` response; re-exports
+ `AgentEvent` as the stream payload). Imported by transport-http (server), the CLI, and the
+ future web repo → "dead simple to create new frontends." Each side owns its own
+ (de)serialization (no shared helper — isolation-over-DRY).
+- **B2 credential registry = new `credential-store` core extension.** Separation of concerns:
+ `auth-apikey` owns the SECRET (key); `credential-store` owns the NAMED PROFILE (name →
+ provider binding) + catalog aggregation; the `provider` lists/streams using its key. MVP:
+ host-bin injects ONE credential `{ name, providerId:"openai-compat" }` (name configurable,
+ default `"opencode"`); the future TOML grows it (and may then own keys too).
+- **B3 multi-turn = `--conversation <id>` + print the returned id.** No new backend route
+ (conversation-store already threads history by id).
+- **Command UX** as in §1.5.
+
+## 4. Unit plan (LOCKED)
+
+### 4.1 Contract changes (orchestrator-owned, `packages/kernel/src/contracts/`)
+- **provider.ts** — add `ModelInfo { id; displayName? }` + `ProviderContract.listModels():
+ Promise<readonly ModelInfo[]>` (additive/minor). Doc-note: future per-credential
+ `listModels(creds)` when multi-credential lands (today the provider uses its own key).
+- **runtime.ts** — add `RunTurnInput.cwd?: string` (passthrough; kernel never interprets it —
+ stays pure).
+- **tool.ts** — add `ToolExecuteContext.cwd?: string` (tools resolve paths against it; fall
+ back to their activate-time workdir when absent). Cache-safe (never enters the prompt).
+- After edits → `lsp references` each changed symbol → dispatch the fan-out.
+
+### 4.1b New types-only contract package (B1) — `packages/transport-contract` (orchestrator-authored)
+- `ChatRequest { conversationId?: string; message: string; model?: string; cwd?: string }`
+- `ModelsResponse { models: readonly string[] }` (each = a model name `<credName>/<model>`)
+- re-export `type { AgentEvent }` from `@dispatch/kernel` (the NDJSON stream payload).
+- Zero runtime; both server and clients implement their own (de)serialization.
+
+### 4.2 Owner-agent units (one writer each; pure-core/injected-shell; feature-as-a-library)
+| Unit | Owns | Job | Tests |
+|---|---|---|---|
+| kernel-runtime | `kernel/src/runtime/*` | thread `RunTurnInput.cwd` → `ToolExecuteContext.cwd` | fake tool sees ctx.cwd |
+| provider-openai-compat | its pkg | implement `listModels()` (GET `{baseURL}/v1/models`); KEEP opencode-go specifics; add deferred-split CODE NOTE | hermetic fetch-mock |
+| credential-store (B2) | new/owner | named credentials + `resolve(modelName)→{providerId,model}` + `listCatalog()→modelName[]`; typed service handle; MVP = 1 cred from env | pure resolve/split + catalog |
+| session-orchestrator | its pkg | `handleMessage` gains `modelName`+`cwd`; resolve via credential-store; thread cwd→RunTurnInput | pure + integration |
+| transport-http | its pkg | `/chat` body +`model?`+`cwd?`; new `GET /models` (catalog) | parse + route |
+| tool-read-file | its pkg | use `ctx.cwd ?? bakedWorkdir` for resolution + containment | pure path tests |
+| host-bin | `main.ts` | register credential-store; wire 1 hardcoded credential (name+provider+key from env); load order | boot |
+| **cli (NEW `packages/cli/`)** | new pkg | PURE: argv parse, `AgentEvent`→render reducer, request builder, catalog formatter. SHELL: fs (`--file`,cwd), fetch NDJSON client, stdout | pure (zero mock) + thin integration via injected fetch |
+
+### 4.3 Build order (two milestones)
+- **M1 — backend surface (curl-verifiable):** §4.1 contracts → **∥** {kernel-runtime · provider
+ listModels · tool ctx.cwd · credential-store} → session-orchestrator → **∥** {transport-http ·
+ host-bin}. Verify: `GET /models`; `curl /chat {model:"<cred>/<model>", message, cwd}`;
+ read_file honoring cwd.
+- **M2 — CLI:** `packages/cli` (HTTP client). Depends only on the wire contract → can build **∥**
+ with M1's tail; integration-tested live once the server is up. Verify: `dispatch models`,
+ `dispatch <cred>/<model> --text|--file [--cwd]`, multi-turn via `--conversation`.
+
+### 4.4 Proposed UX defaults (veto welcome — not boundary calls)
+- **Render:** stream assistant text; show tool-call/result compactly; usage line at end;
+ reasoning behind `--show-reasoning`. (Decision B-render if you want different.)
+- **`--text` + `--file`:** message = text, then a labeled file block; either alone is valid.
+- **Server URL:** default `http://localhost:${BACKEND_PORT:-24203}`; `--server` to override.
+- **No local HTTP auth** for localhost MVP (note it; revisit if exposed).
+
+### 4.5 Boundary decisions (RESOLVED — see §3)
+- **B1** → new types-only `packages/transport-contract` (§4.1b).
+- **B2** → new `credential-store` core extension (auth-apikey keeps the secret; credential-store
+ owns name→provider + catalog).
+- **B3** → `--conversation <id>` + print the returned id; no new backend route.
+
+### 4.6 Contract shapes to be authored (orchestrator; veto before they hit the ABI)
+- kernel `provider.ts`: `ModelInfo { id: string; displayName?: string }`;
+ `ProviderContract.listModels(): Promise<readonly ModelInfo[]>`.
+- kernel `runtime.ts`: `RunTurnInput.cwd?: string`. kernel `tool.ts`: `ToolExecuteContext.cwd?: string`.
+- `transport-contract`: as §4.1b.
+- `credential-store` service (`credentialStoreHandle`): `resolve(modelName: string):
+ { providerId: string; model: string } | undefined` (split on first "/", look up credential);
+ `listCatalog(): Promise<readonly string[]>` (per credential → its provider's `listModels()`
+ → `<credName>/<id>`). Sync resolve (map+split), async catalog (providers are async).
diff --git a/package.json b/package.json
index d46ea7f..c1e0de1 100644
--- a/package.json
+++ b/package.json
@@ -13,7 +13,8 @@
"typecheck": "tsc -b --pretty",
"test:bun": "bun test packages/storage-sqlite/src packages/trace-store/src packages/observability-collector/src",
"test:all": "bun run test && bun run test:bun",
- "dev": "bun packages/host-bin/src/main.ts"
+ "dev": "bun packages/host-bin/src/main.ts",
+ "dispatch": "bun packages/cli/src/main.ts"
},
"devDependencies": {
"@biomejs/biome": "^2.4.15",
diff --git a/packages/cli/package.json b/packages/cli/package.json
new file mode 100644
index 0000000..9b286fd
--- /dev/null
+++ b/packages/cli/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "@dispatch/cli",
+ "version": "0.0.0",
+ "type": "module",
+ "private": true,
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "dependencies": {
+ "@dispatch/transport-contract": "workspace:*"
+ }
+}
diff --git a/packages/cli/src/args.test.ts b/packages/cli/src/args.test.ts
new file mode 100644
index 0000000..02b9e9b
--- /dev/null
+++ b/packages/cli/src/args.test.ts
@@ -0,0 +1,140 @@
+import { describe, expect, it } from "vitest";
+import { parseArgs } from "./args.js";
+
+const defaultServer = "http://localhost:24203";
+
+describe("parseArgs", () => {
+ it("returns help for empty argv", () => {
+ expect(parseArgs([], { defaultServer })).toEqual({ kind: "help" });
+ });
+
+ it("returns help for --help", () => {
+ expect(parseArgs(["--help"], { defaultServer })).toEqual({ kind: "help" });
+ });
+
+ it("returns help for -h", () => {
+ expect(parseArgs(["-h"], { defaultServer })).toEqual({ kind: "help" });
+ });
+
+ describe("models", () => {
+ it("parses 'models' with default server", () => {
+ expect(parseArgs(["models"], { defaultServer })).toEqual({
+ kind: "models",
+ server: "http://localhost:24203",
+ });
+ });
+
+ it("parses 'models --server <url>'", () => {
+ expect(parseArgs(["models", "--server", "http://example.com"], { defaultServer })).toEqual({
+ kind: "models",
+ server: "http://example.com",
+ });
+ });
+
+ it("errors on unknown argument for models", () => {
+ const result = parseArgs(["models", "--foo"], { defaultServer });
+ expect(result.kind).toBe("error");
+ if (result.kind === "error") expect(result.message).toContain("Unknown argument");
+ });
+ });
+
+ describe("chat", () => {
+ it("parses a chat with --text", () => {
+ const result = parseArgs(["my-model", "--text", "hello"], { defaultServer });
+ expect(result).toEqual({
+ kind: "chat",
+ server: "http://localhost:24203",
+ modelName: "my-model",
+ text: "hello",
+ file: undefined,
+ cwd: undefined,
+ conversationId: undefined,
+ showReasoning: false,
+ });
+ });
+
+ it("parses a chat with --file", () => {
+ const result = parseArgs(["my-model", "--file", "foo.txt"], { defaultServer });
+ expect(result).toEqual({
+ kind: "chat",
+ server: "http://localhost:24203",
+ modelName: "my-model",
+ text: undefined,
+ file: "foo.txt",
+ cwd: undefined,
+ conversationId: undefined,
+ showReasoning: false,
+ });
+ });
+
+ it("parses a chat with both --text and --file", () => {
+ const result = parseArgs(["m", "--text", "hi", "--file", "f.txt"], { defaultServer });
+ expect(result).toMatchObject({ kind: "chat", text: "hi", file: "f.txt" });
+ });
+
+ it("parses --cwd, --conversation, --server, --show-reasoning", () => {
+ const result = parseArgs(
+ [
+ "m",
+ "--text",
+ "x",
+ "--cwd",
+ "/tmp",
+ "--conversation",
+ "abc",
+ "--server",
+ "http://s",
+ "--show-reasoning",
+ ],
+ { defaultServer },
+ );
+ expect(result).toEqual({
+ kind: "chat",
+ server: "http://s",
+ modelName: "m",
+ text: "x",
+ file: undefined,
+ cwd: "/tmp",
+ conversationId: "abc",
+ showReasoning: true,
+ });
+ });
+
+ it("errors when text and file are both missing", () => {
+ const result = parseArgs(["my-model"], { defaultServer });
+ expect(result.kind).toBe("error");
+ if (result.kind === "error") expect(result.message).toContain("--text or --file");
+ });
+
+ it("errors on unknown flag", () => {
+ const result = parseArgs(["my-model", "--text", "hi", "--bogus"], { defaultServer });
+ expect(result.kind).toBe("error");
+ if (result.kind === "error") expect(result.message).toContain("Unknown flag");
+ });
+
+ it("errors when --text has no value", () => {
+ const result = parseArgs(["m", "--text"], { defaultServer });
+ expect(result.kind).toBe("error");
+ });
+
+ it("errors when --file has no value", () => {
+ const result = parseArgs(["m", "--file"], { defaultServer });
+ expect(result.kind).toBe("error");
+ });
+
+ it("errors when --server has no value", () => {
+ const result = parseArgs(["models", "--server"], { defaultServer });
+ expect(result.kind).toBe("error");
+ });
+
+ it("errors when --cwd has no value", () => {
+ const result = parseArgs(["m", "--text", "x", "--cwd"], { defaultServer });
+ expect(result.kind).toBe("error");
+ });
+
+ it("errors when --conversation has no value", () => {
+ const result = parseArgs(["m", "--text", "x", "--conversation"], { defaultServer });
+ expect(result.kind).toBe("error");
+ });
+ });
+});
diff --git a/packages/cli/src/args.ts b/packages/cli/src/args.ts
new file mode 100644
index 0000000..2c554e8
--- /dev/null
+++ b/packages/cli/src/args.ts
@@ -0,0 +1,108 @@
+/**
+ * Pure argument parser — zero I/O, zero ambient state.
+ *
+ * Parses process.argv-style strings into a discriminated command union.
+ * Validates required flags and reports unknown flags as errors.
+ */
+
+export type ParsedCommand =
+ | { readonly kind: "models"; readonly server: string }
+ | {
+ readonly kind: "chat";
+ readonly server: string;
+ readonly modelName: string;
+ readonly text?: string | undefined;
+ readonly file?: string | undefined;
+ readonly cwd?: string | undefined;
+ readonly conversationId?: string | undefined;
+ readonly showReasoning: boolean;
+ }
+ | { readonly kind: "help" }
+ | { readonly kind: "error"; readonly message: string };
+
+interface ParseOpts {
+ readonly defaultServer: string;
+}
+
+export function parseArgs(argv: readonly string[], opts: ParseOpts): ParsedCommand {
+ if (argv.length === 0) {
+ return { kind: "help" };
+ }
+
+ const first = argv[0] as string;
+
+ if (first === "--help" || first === "-h") {
+ return { kind: "help" };
+ }
+
+ if (first === "models") {
+ let server = opts.defaultServer;
+ for (let i = 1; i < argv.length; i++) {
+ if (argv[i] === "--server" && i + 1 < argv.length) {
+ server = argv[++i] as string;
+ } else {
+ return { kind: "error", message: `Unknown argument for 'models': ${argv[i]}` };
+ }
+ }
+ return { kind: "models", server };
+ }
+
+ // Chat mode: first arg is the model name
+ const modelName = first;
+ let text: string | undefined;
+ let file: string | undefined;
+ let cwd: string | undefined;
+ let conversationId: string | undefined;
+ let showReasoning = false;
+ let server = opts.defaultServer;
+
+ for (let i = 1; i < argv.length; i++) {
+ const arg = argv[i] as string;
+ switch (arg) {
+ case "--text":
+ if (i + 1 >= argv.length) return { kind: "error", message: "--text requires a value" };
+ text = argv[++i];
+ break;
+ case "--file":
+ if (i + 1 >= argv.length) return { kind: "error", message: "--file requires a value" };
+ file = argv[++i];
+ break;
+ case "--cwd":
+ if (i + 1 >= argv.length) return { kind: "error", message: "--cwd requires a value" };
+ cwd = argv[++i];
+ break;
+ case "--conversation":
+ if (i + 1 >= argv.length)
+ return { kind: "error", message: "--conversation requires a value" };
+ conversationId = argv[++i];
+ break;
+ case "--server":
+ if (i + 1 >= argv.length) return { kind: "error", message: "--server requires a value" };
+ server = argv[++i] as string;
+ break;
+ case "--show-reasoning":
+ showReasoning = true;
+ break;
+ default:
+ return { kind: "error", message: `Unknown flag: ${arg}` };
+ }
+ }
+
+ if (!text && !file) {
+ return {
+ kind: "error",
+ message: "At least one of --text or --file is required for a chat command",
+ };
+ }
+
+ return {
+ kind: "chat",
+ server,
+ modelName,
+ text,
+ file,
+ cwd,
+ conversationId,
+ showReasoning,
+ };
+}
diff --git a/packages/cli/src/catalog.test.ts b/packages/cli/src/catalog.test.ts
new file mode 100644
index 0000000..3b32efc
--- /dev/null
+++ b/packages/cli/src/catalog.test.ts
@@ -0,0 +1,18 @@
+import { describe, expect, it } from "vitest";
+import { formatCatalog } from "./catalog.js";
+
+describe("formatCatalog", () => {
+ it("formats a single model", () => {
+ expect(formatCatalog({ models: ["openai/gpt-4"] })).toBe("openai/gpt-4");
+ });
+
+ it("formats multiple models one per line", () => {
+ expect(formatCatalog({ models: ["openai/gpt-4", "anthropic/claude-3", "local/llama"] })).toBe(
+ "openai/gpt-4\nanthropic/claude-3\nlocal/llama",
+ );
+ });
+
+ it("returns empty string for empty models", () => {
+ expect(formatCatalog({ models: [] })).toBe("");
+ });
+});
diff --git a/packages/cli/src/catalog.ts b/packages/cli/src/catalog.ts
new file mode 100644
index 0000000..a2b0780
--- /dev/null
+++ b/packages/cli/src/catalog.ts
@@ -0,0 +1,11 @@
+/**
+ * Pure catalog formatter — zero I/O.
+ *
+ * Formats a ModelsResponse into one model name per line.
+ */
+
+import type { ModelsResponse } from "@dispatch/transport-contract";
+
+export function formatCatalog(r: ModelsResponse): string {
+ return r.models.join("\n");
+}
diff --git a/packages/cli/src/http.test.ts b/packages/cli/src/http.test.ts
new file mode 100644
index 0000000..becfdbb
--- /dev/null
+++ b/packages/cli/src/http.test.ts
@@ -0,0 +1,164 @@
+import type { AgentEvent } from "@dispatch/transport-contract";
+import { describe, expect, it } from "vitest";
+import { fetchModels, streamChat } from "./http.js";
+
+function ndjsonLines(...events: AgentEvent[]): string {
+ return `${events.map((e) => JSON.stringify(e)).join("\n")}\n`;
+}
+
+function makeFakeFetch(responseBody: string, headers?: Record<string, string>) {
+ const fn = async (_url: string | URL | Request, _init?: RequestInit): Promise<Response> => {
+ const encoder = new TextEncoder();
+ const chunks = responseBody.split("|||");
+ let i = 0;
+ const stream = new ReadableStream<Uint8Array>({
+ pull(controller) {
+ if (i < chunks.length) {
+ const chunk = chunks[i];
+ if (chunk !== undefined) controller.enqueue(encoder.encode(chunk));
+ i++;
+ } else {
+ controller.close();
+ }
+ },
+ });
+ return new Response(stream, {
+ status: 200,
+ headers: headers ?? {},
+ });
+ };
+ return fn as unknown as typeof fetch;
+}
+
+describe("streamChat", () => {
+ it("parses NDJSON events and returns conversationId", async () => {
+ const event1: AgentEvent = {
+ type: "text-delta",
+ conversationId: "c1",
+ turnId: "t1",
+ delta: "Hello",
+ };
+ const event2: AgentEvent = {
+ type: "done",
+ conversationId: "c1",
+ turnId: "t1",
+ reason: "completed",
+ };
+
+ const body = ndjsonLines(event1, event2);
+ const fakeFetch = makeFakeFetch(body, { "X-Conversation-Id": "c1" });
+
+ const { conversationId, events } = await streamChat(
+ { fetchImpl: fakeFetch },
+ {
+ server: "http://localhost:24203",
+ request: { message: "hi", model: "openai/gpt-4" },
+ },
+ );
+
+ expect(conversationId).toBe("c1");
+
+ const collected: AgentEvent[] = [];
+ for await (const e of events) {
+ collected.push(e);
+ }
+
+ expect(collected).toEqual([event1, event2]);
+ });
+
+ it("handles NDJSON split across chunks", async () => {
+ const event1: AgentEvent = {
+ type: "text-delta",
+ conversationId: "c",
+ turnId: "t",
+ delta: "Hi",
+ };
+ const event2: AgentEvent = {
+ type: "usage",
+ conversationId: "c",
+ turnId: "t",
+ usage: { inputTokens: 10, outputTokens: 5 },
+ };
+
+ const fullNdjson = ndjsonLines(event1, event2);
+ // Split mid-line: after 20 chars
+ const mid = 20;
+ const chunk1 = fullNdjson.slice(0, mid);
+ const chunk2 = fullNdjson.slice(mid);
+
+ const fakeFetch = makeFakeFetch(`${chunk1}|||${chunk2}`, {
+ "X-Conversation-Id": "c",
+ });
+
+ const { events } = await streamChat(
+ { fetchImpl: fakeFetch },
+ {
+ server: "http://localhost:24203",
+ request: { message: "hi" },
+ },
+ );
+
+ const collected: AgentEvent[] = [];
+ for await (const e of events) {
+ collected.push(e);
+ }
+
+ expect(collected).toEqual([event1, event2]);
+ });
+
+ it("throws on non-OK status", async () => {
+ const fakeFetch = (async (): Promise<Response> =>
+ new Response("not found", { status: 404 })) as unknown as typeof fetch;
+
+ await expect(
+ streamChat(
+ { fetchImpl: fakeFetch },
+ {
+ server: "http://localhost:24203",
+ request: { message: "hi" },
+ },
+ ),
+ ).rejects.toThrow("POST /chat failed with status 404");
+ });
+
+ it("throws when response has no body", async () => {
+ const fakeFetch = (async (): Promise<Response> =>
+ new Response(null, { status: 200 })) as unknown as typeof fetch;
+
+ await expect(
+ streamChat(
+ { fetchImpl: fakeFetch },
+ {
+ server: "http://localhost:24203",
+ request: { message: "hi" },
+ },
+ ),
+ ).rejects.toThrow("no body");
+ });
+});
+
+describe("fetchModels", () => {
+ it("returns ModelsResponse on success", async () => {
+ const models = { models: ["openai/gpt-4", "anthropic/claude-3"] };
+ const fakeFetch = (async (): Promise<Response> =>
+ new Response(JSON.stringify(models), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ })) as unknown as typeof fetch;
+
+ const result = await fetchModels(
+ { fetchImpl: fakeFetch },
+ { server: "http://localhost:24203" },
+ );
+ expect(result).toEqual(models);
+ });
+
+ it("throws on non-OK status", async () => {
+ const fakeFetch = (async (): Promise<Response> =>
+ new Response("server error", { status: 500 })) as unknown as typeof fetch;
+
+ await expect(
+ fetchModels({ fetchImpl: fakeFetch }, { server: "http://localhost:24203" }),
+ ).rejects.toThrow("GET /models failed with status 500");
+ });
+});
diff --git a/packages/cli/src/http.ts b/packages/cli/src/http.ts
new file mode 100644
index 0000000..5e61afb
--- /dev/null
+++ b/packages/cli/src/http.ts
@@ -0,0 +1,86 @@
+/**
+ * Shell — HTTP transport layer (effects injected at the edges).
+ *
+ * streamChat: POST /chat, returns an async iterable of AgentEvents.
+ * fetchModels: GET /models, returns the ModelsResponse.
+ *
+ * The fetchImpl dependency is injected (outermost edge mock allowed).
+ */
+
+import type { AgentEvent, ChatRequest, ModelsResponse } from "@dispatch/transport-contract";
+import { splitNdjsonLines } from "./ndjson.js";
+
+interface FetchDeps {
+ readonly fetchImpl: typeof fetch;
+}
+
+interface StreamChatOpts {
+ readonly server: string;
+ readonly request: ChatRequest;
+}
+
+export async function streamChat(
+ deps: FetchDeps,
+ opts: StreamChatOpts,
+): Promise<{ conversationId: string | null; events: AsyncIterable<AgentEvent> }> {
+ const url = `${opts.server}/chat`;
+ const res = await deps.fetchImpl(url, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(opts.request),
+ });
+
+ if (!res.ok) {
+ const body = await res.text();
+ throw new Error(`POST /chat failed with status ${res.status}: ${body}`);
+ }
+
+ const conversationId = res.headers.get("X-Conversation-Id");
+
+ if (!res.body) {
+ throw new Error("POST /chat returned no body");
+ }
+
+ const events = readNdjsonStream(res.body);
+ return { conversationId, events };
+}
+
+async function* readNdjsonStream(body: ReadableStream<Uint8Array>): AsyncIterable<AgentEvent> {
+ const reader = body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = "";
+
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ buffer += decoder.decode(value, { stream: true });
+ const { lines, rest } = splitNdjsonLines(buffer);
+ buffer = rest;
+ for (const line of lines) {
+ yield JSON.parse(line) as AgentEvent;
+ }
+ }
+ if (buffer.length > 0) {
+ yield JSON.parse(buffer) as AgentEvent;
+ }
+ } finally {
+ reader.releaseLock();
+ }
+}
+
+interface FetchModelsOpts {
+ readonly server: string;
+}
+
+export async function fetchModels(deps: FetchDeps, opts: FetchModelsOpts): Promise<ModelsResponse> {
+ const url = `${opts.server}/models`;
+ const res = await deps.fetchImpl(url);
+
+ if (!res.ok) {
+ const body = await res.text();
+ throw new Error(`GET /models failed with status ${res.status}: ${body}`);
+ }
+
+ return (await res.json()) as ModelsResponse;
+}
diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts
new file mode 100644
index 0000000..82e0b21
--- /dev/null
+++ b/packages/cli/src/index.ts
@@ -0,0 +1,12 @@
+/**
+ * Barrel export — the public surface of @dispatch/cli.
+ *
+ * Pure functions + http functions for consumers and tests.
+ */
+
+export { type ParsedCommand, parseArgs } from "./args.js";
+export { formatCatalog } from "./catalog.js";
+export { fetchModels, streamChat } from "./http.js";
+export { buildChatRequest, composeMessage } from "./message.js";
+export { type SplitResult, splitNdjsonLines } from "./ndjson.js";
+export { renderEvent } from "./render.js";
diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts
new file mode 100644
index 0000000..fc70c0c
--- /dev/null
+++ b/packages/cli/src/main.ts
@@ -0,0 +1,74 @@
+/**
+ * Composition root — the thin, untested shell that wires everything together.
+ *
+ * Reads process.argv, reads files, writes to stdout/stderr.
+ * This is the ONLY file that touches I/O.
+ */
+
+import { readFile } from "node:fs/promises";
+import { parseArgs } from "./args.js";
+import { formatCatalog } from "./catalog.js";
+import { fetchModels, streamChat } from "./http.js";
+import { buildChatRequest, composeMessage } from "./message.js";
+import { renderEvent } from "./render.js";
+
+const USAGE = `Usage:
+ dispatch models [--server <url>]
+ dispatch <modelName> --text "..." [--file <path>] [--cwd <dir>] [--conversation <id>] [--server <url>] [--show-reasoning]
+ dispatch --help`;
+
+async function main(): Promise<void> {
+ const defaultServer = `http://localhost:${process.env.BACKEND_PORT ?? "24203"}`;
+ const parsed = parseArgs(process.argv.slice(2), { defaultServer });
+
+ switch (parsed.kind) {
+ case "help":
+ process.stdout.write(`${USAGE}\n`);
+ process.exit(0);
+ break;
+ case "error":
+ process.stderr.write(`Error: ${parsed.message}\n`);
+ process.exit(1);
+ break;
+ case "models": {
+ const result = await fetchModels({ fetchImpl: globalThis.fetch }, { server: parsed.server });
+ process.stdout.write(`${formatCatalog(result)}\n`);
+ break;
+ }
+ case "chat": {
+ let fileContent: string | undefined;
+ if (parsed.file) {
+ fileContent = await readFile(parsed.file, "utf-8");
+ }
+
+ const cwd = parsed.cwd ?? process.cwd();
+ const message = composeMessage({
+ ...(parsed.text !== undefined && { text: parsed.text }),
+ ...(parsed.file !== undefined && { file: parsed.file }),
+ ...(fileContent !== undefined && { fileContent }),
+ });
+ const request = buildChatRequest(parsed, { cwd, message });
+
+ const { conversationId, events } = await streamChat(
+ { fetchImpl: globalThis.fetch },
+ { server: parsed.server, request },
+ );
+
+ for await (const event of events) {
+ const rendered = renderEvent(event, { showReasoning: parsed.showReasoning });
+ if (rendered?.stdout) process.stdout.write(rendered.stdout);
+ if (rendered?.stderr) process.stderr.write(rendered.stderr);
+ }
+
+ if (conversationId) {
+ process.stdout.write(`\n[conversation] ${conversationId}\n`);
+ }
+ break;
+ }
+ }
+}
+
+main().catch((err: unknown) => {
+ process.stderr.write(`Fatal: ${err instanceof Error ? err.message : String(err)}\n`);
+ process.exit(1);
+});
diff --git a/packages/cli/src/message.test.ts b/packages/cli/src/message.test.ts
new file mode 100644
index 0000000..8d6d9e1
--- /dev/null
+++ b/packages/cli/src/message.test.ts
@@ -0,0 +1,81 @@
+import { describe, expect, it } from "vitest";
+import { buildChatRequest, composeMessage } from "./message.js";
+
+describe("composeMessage", () => {
+ it("returns text only", () => {
+ expect(composeMessage({ text: "hello" })).toBe("hello");
+ });
+
+ it("returns file only with neutral label", () => {
+ expect(composeMessage({ file: "foo.txt", fileContent: "contents" })).toBe(
+ "Attached file (foo.txt):\ncontents",
+ );
+ });
+
+ it("returns text + labeled file block", () => {
+ expect(composeMessage({ text: "check this", file: "a.ts", fileContent: "const x = 1;" })).toBe(
+ "check this\n\nAttached file (a.ts):\nconst x = 1;",
+ );
+ });
+
+ it("handles missing fileContent gracefully", () => {
+ expect(composeMessage({ file: "f.txt" })).toBe("Attached file (f.txt):\n");
+ });
+
+ it("uses basename for absolute paths", () => {
+ expect(composeMessage({ file: "/tmp/opencode/demo/note.txt", fileContent: "hello" })).toBe(
+ "Attached file (note.txt):\nhello",
+ );
+ });
+
+ it("handles empty input", () => {
+ expect(composeMessage({})).toBe("");
+ });
+});
+
+describe("buildChatRequest", () => {
+ it("maps fields correctly", () => {
+ const req = buildChatRequest(
+ {
+ modelName: "cred/model",
+ text: "hi",
+ showReasoning: false,
+ },
+ { cwd: "/work", message: "hi" },
+ );
+ expect(req).toEqual({
+ message: "hi",
+ model: "cred/model",
+ cwd: "/work",
+ });
+ });
+
+ it("includes conversationId when provided", () => {
+ const req = buildChatRequest(
+ {
+ modelName: "m",
+ text: "x",
+ conversationId: "conv-123",
+ showReasoning: false,
+ },
+ { cwd: "/work", message: "x" },
+ );
+ expect(req.conversationId).toBe("conv-123");
+ });
+
+ it("omits conversationId when not provided", () => {
+ const req = buildChatRequest(
+ { modelName: "m", text: "x", showReasoning: false },
+ { cwd: "/work", message: "x" },
+ );
+ expect(req).not.toHaveProperty("conversationId");
+ });
+
+ it("uses explicit cwd over context cwd", () => {
+ const req = buildChatRequest(
+ { modelName: "m", text: "x", cwd: "/explicit", showReasoning: false },
+ { cwd: "/default", message: "x" },
+ );
+ expect(req.cwd).toBe("/explicit");
+ });
+});
diff --git a/packages/cli/src/message.ts b/packages/cli/src/message.ts
new file mode 100644
index 0000000..0c3f538
--- /dev/null
+++ b/packages/cli/src/message.ts
@@ -0,0 +1,55 @@
+/**
+ * Pure message composition — zero I/O.
+ *
+ * Combines text and file content into a single message string,
+ * and builds a ChatRequest from a parsed command.
+ */
+
+import type { ChatRequest } from "@dispatch/transport-contract";
+
+interface ComposeInput {
+ readonly text?: string;
+ readonly file?: string;
+ readonly fileContent?: string;
+}
+
+function basename(filePath: string): string {
+ const segments = filePath.split("/");
+ return segments[segments.length - 1] ?? filePath;
+}
+
+export function composeMessage(input: ComposeInput): string {
+ const { text, fileContent } = input;
+ const file = input.file;
+
+ if (text && file) {
+ return `${text}\n\nAttached file (${basename(file)}):\n${fileContent ?? ""}`;
+ }
+ if (file) {
+ return `Attached file (${basename(file)}):\n${fileContent ?? ""}`;
+ }
+ return text ?? "";
+}
+
+interface ChatCmd {
+ readonly modelName: string;
+ readonly text?: string | undefined;
+ readonly file?: string | undefined;
+ readonly cwd?: string | undefined;
+ readonly conversationId?: string | undefined;
+ readonly showReasoning: boolean;
+}
+
+interface BuildCtx {
+ readonly cwd: string;
+ readonly message: string;
+}
+
+export function buildChatRequest(cmd: ChatCmd, ctx: BuildCtx): ChatRequest {
+ return {
+ message: ctx.message,
+ model: cmd.modelName,
+ ...(cmd.conversationId !== undefined && { conversationId: cmd.conversationId }),
+ ...(cmd.cwd !== undefined ? { cwd: cmd.cwd } : { cwd: ctx.cwd }),
+ };
+}
diff --git a/packages/cli/src/ndjson.test.ts b/packages/cli/src/ndjson.test.ts
new file mode 100644
index 0000000..8ed3bff
--- /dev/null
+++ b/packages/cli/src/ndjson.test.ts
@@ -0,0 +1,48 @@
+import { describe, expect, it } from "vitest";
+import { splitNdjsonLines } from "./ndjson.js";
+
+describe("splitNdjsonLines", () => {
+ it("splits complete lines", () => {
+ const result = splitNdjsonLines('{"a":1}\n{"b":2}\n');
+ expect(result.lines).toEqual(['{"a":1}', '{"b":2}']);
+ expect(result.rest).toBe("");
+ });
+
+ it("keeps incomplete trailing line in rest", () => {
+ const result = splitNdjsonLines('{"a":1}\n{"b":2');
+ expect(result.lines).toEqual(['{"a":1}']);
+ expect(result.rest).toBe('{"b":2');
+ });
+
+ it("returns empty lines array for single incomplete line", () => {
+ const result = splitNdjsonLines('{"a":1');
+ expect(result.lines).toEqual([]);
+ expect(result.rest).toBe('{"a":1');
+ });
+
+ it("filters out empty lines", () => {
+ const result = splitNdjsonLines('{"a":1}\n\n{"b":2}\n');
+ expect(result.lines).toEqual(['{"a":1}', '{"b":2}']);
+ expect(result.rest).toBe("");
+ });
+
+ it("handles a line split across two buffers", () => {
+ const buf1 = '{"a":1}\n{"b":';
+ const buf2 = '2}\n{"c":3}\n';
+
+ const r1 = splitNdjsonLines(buf1);
+ expect(r1.lines).toEqual(['{"a":1}']);
+ expect(r1.rest).toBe('{"b":');
+
+ const combined = r1.rest + buf2;
+ const r2 = splitNdjsonLines(combined);
+ expect(r2.lines).toEqual(['{"b":2}', '{"c":3}']);
+ expect(r2.rest).toBe("");
+ });
+
+ it("handles empty buffer", () => {
+ const result = splitNdjsonLines("");
+ expect(result.lines).toEqual([]);
+ expect(result.rest).toBe("");
+ });
+});
diff --git a/packages/cli/src/ndjson.ts b/packages/cli/src/ndjson.ts
new file mode 100644
index 0000000..57093c1
--- /dev/null
+++ b/packages/cli/src/ndjson.ts
@@ -0,0 +1,18 @@
+/**
+ * Pure NDJSON line splitter — zero I/O.
+ *
+ * Splits a buffer on newlines, keeping an incomplete trailing line in `rest`.
+ * Does NOT parse JSON — that is the caller's job.
+ */
+
+export interface SplitResult {
+ readonly lines: readonly string[];
+ readonly rest: string;
+}
+
+export function splitNdjsonLines(buffer: string): SplitResult {
+ const parts = buffer.split("\n");
+ const rest = parts[parts.length - 1] ?? "";
+ const lines = parts.slice(0, -1).filter((l) => l.length > 0);
+ return { lines, rest };
+}
diff --git a/packages/cli/src/render.test.ts b/packages/cli/src/render.test.ts
new file mode 100644
index 0000000..bfdb791
--- /dev/null
+++ b/packages/cli/src/render.test.ts
@@ -0,0 +1,147 @@
+import type { AgentEvent } from "@dispatch/transport-contract";
+import { describe, expect, it } from "vitest";
+import { renderEvent } from "./render.js";
+
+describe("renderEvent", () => {
+ const opts = { showReasoning: false };
+ const optsReasoning = { showReasoning: true };
+
+ it("renders text-delta as stdout", () => {
+ const e: AgentEvent = {
+ type: "text-delta",
+ conversationId: "c",
+ turnId: "t",
+ delta: "hello",
+ };
+ expect(renderEvent(e, opts)).toEqual({ stdout: "hello" });
+ });
+
+ it("hides reasoning-delta by default", () => {
+ const e: AgentEvent = {
+ type: "reasoning-delta",
+ conversationId: "c",
+ turnId: "t",
+ delta: "thinking...",
+ };
+ expect(renderEvent(e, opts)).toBeUndefined();
+ });
+
+ it("shows reasoning-delta when showReasoning is true", () => {
+ const e: AgentEvent = {
+ type: "reasoning-delta",
+ conversationId: "c",
+ turnId: "t",
+ delta: "thinking...",
+ };
+ expect(renderEvent(e, optsReasoning)).toEqual({ stdout: "thinking..." });
+ });
+
+ it("renders tool-call with name and JSON input", () => {
+ const e: AgentEvent = {
+ type: "tool-call",
+ conversationId: "c",
+ turnId: "t",
+ toolCallId: "tc1",
+ toolName: "read_file",
+ input: { path: "/foo" },
+ };
+ const result = renderEvent(e, opts);
+ expect(result?.stdout).toContain("[tool] read_file");
+ expect(result?.stdout).toContain('"/foo"');
+ });
+
+ it("renders tool-output data as stdout", () => {
+ const e: AgentEvent = {
+ type: "tool-output",
+ conversationId: "c",
+ turnId: "t",
+ toolCallId: "tc1",
+ data: "some output",
+ stream: "stdout",
+ };
+ expect(renderEvent(e, opts)).toEqual({ stdout: "some output" });
+ });
+
+ it("renders tool-result without error", () => {
+ const e: AgentEvent = {
+ type: "tool-result",
+ conversationId: "c",
+ turnId: "t",
+ toolCallId: "tc1",
+ toolName: "read_file",
+ content: "file contents",
+ isError: false,
+ };
+ expect(renderEvent(e, opts)).toEqual({
+ stdout: "[tool:read_file] file contents\n",
+ });
+ });
+
+ it("renders tool-result with error flag", () => {
+ const e: AgentEvent = {
+ type: "tool-result",
+ conversationId: "c",
+ turnId: "t",
+ toolCallId: "tc1",
+ toolName: "read_file",
+ content: "not found",
+ isError: true,
+ };
+ expect(renderEvent(e, opts)).toEqual({
+ stdout: "[tool:read_file] ERROR not found\n",
+ });
+ });
+
+ it("renders usage event", () => {
+ const e: AgentEvent = {
+ type: "usage",
+ conversationId: "c",
+ turnId: "t",
+ usage: { inputTokens: 100, outputTokens: 50 },
+ };
+ expect(renderEvent(e, opts)).toEqual({
+ stdout: "\n[usage] in=100 out=50\n",
+ });
+ });
+
+ it("renders error event to stderr", () => {
+ const e: AgentEvent = {
+ type: "error",
+ conversationId: "c",
+ turnId: "t",
+ message: "something went wrong",
+ };
+ expect(renderEvent(e, opts)).toEqual({
+ stderr: "[error] something went wrong\n",
+ });
+ });
+
+ it("returns undefined for status", () => {
+ const e: AgentEvent = {
+ type: "status",
+ conversationId: "c",
+ status: "running",
+ };
+ expect(renderEvent(e, opts)).toBeUndefined();
+ });
+
+ it("returns undefined for turn-start", () => {
+ const e: AgentEvent = { type: "turn-start", conversationId: "c", turnId: "t" };
+ expect(renderEvent(e, opts)).toBeUndefined();
+ });
+
+ it("returns undefined for turn-sealed", () => {
+ const e: AgentEvent = { type: "turn-sealed", conversationId: "c", turnId: "t" };
+ expect(renderEvent(e, opts)).toBeUndefined();
+ });
+
+ it("returns undefined for done", () => {
+ const e: AgentEvent = {
+ type: "done",
+ conversationId: "c",
+ turnId: "t",
+ reason: "completed",
+ };
+ expect(renderEvent(e, opts)).toBeUndefined();
+ });
+});
diff --git a/packages/cli/src/render.ts b/packages/cli/src/render.ts
new file mode 100644
index 0000000..1853963
--- /dev/null
+++ b/packages/cli/src/render.ts
@@ -0,0 +1,45 @@
+/**
+ * Pure event renderer — zero I/O.
+ *
+ * Maps an AgentEvent to optional stdout/stderr strings.
+ * Consumers write these to process.stdout / process.stderr.
+ */
+
+import type { AgentEvent } from "@dispatch/transport-contract";
+
+interface RenderOpts {
+ readonly showReasoning: boolean;
+}
+
+interface RenderOutput {
+ readonly stdout?: string;
+ readonly stderr?: string;
+}
+
+export function renderEvent(e: AgentEvent, opts: RenderOpts): RenderOutput | undefined {
+ switch (e.type) {
+ case "text-delta":
+ return { stdout: e.delta };
+ case "reasoning-delta":
+ return opts.showReasoning ? { stdout: e.delta } : undefined;
+ case "tool-call":
+ return { stdout: `\n[tool] ${e.toolName} ${JSON.stringify(e.input)}\n` };
+ case "tool-output":
+ return { stdout: e.data };
+ case "tool-result":
+ return {
+ stdout: `[tool:${e.toolName}]${e.isError ? " ERROR" : ""} ${e.content}\n`,
+ };
+ case "usage":
+ return {
+ stdout: `\n[usage] in=${e.usage.inputTokens} out=${e.usage.outputTokens}\n`,
+ };
+ case "error":
+ return { stderr: `[error] ${e.message}\n` };
+ case "status":
+ case "turn-start":
+ case "turn-sealed":
+ case "done":
+ return undefined;
+ }
+}
diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json
new file mode 100644
index 0000000..e011575
--- /dev/null
+++ b/packages/cli/tsconfig.json
@@ -0,0 +1,6 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true },
+ "include": ["src/**/*.ts"],
+ "references": [{ "path": "../transport-contract" }]
+}
diff --git a/tasks.md b/tasks.md
index fe88001..6e5732e 100644
--- a/tasks.md
+++ b/tasks.md
@@ -287,7 +287,7 @@ reports/phase-a-{kernel-logging,journal-sink}.md.
## ROADMAP — what's next (user-decided, §5.2)
-### 1. CLI (first, before web frontend)
+### 1. CLI (first, before web frontend) [x] MVP DONE + verified live
A **CLI** (NOT a TUI — line-oriented command-line interface; may have basic selectors
for things like picking a conversation). Terminal client for Dispatch: send a message,
render the streamed multi-turn response (`conversationId` threads history). Same
@@ -296,6 +296,34 @@ state / typed contracts / one owner per unit / asymmetric testing. A design pass
— seed `notes/cli-design.md` (IDEATION with the user, like `notes/observability-design.md`
and `notes/frontend-design.md`) before any summon.
+**Design decisions (user, see `notes/cli-design.md` §3):** HTTP client for BOTH the CLI and
+the future (separate-repo) web frontend; one-shot invocation; named **credentials** addressed
+as **model names** `<credentialName>/<model>`; per-provider `listModels()`; `--cwd` threaded
+to tools (cache-safe); `provider-openai-compat` keeps opencode-go specifics (deferred split
+noted in code). New vocab in GLOSSARY: credential / key / model name / model catalog.
+
+**Built (full fidelity, owner-agent per unit, mimo-v2.5-pro):**
+- **contracts (orchestrator):** `ProviderContract.listModels()`+`ModelInfo`, `RunTurnInput.cwd`,
+ `ToolExecuteContext.cwd` (all additive/optional).
+- **`transport-contract`** (NEW, types-only): `ChatRequest`/`ModelsResponse` + re-export
+ `AgentEvent` — the HTTP API contract every client imports.
+- **`credential-store`** (NEW core ext): named credentials + `resolve(modelName)` +
+ `listCatalog()`; typed `credentialStoreHandle`.
+- **`cli`** (NEW bundled pkg): pure core (arg parse / render / ndjson / message / catalog) +
+ injected shell (fetch NDJSON client, fs, stdout). HTTP client of the wire contract only.
+- **modified:** kernel-runtime (cwd thread), provider-openai-compat (`listModels` + code note),
+ tool-read-file (`ctx.cwd` honored), session-orchestrator (modelName+cwd; `resolveModel` dep
+ wired via the handle), transport-http (`GET /models` + model/cwd on `/chat`), host-bin
+ (registers credential-store w/ the `opencode` credential).
+
+**RESULT:** typecheck clean, **429 vitest + 72 bun = 501 tests**, biome 0/0. **Verified LIVE**
+(opencode-go flash on :24203): `GET /models` → 18 `opencode/*` models; `dispatch models`;
+chat `opencode/deepseek-v4-flash` → "Hello there friend."; `--cwd` → live `read_file` round-trip
+resolving against cwd (VELVET-WALRUS-93); `--file` folded into the message; `--conversation`
+threaded multi-turn (PINEAPPLE recalled). Run: `bun packages/cli/src/main.ts <args>`.
+Phasing: contracts → [kernel-runtime] → [provider ∥ tool ∥ credential-store ∥ cli] →
+session-orchestrator (+extension wiring) → transport-http → host-bin → live integration.
+
### 2. Web frontend (after CLI)
Svelte + DaisyUI (same stack as old Dispatch). **Methodology mirror** — same constraints
as the backend and the CLI. Design scratch lives at `notes/frontend-design.md` (IDEATION
diff --git a/tsconfig.json b/tsconfig.json
index 9a64819..2935e09 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -11,6 +11,7 @@
{ "path": "./packages/session-orchestrator" },
{ "path": "./packages/transport-http" },
{ "path": "./packages/tool-read-file" },
+ { "path": "./packages/cli" },
{ "path": "./packages/journal-sink" },
{ "path": "./packages/trace-store" },
{ "path": "./packages/observability-collector" },