diff options
| author | Adam Malczewski <[email protected]> | 2026-06-05 21:20:34 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-05 21:20:34 +0900 |
| commit | 552c22d74e5df915088d9e9ff4a286c96c2a54d6 (patch) | |
| tree | 7d9db1052bab91ef994446d80efc3bfc38026cad | |
| parent | 7fb3269c698ae583ea7997ce206c4ae252fd3218 (diff) | |
| download | dispatch-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-- | .gitignore | 1 | ||||
| -rw-r--r-- | GLOSSARY.md | 4 | ||||
| -rw-r--r-- | ORCHESTRATOR.md | 7 | ||||
| -rw-r--r-- | notes/cli-design.md | 196 | ||||
| -rw-r--r-- | package.json | 3 | ||||
| -rw-r--r-- | packages/cli/package.json | 11 | ||||
| -rw-r--r-- | packages/cli/src/args.test.ts | 140 | ||||
| -rw-r--r-- | packages/cli/src/args.ts | 108 | ||||
| -rw-r--r-- | packages/cli/src/catalog.test.ts | 18 | ||||
| -rw-r--r-- | packages/cli/src/catalog.ts | 11 | ||||
| -rw-r--r-- | packages/cli/src/http.test.ts | 164 | ||||
| -rw-r--r-- | packages/cli/src/http.ts | 86 | ||||
| -rw-r--r-- | packages/cli/src/index.ts | 12 | ||||
| -rw-r--r-- | packages/cli/src/main.ts | 74 | ||||
| -rw-r--r-- | packages/cli/src/message.test.ts | 81 | ||||
| -rw-r--r-- | packages/cli/src/message.ts | 55 | ||||
| -rw-r--r-- | packages/cli/src/ndjson.test.ts | 48 | ||||
| -rw-r--r-- | packages/cli/src/ndjson.ts | 18 | ||||
| -rw-r--r-- | packages/cli/src/render.test.ts | 147 | ||||
| -rw-r--r-- | packages/cli/src/render.ts | 45 | ||||
| -rw-r--r-- | packages/cli/tsconfig.json | 6 | ||||
| -rw-r--r-- | tasks.md | 30 | ||||
| -rw-r--r-- | tsconfig.json | 1 |
23 files changed, 1238 insertions, 28 deletions
@@ -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" }] +} @@ -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" }, |
