@dench.com/cli 0.3.2 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/chat-spawn.ts ADDED
@@ -0,0 +1,370 @@
1
+ /**
2
+ * `dench chat new` / `dench chat send` — spawn a chat-turn workflow
3
+ * from the CLI, exactly the same workflow the web UI uses. Threads
4
+ * created here are visible in `dench chat list` and the UI's chat
5
+ * thread list immediately, since they go through the same Convex
6
+ * tables (`chatThreads` + `chatMessages`).
7
+ *
8
+ * Aliased as `dench agent new` / `dench agent send` so users who
9
+ * grep for "agent" find them.
10
+ *
11
+ * Both subcommands POST to `/api/chat/cli` (auth: same `dch_agent_*`
12
+ * session token or Dench API key as the rest of the CLI). The
13
+ * server creates the thread + queued run, kicks off
14
+ * `chatTurnWorkflow`, and returns ids + URLs. With `--follow` the
15
+ * CLI then opens the durable resumable stream
16
+ * (`/api/chat/[runId]/stream`) and pretty-prints text deltas + tool
17
+ * calls until the workflow finishes.
18
+ */
19
+
20
+ import { hasFlag } from "./lib/cli-args";
21
+
22
+ class ChatSpawnError extends Error {}
23
+
24
+ type RuntimeBundle = {
25
+ host: string;
26
+ bearerToken: string;
27
+ };
28
+
29
+ type ChatSpawnContext = {
30
+ runtime: RuntimeBundle;
31
+ args: string[];
32
+ jsonOutput: boolean;
33
+ };
34
+
35
+ function consumeFlagValue(args: string[], name: string): string | undefined {
36
+ const idx = args.indexOf(name);
37
+ if (idx === -1) return undefined;
38
+ const value = args[idx + 1];
39
+ args.splice(idx, 2);
40
+ return value;
41
+ }
42
+
43
+ function consumeFlag(args: string[], name: string): boolean {
44
+ const idx = args.indexOf(name);
45
+ if (idx === -1) return false;
46
+ args.splice(idx, 1);
47
+ return true;
48
+ }
49
+
50
+ function out(ctx: ChatSpawnContext, value: unknown): void {
51
+ if (ctx.jsonOutput) {
52
+ console.log(JSON.stringify(value, null, 2));
53
+ return;
54
+ }
55
+ if (value === null || value === undefined) {
56
+ console.log("(empty)");
57
+ return;
58
+ }
59
+ console.log(JSON.stringify(value, null, 2));
60
+ }
61
+
62
+ async function postJson(
63
+ url: string,
64
+ bearer: string,
65
+ body: unknown,
66
+ ): Promise<{ ok: boolean; status: number; payload: unknown }> {
67
+ const response = await fetch(url, {
68
+ method: "POST",
69
+ headers: {
70
+ accept: "application/json",
71
+ "content-type": "application/json",
72
+ authorization: `Bearer ${bearer}`,
73
+ },
74
+ body: JSON.stringify(body),
75
+ });
76
+ const text = await response.text();
77
+ let payload: unknown;
78
+ try {
79
+ payload = text ? JSON.parse(text) : null;
80
+ } catch {
81
+ payload = { raw: text };
82
+ }
83
+ return { ok: response.ok, status: response.status, payload };
84
+ }
85
+
86
+ function buildSpawnUrl(host: string): string {
87
+ return `${host.replace(/\/+$/, "")}/api/chat/cli`;
88
+ }
89
+
90
+ function buildStreamUrl(host: string, runId: string): string {
91
+ return `${host.replace(/\/+$/, "")}/api/chat/${encodeURIComponent(runId)}/stream`;
92
+ }
93
+
94
+ /**
95
+ * Subscribe to the durable chat stream and pretty-print useful chunks
96
+ * until the stream closes. Mirrors the chunk types the chat panel
97
+ * cares about (text deltas, reasoning, tool input/output, finish).
98
+ */
99
+ async function followStream(args: {
100
+ host: string;
101
+ bearerToken: string;
102
+ runId: string;
103
+ }): Promise<void> {
104
+ const url = buildStreamUrl(args.host, args.runId);
105
+ const response = await fetch(url, {
106
+ method: "GET",
107
+ headers: {
108
+ accept: "text/event-stream",
109
+ authorization: `Bearer ${args.bearerToken}`,
110
+ },
111
+ });
112
+ if (!response.ok || !response.body) {
113
+ throw new ChatSpawnError(
114
+ `Failed to open stream: ${response.status} ${response.statusText}`,
115
+ );
116
+ }
117
+
118
+ const reader = response.body.getReader();
119
+ const decoder = new TextDecoder();
120
+ let buffer = "";
121
+ // Tool calls in flight, keyed by toolCallId, so we can render
122
+ // a single line per call ("→ tool_name … done") instead of
123
+ // dumping every chunk verbatim.
124
+ const inFlightTools = new Map<string, { toolName: string }>();
125
+ let textBuffer = "";
126
+
127
+ const flushText = () => {
128
+ if (textBuffer) {
129
+ process.stdout.write(textBuffer);
130
+ textBuffer = "";
131
+ }
132
+ };
133
+
134
+ const handleChunk = (raw: string) => {
135
+ if (!raw.startsWith("data:")) return;
136
+ const data = raw.slice("data:".length).trim();
137
+ if (!data || data === "[DONE]") return;
138
+ let parsed: unknown;
139
+ try {
140
+ parsed = JSON.parse(data);
141
+ } catch {
142
+ return;
143
+ }
144
+ if (!parsed || typeof parsed !== "object") return;
145
+ const c = parsed as {
146
+ type?: unknown;
147
+ delta?: unknown;
148
+ text?: unknown;
149
+ toolName?: unknown;
150
+ toolCallId?: unknown;
151
+ input?: unknown;
152
+ output?: unknown;
153
+ errorText?: unknown;
154
+ };
155
+ switch (c.type) {
156
+ case "text-delta":
157
+ case "reasoning-delta": {
158
+ if (typeof c.delta === "string") {
159
+ textBuffer += c.delta;
160
+ }
161
+ break;
162
+ }
163
+ case "tool-input-start": {
164
+ flushText();
165
+ if (
166
+ typeof c.toolCallId === "string" &&
167
+ typeof c.toolName === "string"
168
+ ) {
169
+ inFlightTools.set(c.toolCallId, { toolName: c.toolName });
170
+ process.stdout.write(`\n→ ${c.toolName} …`);
171
+ }
172
+ break;
173
+ }
174
+ case "tool-output-available": {
175
+ flushText();
176
+ if (typeof c.toolCallId === "string") {
177
+ const meta = inFlightTools.get(c.toolCallId);
178
+ inFlightTools.delete(c.toolCallId);
179
+ process.stdout.write(` done (${meta?.toolName ?? "tool"})\n`);
180
+ }
181
+ break;
182
+ }
183
+ case "tool-output-error": {
184
+ flushText();
185
+ if (typeof c.toolCallId === "string") {
186
+ const meta = inFlightTools.get(c.toolCallId);
187
+ inFlightTools.delete(c.toolCallId);
188
+ process.stdout.write(
189
+ ` error (${meta?.toolName ?? "tool"}): ${
190
+ typeof c.errorText === "string" ? c.errorText : "unknown"
191
+ }\n`,
192
+ );
193
+ }
194
+ break;
195
+ }
196
+ case "finish": {
197
+ flushText();
198
+ process.stdout.write("\n");
199
+ break;
200
+ }
201
+ case "error": {
202
+ flushText();
203
+ const msg =
204
+ typeof c.errorText === "string" ? c.errorText : "stream error";
205
+ process.stderr.write(`\n[error] ${msg}\n`);
206
+ break;
207
+ }
208
+ default:
209
+ break;
210
+ }
211
+ };
212
+
213
+ // Read SSE-style chunks. The route uses Vercel AI SDK's
214
+ // `createUIMessageStreamResponse` which emits `data: {…}\n\n`
215
+ // lines, so we split on double newlines.
216
+ while (true) {
217
+ const { value, done } = await reader.read();
218
+ if (done) break;
219
+ buffer += decoder.decode(value, { stream: true });
220
+ let nextBreak = buffer.indexOf("\n\n");
221
+ while (nextBreak !== -1) {
222
+ const event = buffer.slice(0, nextBreak);
223
+ buffer = buffer.slice(nextBreak + 2);
224
+ // Each event may contain multiple `data:` lines.
225
+ for (const line of event.split("\n")) {
226
+ if (line.trim().length > 0) handleChunk(line);
227
+ }
228
+ nextBreak = buffer.indexOf("\n\n");
229
+ }
230
+ }
231
+ flushText();
232
+ }
233
+
234
+ function joinPositionalsExceptFlags(args: string[]): string {
235
+ return args.filter((arg) => !arg.startsWith("--")).join(" ").trim();
236
+ }
237
+
238
+ async function runChatNew(ctx: ChatSpawnContext): Promise<void> {
239
+ const model = consumeFlagValue(ctx.args, "--model");
240
+ const fileContextPath = consumeFlagValue(ctx.args, "--file-context");
241
+ const visibility = consumeFlagValue(ctx.args, "--visibility");
242
+ const title = consumeFlagValue(ctx.args, "--title");
243
+ const yolo = consumeFlag(ctx.args, "--yolo");
244
+ const follow = consumeFlag(ctx.args, "--follow");
245
+
246
+ const prompt = joinPositionalsExceptFlags(ctx.args);
247
+ if (!prompt) {
248
+ throw new ChatSpawnError(
249
+ 'Usage: dench chat new "<prompt>" [--model M] [--file-context PATH] [--visibility private|shared] [--title T] [--yolo] [--follow] [--json]',
250
+ );
251
+ }
252
+
253
+ const result = await postJson(
254
+ buildSpawnUrl(ctx.runtime.host),
255
+ ctx.runtime.bearerToken,
256
+ {
257
+ prompt,
258
+ model,
259
+ fileContextPath,
260
+ visibility,
261
+ title,
262
+ yolo,
263
+ },
264
+ );
265
+ if (!result.ok) {
266
+ const errorMessage =
267
+ (result.payload as { error?: string } | null)?.error ??
268
+ `Spawn failed (${result.status})`;
269
+ throw new ChatSpawnError(errorMessage);
270
+ }
271
+
272
+ const payload = result.payload as {
273
+ threadId?: string;
274
+ runId?: string;
275
+ threadUrl?: string;
276
+ streamUrl?: string;
277
+ } | null;
278
+ out(ctx, payload);
279
+
280
+ if (follow && payload?.runId) {
281
+ if (!ctx.jsonOutput) console.error("\n--- following stream ---");
282
+ await followStream({
283
+ host: ctx.runtime.host,
284
+ bearerToken: ctx.runtime.bearerToken,
285
+ runId: payload.runId,
286
+ });
287
+ }
288
+ }
289
+
290
+ async function runChatSend(ctx: ChatSpawnContext): Promise<void> {
291
+ const yolo = consumeFlag(ctx.args, "--yolo");
292
+ const follow = consumeFlag(ctx.args, "--follow");
293
+ const model = consumeFlagValue(ctx.args, "--model");
294
+
295
+ // First positional = threadId, the rest joined = prompt.
296
+ const positionals = ctx.args.filter((arg) => !arg.startsWith("--"));
297
+ const threadId = positionals.shift();
298
+ if (!threadId) {
299
+ throw new ChatSpawnError(
300
+ 'Usage: dench chat send <threadId> "<message>" [--model M] [--yolo] [--follow] [--json]',
301
+ );
302
+ }
303
+ const prompt = positionals.join(" ").trim();
304
+ if (!prompt) {
305
+ throw new ChatSpawnError(
306
+ 'Missing message body. Usage: dench chat send <threadId> "<message>"',
307
+ );
308
+ }
309
+
310
+ const result = await postJson(
311
+ buildSpawnUrl(ctx.runtime.host),
312
+ ctx.runtime.bearerToken,
313
+ {
314
+ threadId,
315
+ prompt,
316
+ model,
317
+ yolo,
318
+ },
319
+ );
320
+ if (!result.ok) {
321
+ const errorMessage =
322
+ (result.payload as { error?: string } | null)?.error ??
323
+ `Send failed (${result.status})`;
324
+ throw new ChatSpawnError(errorMessage);
325
+ }
326
+
327
+ const payload = result.payload as {
328
+ threadId?: string;
329
+ runId?: string;
330
+ threadUrl?: string;
331
+ streamUrl?: string;
332
+ } | null;
333
+ out(ctx, payload);
334
+
335
+ if (follow && payload?.runId) {
336
+ if (!ctx.jsonOutput) console.error("\n--- following stream ---");
337
+ await followStream({
338
+ host: ctx.runtime.host,
339
+ bearerToken: ctx.runtime.bearerToken,
340
+ runId: payload.runId,
341
+ });
342
+ }
343
+ }
344
+
345
+ /**
346
+ * Public entry point invoked by the chat dispatcher in cli/dench.ts
347
+ * (and the agent dispatcher when the user runs `dench agent new` /
348
+ * `dench agent send` aliases). The CLI dispatcher resolves the
349
+ * runtime + bearer token once and hands them in.
350
+ */
351
+ export async function runChatSpawnCommand(opts: {
352
+ runtime: RuntimeBundle;
353
+ args: string[];
354
+ subcommand: "new" | "send";
355
+ }): Promise<void> {
356
+ const args = [...opts.args];
357
+ const jsonOutput = hasFlag(args, "--json");
358
+ const ctx: ChatSpawnContext = {
359
+ runtime: opts.runtime,
360
+ args,
361
+ jsonOutput,
362
+ };
363
+ if (opts.subcommand === "new") {
364
+ await runChatNew(ctx);
365
+ return;
366
+ }
367
+ await runChatSend(ctx);
368
+ }
369
+
370
+ export { ChatSpawnError };
package/chat.ts ADDED
@@ -0,0 +1,216 @@
1
+ /**
2
+ * `dench chat <subcommand>` — CLI surface for browsing past chat threads
3
+ * in the user's workspace.
4
+ *
5
+ * Mirrors the `dench crm` pattern: a thin command dispatcher on top of
6
+ * Convex public queries (`functions/chat:listPastChats`,
7
+ * `searchPastChats`, `readPastChat`) which themselves enforce the
8
+ * "own + shared in this org" visibility rule.
9
+ *
10
+ * Auth: every call forwards a single bearer token via `sessionToken`
11
+ * (either a `dch_agent_*` agent session minted by `dench login` or a
12
+ * unified Dench API key like `DENCH_API_KEY` in a sandbox). When the
13
+ * caller is using an API key, the server needs a `runId` to resolve
14
+ * the acting user — the dispatcher in `cli/dench.ts` lifts that from
15
+ * `DENCH_RUN_ID` and forwards it here so `dench chat` works seamlessly
16
+ * inside a sandbox without extra flags.
17
+ *
18
+ * Subcommands:
19
+ * chat list [--query <substr>] [--limit N] [--include-archived]
20
+ * chat search <query> [--limit N] [--include-current]
21
+ * chat read <thread-id> [--limit N]
22
+ */
23
+ import type { ConvexHttpClient } from "convex/browser";
24
+ import { makeFunctionReference } from "convex/server";
25
+ import {
26
+ CliArgError,
27
+ getFlag,
28
+ hasFlag,
29
+ shift as shiftRaw,
30
+ } from "./lib/cli-args";
31
+
32
+ type ChatCliContext = {
33
+ convex: ConvexHttpClient;
34
+ args: string[];
35
+ jsonOutput: boolean;
36
+ sessionToken?: string;
37
+ runId?: string;
38
+ };
39
+
40
+ class ChatCliError extends Error {}
41
+
42
+ function shift(args: string[], expected: string): string {
43
+ try {
44
+ return shiftRaw(args, expected);
45
+ } catch (error) {
46
+ if (error instanceof CliArgError) throw new ChatCliError(error.message);
47
+ throw error;
48
+ }
49
+ }
50
+
51
+ function parseLimit(raw: string | undefined): number | undefined {
52
+ if (raw === undefined) return undefined;
53
+ const parsed = Number(raw);
54
+ if (!Number.isFinite(parsed) || parsed <= 0) {
55
+ throw new ChatCliError(`Invalid --limit value: ${raw}`);
56
+ }
57
+ return Math.floor(parsed);
58
+ }
59
+
60
+ const api = {
61
+ listPastChats: makeFunctionReference<"query">(
62
+ "functions/chat:listPastChats",
63
+ ),
64
+ searchPastChats: makeFunctionReference<"query">(
65
+ "functions/chat:searchPastChats",
66
+ ),
67
+ readPastChat: makeFunctionReference<"query">(
68
+ "functions/chat:readPastChat",
69
+ ),
70
+ };
71
+
72
+ function commonArgs(ctx: ChatCliContext) {
73
+ return {
74
+ ...(ctx.sessionToken ? { sessionToken: ctx.sessionToken } : {}),
75
+ ...(ctx.runId ? { runId: ctx.runId } : {}),
76
+ };
77
+ }
78
+
79
+ async function callQuery(
80
+ ctx: ChatCliContext,
81
+ fn: Parameters<ConvexHttpClient["query"]>[0],
82
+ args: Record<string, unknown> = {},
83
+ ): Promise<unknown> {
84
+ return ctx.convex.query(fn, { ...args, ...commonArgs(ctx) } as never);
85
+ }
86
+
87
+ function out(ctx: ChatCliContext, value: unknown): void {
88
+ if (ctx.jsonOutput) {
89
+ console.log(JSON.stringify(value, null, 2));
90
+ return;
91
+ }
92
+ if (value === null || value === undefined) {
93
+ console.log("(empty)");
94
+ return;
95
+ }
96
+ console.log(JSON.stringify(value, null, 2));
97
+ }
98
+
99
+ function chatHelp(): void {
100
+ console.log(`Usage: dench chat <subcommand>
101
+
102
+ List past chat threads (own + shared in this workspace):
103
+ dench chat list [--query <substr>] [--limit N] [--include-archived] [--json]
104
+
105
+ Full-text search across past chat messages (own + shared):
106
+ dench chat search "<query>" [--limit N] [--include-current] [--json]
107
+
108
+ Read messages from a specific past chat thread:
109
+ dench chat read <thread-id> [--limit N] [--json]
110
+
111
+ Spawn a new chat-turn workflow (same agent loop as the web UI):
112
+ dench chat new "<prompt>" [--model <m>] [--file-context <path>] [--visibility private|shared] [--title T] [--yolo] [--follow] [--json]
113
+ dench chat send <thread-id> "<message>" [--model <m>] [--yolo] [--follow] [--json]
114
+ Aliased as: dench agent new / dench agent send.
115
+
116
+ Approval policy:
117
+ Without --yolo the workflow uses the workspace policy from
118
+ /<slug>/settings -> Approvals (master YOLO + per-rule toggles).
119
+ --yolo overrides for this single chat turn — every approval gate is
120
+ off, equivalent to flipping the org to YOLO mode for this run.
121
+
122
+ Auth:
123
+ Inside a sandbox, this just works — DENCH_API_KEY + DENCH_RUN_ID are
124
+ used automatically. Locally, run \`dench login\` first.
125
+
126
+ Global flags:
127
+ --json Output raw JSON instead of pretty-printed text.
128
+ --follow After spawning, stream the chat response to stdout.
129
+ `);
130
+ }
131
+
132
+ export async function runChatCommand(opts: {
133
+ convex: ConvexHttpClient;
134
+ args: string[];
135
+ sessionToken?: string;
136
+ runId?: string;
137
+ }): Promise<void> {
138
+ const args = [...opts.args];
139
+ const jsonOutput = hasFlag(args, "--json");
140
+ const ctx: ChatCliContext = {
141
+ convex: opts.convex,
142
+ args,
143
+ jsonOutput,
144
+ sessionToken: opts.sessionToken,
145
+ runId: opts.runId,
146
+ };
147
+ const subcommand = args.shift();
148
+ if (!subcommand || subcommand === "help" || subcommand === "--help") {
149
+ chatHelp();
150
+ return;
151
+ }
152
+ switch (subcommand) {
153
+ case "list":
154
+ return await runChatListCommand(ctx);
155
+ case "search":
156
+ return await runChatSearchCommand(ctx);
157
+ case "read":
158
+ return await runChatReadCommand(ctx);
159
+ default:
160
+ throw new ChatCliError(`Unknown chat subcommand: ${subcommand}`);
161
+ }
162
+ }
163
+
164
+ async function runChatListCommand(ctx: ChatCliContext): Promise<void> {
165
+ const titleQuery = getFlag(ctx.args, "--query");
166
+ const limit = parseLimit(getFlag(ctx.args, "--limit"));
167
+ const includeArchived = hasFlag(ctx.args, "--include-archived");
168
+ out(
169
+ ctx,
170
+ await callQuery(ctx, api.listPastChats, {
171
+ titleQuery,
172
+ limit,
173
+ includeArchived,
174
+ }),
175
+ );
176
+ }
177
+
178
+ async function runChatSearchCommand(ctx: ChatCliContext): Promise<void> {
179
+ const includeCurrent = hasFlag(ctx.args, "--include-current");
180
+ const limit = parseLimit(getFlag(ctx.args, "--limit"));
181
+ const queryParts: string[] = [];
182
+ while (ctx.args.length > 0) {
183
+ const next = ctx.args.shift();
184
+ if (next === undefined) break;
185
+ queryParts.push(next);
186
+ }
187
+ const query = queryParts.join(" ").trim();
188
+ if (!query) {
189
+ throw new ChatCliError(
190
+ 'Usage: dench chat search "<query>" [--limit N] [--include-current]',
191
+ );
192
+ }
193
+ out(
194
+ ctx,
195
+ await callQuery(ctx, api.searchPastChats, {
196
+ query,
197
+ limit,
198
+ // The server defaults excludeThreadId to undefined; the agent path
199
+ // sets it explicitly. CLI users are not "in" a thread, so we pass
200
+ // undefined unless --include-current is intentionally set (no-op).
201
+ ...(includeCurrent ? {} : {}),
202
+ }),
203
+ );
204
+ }
205
+
206
+ async function runChatReadCommand(ctx: ChatCliContext): Promise<void> {
207
+ const threadId = shift(ctx.args, "thread id");
208
+ const limit = parseLimit(getFlag(ctx.args, "--limit"));
209
+ out(
210
+ ctx,
211
+ await callQuery(ctx, api.readPastChat, {
212
+ threadId,
213
+ limit,
214
+ }),
215
+ );
216
+ }