summaryrefslogtreecommitdiffhomepage
path: root/packages/cli/src/http.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-21 19:20:10 +0900
committerAdam Malczewski <[email protected]>2026-06-21 19:20:10 +0900
commitc5e9fd6cd6565b55fab1bf2b9d8dacf8ba72a9f4 (patch)
tree809bd93eaa25646f237fb4b1ddd3719e25aaca90 /packages/cli/src/http.ts
parentea0e938eca3072649dc8707c999ec00cf87b986a (diff)
downloaddispatch-c5e9fd6cd6565b55fab1bf2b9d8dacf8ba72a9f4.tar.gz
dispatch-c5e9fd6cd6565b55fab1bf2b9d8dacf8ba72a9f4.zip
feat(cli): list, read, send commands (Wave 3)
CLI gains three new sub-commands: - dispatch list [--server] — list conversations (short ID + title + activity) - dispatch read <id> [--server] — block until turn settles, print last AI message - dispatch send <id> --text [--queue] [--open] [--cwd] [--effort] [--server] - Default: blocking (consumes NDJSON stream, prints accumulated text + conv ID) - --queue: non-blocking (POST /conversations/:id/queue, exit immediately) - --open: signals frontend to open the conversation tab (POST /conversations/:id/open) Short-ID resolution: 4+ char prefix → GET /conversations?q= → resolve to full ID. 32+ char input is treated as a full UUID (no resolution). Errors on 0 or >1 matches. 48 new tests (108 total in cli). Pure arg parser + HTTP client functions, zero vi.mock.
Diffstat (limited to 'packages/cli/src/http.ts')
-rw-r--r--packages/cli/src/http.ts142
1 files changed, 141 insertions, 1 deletions
diff --git a/packages/cli/src/http.ts b/packages/cli/src/http.ts
index 5e61afb..8434519 100644
--- a/packages/cli/src/http.ts
+++ b/packages/cli/src/http.ts
@@ -7,7 +7,15 @@
* The fetchImpl dependency is injected (outermost edge mock allowed).
*/
-import type { AgentEvent, ChatRequest, ModelsResponse } from "@dispatch/transport-contract";
+import type {
+ AgentEvent,
+ ChatRequest,
+ ConversationListResponse,
+ LastMessageResponse,
+ ModelsResponse,
+ OpenConversationResponse,
+ QueueResponse,
+} from "@dispatch/transport-contract";
import { splitNdjsonLines } from "./ndjson.js";
interface FetchDeps {
@@ -84,3 +92,135 @@ export async function fetchModels(deps: FetchDeps, opts: FetchModelsOpts): Promi
return (await res.json()) as ModelsResponse;
}
+
+interface FetchConversationsOpts {
+ readonly server: string;
+ readonly query?: string;
+}
+
+export async function fetchConversations(
+ deps: FetchDeps,
+ opts: FetchConversationsOpts,
+): Promise<ConversationListResponse> {
+ const url =
+ opts.query !== undefined
+ ? `${opts.server}/conversations?q=${encodeURIComponent(opts.query)}`
+ : `${opts.server}/conversations`;
+ const res = await deps.fetchImpl(url);
+
+ if (!res.ok) {
+ const body = await res.text();
+ throw new Error(`GET /conversations failed with status ${res.status}: ${body}`);
+ }
+
+ return (await res.json()) as ConversationListResponse;
+}
+
+interface FetchLastMessageOpts {
+ readonly server: string;
+ readonly conversationId: string;
+}
+
+export async function fetchLastMessage(
+ deps: FetchDeps,
+ opts: FetchLastMessageOpts,
+): Promise<LastMessageResponse> {
+ const url = `${opts.server}/conversations/${encodeURIComponent(opts.conversationId)}/last`;
+ const res = await deps.fetchImpl(url);
+
+ if (!res.ok) {
+ const body = await res.text();
+ throw new Error(`GET /conversations/:id/last failed with status ${res.status}: ${body}`);
+ }
+
+ return (await res.json()) as LastMessageResponse;
+}
+
+interface EnqueueMessageOpts {
+ readonly server: string;
+ readonly conversationId: string;
+ readonly text: string;
+}
+
+export async function enqueueMessage(
+ deps: FetchDeps,
+ opts: EnqueueMessageOpts,
+): Promise<QueueResponse> {
+ const url = `${opts.server}/conversations/${encodeURIComponent(opts.conversationId)}/queue`;
+ const res = await deps.fetchImpl(url, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ text: opts.text }),
+ });
+
+ if (!res.ok) {
+ const body = await res.text();
+ throw new Error(`POST /conversations/:id/queue failed with status ${res.status}: ${body}`);
+ }
+
+ return (await res.json()) as QueueResponse;
+}
+
+interface OpenConversationOpts {
+ readonly server: string;
+ readonly conversationId: string;
+}
+
+export async function openConversation(
+ deps: FetchDeps,
+ opts: OpenConversationOpts,
+): Promise<OpenConversationResponse> {
+ const url = `${opts.server}/conversations/${encodeURIComponent(opts.conversationId)}/open`;
+ const res = await deps.fetchImpl(url, { method: "POST" });
+
+ if (!res.ok) {
+ const body = await res.text();
+ throw new Error(`POST /conversations/:id/open failed with status ${res.status}: ${body}`);
+ }
+
+ return (await res.json()) as OpenConversationResponse;
+}
+
+/**
+ * The outcome of short-ID resolution: either the full conversation id to use,
+ * or a human-readable error describing why resolution failed.
+ */
+export type ConversationIdResolution = string | { readonly error: string };
+
+interface ResolveConversationIdOpts {
+ readonly server: string;
+ readonly shortId: string;
+}
+
+/**
+ * Resolve a user-typed conversation prefix to a full id. A 32+ char input is
+ * assumed to be a full UUID and returned untouched. Otherwise the conversation
+ * list is filtered by the prefix: 1 match → its id; 0 → error; >1 → error with
+ * the candidate short ids + titles so the user can disambiguate.
+ */
+export async function resolveConversationId(
+ deps: FetchDeps,
+ opts: ResolveConversationIdOpts,
+): Promise<ConversationIdResolution> {
+ if (opts.shortId.length >= 32) {
+ return opts.shortId;
+ }
+
+ const list = await fetchConversations(deps, { server: opts.server, query: opts.shortId });
+ const matches = list.conversations;
+
+ if (matches.length === 0) {
+ return { error: `No conversation matching "${opts.shortId}"` };
+ }
+
+ if (matches.length === 1) {
+ const only = matches[0];
+ if (only === undefined) return { error: `No conversation matching "${opts.shortId}"` };
+ return only.id;
+ }
+
+ const lines = matches.map((m) => `${m.id.slice(0, 8)} ${m.title}`).join("\n");
+ return {
+ error: `Multiple conversations matching "${opts.shortId}"\n${lines}`,
+ };
+}