From d4b470ca582e3c69c40438895b90748d44fc4653 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Thu, 25 Jun 2026 10:30:41 +0900 Subject: feat(cli): add --file flag to 'dispatch send' subcommand Add the same --file support that the summon (chat) command has to the 'dispatch send' subcommand. When --file is given, the file's contents are read and attached to the message (composed via composeMessage, identical to chat). - args.ts: add 'file' to the send ParsedCommand, make 'text' optional, parse --file, and require at least one of --text or --file. - main.ts: read the file and compose the message in the send case, using the composed message in both the --queue and streaming branches; update USAGE. - args.test.ts: cover --file parsing (alone, with --text, missing value) and update the existing send expectations + the both-missing error message. --- packages/cli/src/args.test.ts | 33 +++++++++++++++++++++++++++++++-- packages/cli/src/args.ts | 16 +++++++++++++--- packages/cli/src/main.ts | 16 +++++++++++++--- 3 files changed, 57 insertions(+), 8 deletions(-) (limited to 'packages/cli') diff --git a/packages/cli/src/args.test.ts b/packages/cli/src/args.test.ts index 3d07c96..7a45c02 100644 --- a/packages/cli/src/args.test.ts +++ b/packages/cli/src/args.test.ts @@ -320,11 +320,31 @@ describe("parseArgs", () => { server: "http://localhost:24203", conversationId: "deadbeef", text: "hi", + file: undefined, + queue: false, + open: false, + }); + }); + + it("parses 'send' with --file", () => { + expect(parseArgs(["send", "deadbeef", "--file", "foo.txt"], { defaultServer })).toEqual({ + kind: "send", + server: "http://localhost:24203", + conversationId: "deadbeef", + text: undefined, + file: "foo.txt", queue: false, open: false, }); }); + it("parses 'send' with both --text and --file", () => { + const result = parseArgs(["send", "deadbeef", "--text", "hi", "--file", "f.txt"], { + defaultServer, + }); + expect(result).toMatchObject({ kind: "send", text: "hi", file: "f.txt" }); + }); + it("parses 'send' with --queue", () => { const result = parseArgs(["send", "deadbeef", "--text", "hi", "--queue"], { defaultServer, @@ -334,6 +354,7 @@ describe("parseArgs", () => { server: "http://localhost:24203", conversationId: "deadbeef", text: "hi", + file: undefined, queue: true, open: false, }); @@ -348,6 +369,7 @@ describe("parseArgs", () => { server: "http://localhost:24203", conversationId: "deadbeef", text: "hi", + file: undefined, queue: false, open: true, }); @@ -363,6 +385,7 @@ describe("parseArgs", () => { server: "http://localhost:24203", conversationId: "deadbeef", text: "hi", + file: undefined, queue: false, open: false, cwd: "/tmp", @@ -370,10 +393,10 @@ describe("parseArgs", () => { }); }); - it("requires --text", () => { + it("errors when --text and --file are both missing", () => { const result = parseArgs(["send", "deadbeef"], { defaultServer }); expect(result.kind).toBe("error"); - if (result.kind === "error") expect(result.message).toContain("--text"); + if (result.kind === "error") expect(result.message).toContain("--text or --file"); }); it("requires a conversation id", () => { @@ -386,6 +409,12 @@ describe("parseArgs", () => { const result = parseArgs(["send", "deadbeef", "--text"], { defaultServer }); expect(result.kind).toBe("error"); }); + + it("errors when --file has no value", () => { + const result = parseArgs(["send", "deadbeef", "--file"], { defaultServer }); + expect(result.kind).toBe("error"); + if (result.kind === "error") expect(result.message).toContain("--file requires a value"); + }); }); describe("open", () => { diff --git a/packages/cli/src/args.ts b/packages/cli/src/args.ts index 8a63777..aaad2de 100644 --- a/packages/cli/src/args.ts +++ b/packages/cli/src/args.ts @@ -42,7 +42,8 @@ export type ParsedCommand = readonly kind: "send"; readonly server: string; readonly conversationId: string; - readonly text: string; + readonly text?: string | undefined; + readonly file?: string | undefined; readonly queue: boolean; readonly open: boolean; readonly cwd?: string; @@ -204,6 +205,7 @@ export function parseArgs(argv: readonly string[], opts: ParseOpts): ParsedComma let server = opts.defaultServer; let conversationId: string | undefined; let text: string | undefined; + let file: string | undefined; let queue = false; let open = false; let cwd: string | undefined; @@ -221,6 +223,10 @@ export function parseArgs(argv: readonly string[], opts: ParseOpts): ParsedComma 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 "--queue": queue = true; break; @@ -263,8 +269,11 @@ export function parseArgs(argv: readonly string[], opts: ParseOpts): ParsedComma if (conversationId === undefined) { return { kind: "error", message: "'send' requires a conversation id" }; } - if (text === undefined) { - return { kind: "error", message: "'send' requires --text" }; + if (!text && !file) { + return { + kind: "error", + message: "At least one of --text or --file is required for 'send'", + }; } return { @@ -272,6 +281,7 @@ export function parseArgs(argv: readonly string[], opts: ParseOpts): ParsedComma server, conversationId, text, + file, queue, open, ...(cwd !== undefined && { cwd }), diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index 9dfc317..b61be07 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -29,7 +29,7 @@ const USAGE = `Usage: dispatch compact [--server ] dispatch read [--server ] dispatch open [--server ] - dispatch send --text "..." [--queue] [--open] [--cwd ] [--effort ] [--workspace ] [--server ] + dispatch send --text "..." [--file ] [--queue] [--open] [--cwd ] [--effort ] [--workspace ] [--server ] dispatch --text "..." [--file ] [--cwd ] [--conversation ] [--effort ] [--workspace ] [--server ] [--show-reasoning] [--open] dispatch --help @@ -156,10 +156,20 @@ async function main(): Promise { process.stdout.write(`Signaled frontend to open ${conversationId}\n`); } + let fileContent: string | undefined; + if (parsed.file) { + fileContent = await readFile(parsed.file, "utf-8"); + } + const message = composeMessage({ + ...(parsed.text !== undefined && { text: parsed.text }), + ...(parsed.file !== undefined && { file: parsed.file }), + ...(fileContent !== undefined && { fileContent }), + }); + if (parsed.queue) { const queued = await enqueueMessage( { fetchImpl: globalThis.fetch }, - { server: parsed.server, conversationId, text: parsed.text }, + { server: parsed.server, conversationId, text: message }, ); const line = queued.startedTurn ? `Started turn for ${conversationId}` @@ -168,7 +178,7 @@ async function main(): Promise { } else { const request = { conversationId, - message: parsed.text, + message, ...(parsed.cwd !== undefined && { cwd: parsed.cwd }), ...(parsed.reasoningEffort !== undefined && { reasoningEffort: parsed.reasoningEffort }), ...(parsed.workspaceId !== undefined && { workspaceId: parsed.workspaceId }), -- cgit v1.2.3 From c1bc7bfaaca7bdf4d9b2973f5dc88605217a7866 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Thu, 25 Jun 2026 11:27:43 +0900 Subject: feat(cli): add --workspace filter to 'dispatch list' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backend already supported GET /conversations?workspaceId= but the CLI never sent it. Wire the list command to that filter: - args.ts: parse --workspace / -w on 'list' (placed before the --catch-all so the single-dash -w shorthand isn't taken for a positional prefix); add workspaceId? to the list ParsedCommand. - http.ts: add workspaceId? to FetchConversationsOpts; send ?workspaceId= (after q/status, preserving URLSearchParams order). - main.ts: forward parsed.workspaceId into fetchConversations; update USAGE. Composable with --status and the short-id arg. 'Open conversations in workspace X' is now: dispatch list --workspace X (status defaults to active,idle). No contract changes — purely additive CLI wiring. Tests: +4 args (incl. composability + missing-value error), +2 http (exact ?workspaceId= URL + combined status/workspaceId with %2C encoding). typecheck EXIT 0, biome clean (364 files), full suite 1558 passed. Live-verified against an isolated server. --- packages/cli/src/args.test.ts | 35 +++++++++++++++++++++++++++++++++++ packages/cli/src/args.ts | 6 ++++++ packages/cli/src/http.test.ts | 30 ++++++++++++++++++++++++++++++ packages/cli/src/http.ts | 2 ++ packages/cli/src/main.ts | 3 ++- 5 files changed, 75 insertions(+), 1 deletion(-) (limited to 'packages/cli') diff --git a/packages/cli/src/args.test.ts b/packages/cli/src/args.test.ts index 7a45c02..e613f31 100644 --- a/packages/cli/src/args.test.ts +++ b/packages/cli/src/args.test.ts @@ -254,6 +254,41 @@ describe("parseArgs", () => { }); }); + it("parses 'list' with --workspace", () => { + expect(parseArgs(["list", "--workspace", "proj"], { defaultServer })).toEqual({ + kind: "list", + server: "http://localhost:24203", + workspaceId: "proj", + all: false, + }); + }); + + it("parses 'list' with -w shorthand", () => { + const result = parseArgs(["list", "-w", "ws"], { defaultServer }); + expect(result.kind).toBe("list"); + if (result.kind === "list") expect(result.workspaceId).toBe("ws"); + }); + + it("parses 'list' with --workspace, --status, and a prefix together", () => { + const result = parseArgs(["list", "abc", "--status", "active", "--workspace", "proj"], { + defaultServer, + }); + expect(result).toEqual({ + kind: "list", + server: "http://localhost:24203", + query: "abc", + status: "active", + workspaceId: "proj", + all: false, + }); + }); + + it("errors when --workspace has no value (list)", () => { + const result = parseArgs(["list", "--workspace"], { defaultServer }); + expect(result.kind).toBe("error"); + if (result.kind === "error") expect(result.message).toContain("--workspace requires a value"); + }); + it("parses 'list' with --all", () => { expect(parseArgs(["list", "--all"], { defaultServer })).toEqual({ kind: "list", diff --git a/packages/cli/src/args.ts b/packages/cli/src/args.ts index aaad2de..74cc56a 100644 --- a/packages/cli/src/args.ts +++ b/packages/cli/src/args.ts @@ -33,6 +33,7 @@ export type ParsedCommand = readonly server: string; readonly query?: string; readonly status?: string; + readonly workspaceId?: string; readonly all: boolean; } | { readonly kind: "compact"; readonly server: string; readonly conversationId: string } @@ -85,6 +86,7 @@ export function parseArgs(argv: readonly string[], opts: ParseOpts): ParsedComma let server = opts.defaultServer; let query: string | undefined; let status: string | undefined; + let workspaceId: string | undefined; let all = false; for (let i = 1; i < argv.length; i++) { const arg = argv[i] as string; @@ -94,6 +96,9 @@ export function parseArgs(argv: readonly string[], opts: ParseOpts): ParsedComma } else if (arg === "--status") { if (i + 1 >= argv.length) return { kind: "error", message: "--status requires a value" }; status = argv[++i]; + } else if (arg === "--workspace" || arg === "-w") { + if (i + 1 >= argv.length) return { kind: "error", message: "--workspace requires a value" }; + workspaceId = argv[++i]; } else if (arg === "--all") { all = true; } else if (arg.startsWith("--")) { @@ -109,6 +114,7 @@ export function parseArgs(argv: readonly string[], opts: ParseOpts): ParsedComma server, ...(query !== undefined && { query }), ...(status !== undefined && { status }), + ...(workspaceId !== undefined && { workspaceId }), all, }; } diff --git a/packages/cli/src/http.test.ts b/packages/cli/src/http.test.ts index 2aa61e9..ab39813 100644 --- a/packages/cli/src/http.test.ts +++ b/packages/cli/src/http.test.ts @@ -289,6 +289,36 @@ describe("fetchConversations", () => { expect(calledUrl).toBe("http://localhost:24203/conversations?q=abc+def"); }); + it("appends ?workspaceId= when a workspaceId is given", async () => { + let calledUrl: string | undefined; + const fakeFetch = (async (url: string | URL | Request): Promise => { + calledUrl = String(url); + return new Response(JSON.stringify({ conversations: [] }), { status: 200 }); + }) as unknown as typeof fetch; + + await fetchConversations( + { fetchImpl: fakeFetch }, + { server: "http://localhost:24203", workspaceId: "proj" }, + ); + expect(calledUrl).toBe("http://localhost:24203/conversations?workspaceId=proj"); + }); + + it("combines ?status= and ?workspaceId= when both are given", async () => { + let calledUrl: string | undefined; + const fakeFetch = (async (url: string | URL | Request): Promise => { + calledUrl = String(url); + return new Response(JSON.stringify({ conversations: [] }), { status: 200 }); + }) as unknown as typeof fetch; + + await fetchConversations( + { fetchImpl: fakeFetch }, + { server: "http://localhost:24203", status: "active,idle", workspaceId: "proj" }, + ); + expect(calledUrl).toBe( + "http://localhost:24203/conversations?status=active%2Cidle&workspaceId=proj", + ); + }); + it("throws on non-OK status", async () => { const fakeFetch = (async (): Promise => new Response("boom", { status: 500 })) as unknown as typeof fetch; diff --git a/packages/cli/src/http.ts b/packages/cli/src/http.ts index 42fcfec..e13842a 100644 --- a/packages/cli/src/http.ts +++ b/packages/cli/src/http.ts @@ -98,6 +98,7 @@ interface FetchConversationsOpts { readonly server: string; readonly query?: string; readonly status?: string; + readonly workspaceId?: string; } export async function fetchConversations( @@ -107,6 +108,7 @@ export async function fetchConversations( const params = new URLSearchParams(); if (opts.query !== undefined) params.set("q", opts.query); if (opts.status !== undefined) params.set("status", opts.status); + if (opts.workspaceId !== undefined) params.set("workspaceId", opts.workspaceId); const qs = params.toString(); const url = qs.length > 0 ? `${opts.server}/conversations?${qs}` : `${opts.server}/conversations`; const res = await deps.fetchImpl(url); diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index b61be07..cba0de7 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -24,7 +24,7 @@ import { extractLastText, formatConversationList, renderEvent } from "./render.j const USAGE = `Usage: dispatch models [--server ] - dispatch list [] [--status ] [--all] [--server ] + dispatch list [] [--status ] [--workspace ] [--all] [--server ] dispatch stop [--server ] dispatch compact [--server ] dispatch read [--server ] @@ -61,6 +61,7 @@ async function main(): Promise { server: parsed.server, ...(parsed.query !== undefined && { query: parsed.query }), ...(status !== undefined && { status }), + ...(parsed.workspaceId !== undefined && { workspaceId: parsed.workspaceId }), }, ); const table = formatConversationList(result.conversations, Date.now()); -- cgit v1.2.3