summaryrefslogtreecommitdiffhomepage
path: root/packages/cli/src/http.ts
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 /packages/cli/src/http.ts
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.
Diffstat (limited to 'packages/cli/src/http.ts')
-rw-r--r--packages/cli/src/http.ts86
1 files changed, 86 insertions, 0 deletions
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;
+}