@dench.com/cli 0.3.1 → 0.3.3

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/README.md CHANGED
@@ -81,6 +81,30 @@ Use `dench context` when you need to know who you are, which workspace you are
81
81
  using, assigned tasks, pending approvals you requested, connected apps, and the
82
82
  best next commands. Add `--json` for structured output.
83
83
 
84
+ ## Past chat threads
85
+
86
+ Browse and search the user's past chat threads in this workspace (own +
87
+ shared). Useful for pulling in earlier context when the user refers to
88
+ something they discussed before. The chat agent has the equivalent
89
+ in-process tools (`list_past_chats`, `search_past_chats`,
90
+ `read_past_chat`); this CLI surface is the same data accessed via bash.
91
+
92
+ ```bash
93
+ dench chat list [--query <substr>] [--limit N] [--include-archived] [--json]
94
+ dench chat search "<query>" [--limit N] [--json]
95
+ dench chat read <thread-id> [--limit N] [--json]
96
+ ```
97
+
98
+ Visibility scope: only threads created by the current user, plus threads
99
+ in the same organization marked as `shared`.
100
+
101
+ `dench chat search` is a keyword full-text search across past message
102
+ text — pick concrete tokens that the prior chat would have used (file
103
+ paths, product names, error strings) rather than paraphrases.
104
+
105
+ Inside a sandbox, `dench chat` uses `DENCH_API_KEY` + `DENCH_RUN_ID`
106
+ automatically; locally, run `dench login` first.
107
+
84
108
  ## Billing
85
109
 
86
110
  Fresh workspaces verify a card for abuse prevention before starter credits are
@@ -149,3 +173,36 @@ For staging:
149
173
  dench login --staging
150
174
  dench login --host https://workspace-staging.dench.com
151
175
  ```
176
+
177
+ ## Sandbox / API key auth (no `dench login` required)
178
+
179
+ Inside a Daytona sandbox the unified Dench API key is baked into envVars
180
+ at create time (see `src/lib/secrets/sandbox-tokens.ts`). When
181
+ `DENCH_API_KEY` and `CONVEX_URL` are both present, the CLI skips the
182
+ `dench login` flow entirely and uses the API key as a bearer for every
183
+ org-scoped command — `dench crm`, `dench files`, `dench fs sync`, etc.
184
+
185
+ ```bash
186
+ export DENCH_API_KEY=dench_pk_live_…
187
+ export CONVEX_URL=https://your-convex-deployment.convex.cloud
188
+ # Optional but enables nicer URLs in CLI output:
189
+ export DENCH_API_URL=https://app.dench.com
190
+ export DENCH_ORG_ID=…
191
+ export DENCH_ORG_SLUG=…
192
+
193
+ dench crm entries list people
194
+ ```
195
+
196
+ Auth precedence:
197
+
198
+ 1. `--dev` flag → use the dev workspace env (`NEXT_PUBLIC_CONVEX_URL`,
199
+ `DENCH_WORKSPACE`, `DENCH_DEV_AGENT_KEY`).
200
+ 2. A locally-stored `dench login` session for the current host.
201
+ 3. `DENCH_API_KEY` + `CONVEX_URL` (sandbox / automation path).
202
+ 4. `--dev`-style env vars (without the flag).
203
+
204
+ Commands that need to impersonate a specific agent (`dench memory`,
205
+ `dench task`, `dench claim`, `dench log`, `dench approval request`,
206
+ etc.) still require an agent session (`dench login`) — the unified API
207
+ key is org-scoped and cannot speak as a single agent. Those commands
208
+ return a clear "agent session required" error in API-key mode.
package/agent.ts ADDED
@@ -0,0 +1,391 @@
1
+ /**
2
+ * `dench agent <subcommand>` — CLI surface for the subagent + peer
3
+ * messaging system.
4
+ *
5
+ * Used inside sandboxes (where DENCH_API_KEY + DENCH_INTERNAL_BASE +
6
+ * DENCH_INTERNAL_TOKEN are already present in env) to:
7
+ * - spawn parallel subagents
8
+ * - await their completion
9
+ * - send / wait on peer messages
10
+ * - pause / resume / continue runs
11
+ * - list run trees
12
+ *
13
+ * Routes hit:
14
+ * POST /api/runs/spawn-child (internal token)
15
+ * POST /api/runs/notify-parent (internal token)
16
+ * POST /api/runs/send-message (internal token)
17
+ * POST /api/runs/continue (Bearer Dench API key)
18
+ * POST /api/runs (existing legacy route — kept for
19
+ * compatibility; new runs prefer /start)
20
+ * POST /api/runs/start (Bearer Dench API key, the new flow)
21
+ */
22
+ import type { ConvexHttpClient } from "convex/browser";
23
+ import { makeFunctionReference } from "convex/server";
24
+ import {
25
+ CliArgError,
26
+ getFlag,
27
+ hasFlag,
28
+ shift as shiftRaw,
29
+ } from "./lib/cli-args";
30
+
31
+ class AgentCliError extends Error {}
32
+
33
+ type CliCtx = {
34
+ convex: ConvexHttpClient;
35
+ args: string[];
36
+ jsonOutput: boolean;
37
+ };
38
+
39
+ // Args helpers moved to cli/lib/cli-args.ts; we adapt shift to throw
40
+ // AgentCliError so existing catch blocks don't need updating.
41
+ function shift(args: string[], expected: string): string {
42
+ try {
43
+ return shiftRaw(args, expected);
44
+ } catch (error) {
45
+ if (error instanceof CliArgError) throw new AgentCliError(error.message);
46
+ throw error;
47
+ }
48
+ }
49
+
50
+ function out(ctx: CliCtx, 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
+ function getInternalBase(): string {
63
+ const base = (
64
+ process.env.DENCH_INTERNAL_BASE ??
65
+ process.env.DENCH_API_URL ??
66
+ ""
67
+ ).replace(/\/+$/, "");
68
+ if (!base) {
69
+ throw new AgentCliError(
70
+ "DENCH_INTERNAL_BASE (or DENCH_API_URL) is not configured in this sandbox",
71
+ );
72
+ }
73
+ return base;
74
+ }
75
+
76
+ function getApiBase(): string {
77
+ const base = (process.env.DENCH_API_URL ?? "").replace(/\/+$/, "");
78
+ if (!base) {
79
+ throw new AgentCliError("DENCH_API_URL is not configured in this sandbox");
80
+ }
81
+ return base;
82
+ }
83
+
84
+ function internalHeaders(): Record<string, string> {
85
+ const token = process.env.DENCH_INTERNAL_TOKEN?.trim();
86
+ if (!token) {
87
+ throw new AgentCliError(
88
+ "DENCH_INTERNAL_TOKEN is not configured in this sandbox",
89
+ );
90
+ }
91
+ return {
92
+ "content-type": "application/json",
93
+ "x-dench-internal-token": token,
94
+ };
95
+ }
96
+
97
+ function apiKeyHeaders(): Record<string, string> {
98
+ const key = process.env.DENCH_API_KEY?.trim();
99
+ if (!key) {
100
+ throw new AgentCliError(
101
+ "DENCH_API_KEY is not configured in this sandbox",
102
+ );
103
+ }
104
+ return {
105
+ "content-type": "application/json",
106
+ authorization: `Bearer ${key}`,
107
+ };
108
+ }
109
+
110
+ async function postJson(
111
+ url: string,
112
+ headers: Record<string, string>,
113
+ body: unknown,
114
+ ): Promise<unknown> {
115
+ const response = await fetch(url, {
116
+ method: "POST",
117
+ headers,
118
+ body: JSON.stringify(body ?? {}),
119
+ });
120
+ const text = await response.text();
121
+ let json: unknown;
122
+ try {
123
+ json = text ? JSON.parse(text) : null;
124
+ } catch {
125
+ json = { raw: text };
126
+ }
127
+ if (!response.ok) {
128
+ throw new AgentCliError(
129
+ `${url} failed with ${response.status}: ${
130
+ typeof json === "object" ? JSON.stringify(json) : String(json)
131
+ }`,
132
+ );
133
+ }
134
+ return json;
135
+ }
136
+
137
+ export async function runAgentCommand(opts: {
138
+ convex: ConvexHttpClient;
139
+ args: string[];
140
+ }): Promise<void> {
141
+ const args = [...opts.args];
142
+ const jsonOutput = hasFlag(args, "--json");
143
+ const ctx: CliCtx = { convex: opts.convex, args, jsonOutput };
144
+ const subcommand = args.shift();
145
+ if (!subcommand || subcommand === "help" || subcommand === "--help") {
146
+ agentHelp();
147
+ return;
148
+ }
149
+ switch (subcommand) {
150
+ case "spawn":
151
+ return await runSpawn(ctx);
152
+ case "await":
153
+ return await runAwait(ctx);
154
+ case "message":
155
+ return await runMessage(ctx);
156
+ case "wait-message":
157
+ return await runWaitMessage(ctx);
158
+ case "pause":
159
+ return await runPause(ctx);
160
+ case "resume":
161
+ return await runResume(ctx);
162
+ case "continue":
163
+ return await runContinue(ctx);
164
+ case "tree":
165
+ return await runTree(ctx);
166
+ default:
167
+ throw new AgentCliError(
168
+ `Unknown agent subcommand: ${subcommand}. Run 'dench agent help'.`,
169
+ );
170
+ }
171
+ }
172
+
173
+ async function runPause(ctx: CliCtx): Promise<void> {
174
+ const runId = shift(ctx.args, "run id");
175
+ const result = await postJson(
176
+ `${getApiBase()}/api/runs/pause`,
177
+ apiKeyHeaders(),
178
+ { runId },
179
+ );
180
+ out(ctx, result);
181
+ }
182
+
183
+ async function runResume(ctx: CliCtx): Promise<void> {
184
+ const runId = shift(ctx.args, "run id");
185
+ const prompt = ctx.args.length > 0 ? ctx.args.join(" ").trim() : undefined;
186
+ const result = await postJson(
187
+ `${getApiBase()}/api/runs/resume`,
188
+ apiKeyHeaders(),
189
+ { runId, prompt },
190
+ );
191
+ out(ctx, result);
192
+ }
193
+
194
+ async function runSpawn(ctx: CliCtx): Promise<void> {
195
+ const goal = ctx.args.filter((arg) => !arg.startsWith("--")).join(" ").trim();
196
+ if (!goal) throw new AgentCliError("Goal required: dench agent spawn '...'");
197
+ const parentRunId =
198
+ getFlag(ctx.args, "--parent-run-id") ?? process.env.DENCH_RUN_ID;
199
+ if (!parentRunId) {
200
+ throw new AgentCliError(
201
+ "--parent-run-id required (or set DENCH_RUN_ID inside a sandbox)",
202
+ );
203
+ }
204
+ const sandboxStrategy = getFlag(ctx.args, "--sandbox") as
205
+ | "own"
206
+ | "share_parent"
207
+ | undefined;
208
+ const timeBudgetMs = parseInt(
209
+ getFlag(ctx.args, "--time-budget-ms") ?? "0",
210
+ 10,
211
+ );
212
+
213
+ // Step 1: createChildRun via Convex (mutation requires admin auth, so we
214
+ // hop through the spawn-child route which has CONVEX_DEPLOY_KEY).
215
+ // For the v1 plan, we ship a single internal-route hop: the route owns
216
+ // the createChildRun + setRunLinkAwaitToken + start() sequence.
217
+ const result = await postJson(
218
+ `${getInternalBase()}/api/runs/spawn-child`,
219
+ internalHeaders(),
220
+ {
221
+ // The route currently expects a pre-created childRunId. To support
222
+ // the CLI flow we'll pass the raw goal and let the route create
223
+ // the child row + start the workflow. (Route extension lands in
224
+ // the next commit.)
225
+ parentRunId,
226
+ goal,
227
+ sandboxStrategy,
228
+ timeBudgetMs: timeBudgetMs > 0 ? timeBudgetMs : undefined,
229
+ },
230
+ );
231
+ out(ctx, result);
232
+ }
233
+
234
+ async function runAwait(ctx: CliCtx): Promise<void> {
235
+ const hookToken = getFlag(ctx.args, "--hook");
236
+ if (hookToken) {
237
+ out(ctx, { hookToken, status: "registered" });
238
+ return;
239
+ }
240
+ const childIdsCsv = getFlag(ctx.args, "--children");
241
+ if (!childIdsCsv) {
242
+ throw new AgentCliError(
243
+ "dench agent await requires --hook <token> or --children <csv>",
244
+ );
245
+ }
246
+ // Polling fallback: subscribe to runs via Convex realtime would be the
247
+ // proper implementation; for v1 we poll status fields every 2s.
248
+ const childIds = childIdsCsv.split(",").map((id) => id.trim()).filter(Boolean);
249
+ const completed: Record<string, string> = {};
250
+ const start = Date.now();
251
+ const timeoutMs = parseInt(
252
+ getFlag(ctx.args, "--timeout-ms") ?? "3600000",
253
+ 10,
254
+ );
255
+ while (Object.keys(completed).length < childIds.length) {
256
+ if (Date.now() - start > timeoutMs) {
257
+ throw new AgentCliError("dench agent await timed out");
258
+ }
259
+ for (const childId of childIds) {
260
+ if (completed[childId]) continue;
261
+ // Reading status requires a query; for v1 use a tight loop on the
262
+ // public runs.getRun. (Wired in P5 once DenchClient lands.)
263
+ completed[childId] = "pending";
264
+ }
265
+ await new Promise((resolve) => setTimeout(resolve, 2000));
266
+ break; // v1 polling stub — placeholder
267
+ }
268
+ out(ctx, { children: completed });
269
+ }
270
+
271
+ async function runMessage(ctx: CliCtx): Promise<void> {
272
+ const toRunId = shift(ctx.args, "target run id");
273
+ const fromRunId =
274
+ getFlag(ctx.args, "--from-run-id") ?? process.env.DENCH_RUN_ID;
275
+ if (!fromRunId) {
276
+ throw new AgentCliError(
277
+ "--from-run-id required (or set DENCH_RUN_ID inside a sandbox)",
278
+ );
279
+ }
280
+ const text = ctx.args.join(" ").trim();
281
+ if (!text) {
282
+ throw new AgentCliError(
283
+ "Message body required: dench agent message <toRunId> 'text...'",
284
+ );
285
+ }
286
+ const result = await postJson(
287
+ `${getInternalBase()}/api/runs/send-message`,
288
+ internalHeaders(),
289
+ { fromRunId, toRunId, payload: { text } },
290
+ );
291
+ out(ctx, result);
292
+ }
293
+
294
+ async function runWaitMessage(ctx: CliCtx): Promise<void> {
295
+ // v1 stub: print a message + exit. The real implementation lives in
296
+ // the agent loop's `wait_for_message` tool, which calls the workflow
297
+ // step that registers an await-message hook and durably sleeps.
298
+ out(ctx, {
299
+ note:
300
+ "wait-message is intended to be called by the agent loop, not from a one-shot CLI. Use the agent's wait_for_message tool inside a Long Session.",
301
+ });
302
+ }
303
+
304
+ async function runContinue(ctx: CliCtx): Promise<void> {
305
+ const prevRunId = shift(ctx.args, "previous run id");
306
+ const prompt = ctx.args.join(" ").trim();
307
+ if (!prompt) {
308
+ throw new AgentCliError(
309
+ "Continuation prompt required: dench agent continue <prevRunId> '...'",
310
+ );
311
+ }
312
+ const result = await postJson(
313
+ `${getApiBase()}/api/runs/continue`,
314
+ apiKeyHeaders(),
315
+ { prevRunId, prompt },
316
+ );
317
+ out(ctx, result);
318
+ }
319
+
320
+ async function runTree(ctx: CliCtx): Promise<void> {
321
+ const rootRunId =
322
+ ctx.args[0] ??
323
+ process.env.DENCH_ROOT_RUN_ID ??
324
+ process.env.DENCH_RUN_ID;
325
+ if (!rootRunId) {
326
+ throw new AgentCliError(
327
+ "dench agent tree <rootRunId> (or set DENCH_ROOT_RUN_ID inside a sandbox)",
328
+ );
329
+ }
330
+ const getRunTree = makeFunctionReference<"query">(
331
+ "functions/runs:getRunTree",
332
+ );
333
+ const flat = (await ctx.convex.query(getRunTree, {
334
+ rootRunId: rootRunId as never,
335
+ })) as Array<{
336
+ _id: string;
337
+ parentRunId?: string;
338
+ goal: string;
339
+ status: string;
340
+ }>;
341
+ if (ctx.jsonOutput) {
342
+ out(ctx, flat);
343
+ return;
344
+ }
345
+ // Tree-pretty-print, depth-first.
346
+ const byParent = new Map<string | null, typeof flat>();
347
+ for (const node of flat) {
348
+ const key = node.parentRunId ?? null;
349
+ const list = byParent.get(key) ?? [];
350
+ list.push(node);
351
+ byParent.set(key, list);
352
+ }
353
+ function print(node: (typeof flat)[number], depth: number): void {
354
+ const indent = " ".repeat(depth);
355
+ const goalLine = node.goal.split("\n")[0].slice(0, 60);
356
+ process.stdout.write(
357
+ `${indent}- [${node.status}] ${node._id} ${goalLine}\n`,
358
+ );
359
+ for (const child of byParent.get(node._id) ?? []) print(child, depth + 1);
360
+ }
361
+ for (const root of byParent.get(null) ?? []) print(root, 0);
362
+ }
363
+
364
+ function agentHelp(): void {
365
+ console.log(`Usage: dench agent <subcommand>
366
+
367
+ Spawn a chat-turn workflow (same as the web UI's chat panel):
368
+ dench agent new "<prompt>" [--model <m>] [--file-context <path>] [--visibility private|shared] [--title T] [--yolo] [--follow] [--json]
369
+ dench agent send <thread-id> "<message>" [--model <m>] [--yolo] [--follow] [--json]
370
+ These are aliases for \`dench chat new\` / \`dench chat send\`.
371
+
372
+ Subagents (autonomous Long Sessions, NOT chat threads):
373
+ dench agent spawn '<goal>' [--parent-run-id <id>] [--sandbox own|share_parent] [--time-budget-ms N]
374
+ dench agent await --hook <token>
375
+ dench agent await --children id1,id2,id3 [--timeout-ms N]
376
+
377
+ Peer messaging:
378
+ dench agent message <toRunId> '<text...>' [--from-run-id <id>]
379
+ dench agent wait-message (intended for use inside the agent loop)
380
+
381
+ Lifecycle:
382
+ dench agent pause <runId>
383
+ dench agent resume <runId> ['optional prompt to inject on resume']
384
+ dench agent continue <prevRunId> '<prompt>' (creates a new run from a completed one)
385
+ dench agent tree (see <RunTreeView> in dench.com UI)
386
+
387
+ Global flags:
388
+ --json Emit raw JSON instead of pretty-printed text.
389
+ --follow After spawning a chat (new/send), stream the response to stdout.
390
+ `);
391
+ }
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
+ }
package/crm.ts CHANGED
@@ -2,8 +2,14 @@
2
2
  * `dench crm <subcommand>` — CLI surface for the Convex CRM.
3
3
  *
4
4
  * All operations resolve via the unified Dench API key baked into the
5
- * sandbox (DENCH_API_KEY) or the locally-stored session token. Talks to
6
- * the Convex deployment URL stored in the active session.
5
+ * sandbox (DENCH_API_KEY) or a locally-stored agent session token from
6
+ * `dench login`. The dispatcher does not care which one — the parent
7
+ * (`cli/dench.ts → getRuntime`) picks whichever auth is available and
8
+ * forwards a single bearer through `sessionToken`. The server-side
9
+ * `requireCrmAccess` (convex/lib/crm/access.ts) accepts either format.
10
+ *
11
+ * Talks to the Convex deployment URL provided by the active runtime
12
+ * (the session's `convexUrl` or the sandbox's `CONVEX_URL` envvar).
7
13
  *
8
14
  * This is a thin command dispatcher; the heavy lifting lives in
9
15
  * convex/functions/crm/* and convex/lib/crm/*.
@@ -37,9 +43,14 @@ type CrmCliContext = {
37
43
  args: string[];
38
44
  jsonOutput: boolean;
39
45
  /**
40
- * Agent session token (`dch_agent_*`) minted by `dench login`. Sent
41
- * with every Convex call so the server-side `requireCrmAccess` helper
42
- * can resolve the caller's organization without a Convex Auth cookie.
46
+ * Bearer token sent with every Convex call so the server-side
47
+ * `requireCrmAccess` helper can resolve the caller's organization
48
+ * without a Convex Auth cookie. Two formats are accepted:
49
+ * • `dch_agent_*` agent session minted by `dench login`.
50
+ * • Unified Dench API key (`dench_pk_*`, `dench_live_*`,
51
+ * `gw_sk_*`, etc.) baked into sandbox envVars as `DENCH_API_KEY`.
52
+ * The server dispatches on prefix; both resolve to the same
53
+ * organization context.
43
54
  */
44
55
  sessionToken?: string;
45
56
  };