@dench.com/cli 2.7.0 → 2.7.2

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 CHANGED
@@ -108,6 +108,17 @@ function buildStreamUrlByThreadId(host: string, threadId: string): string {
108
108
  return `${host.replace(/\/+$/, "")}/api/chat/stream?sessionId=${encodeURIComponent(threadId)}`;
109
109
  }
110
110
 
111
+ const CHAT_STREAM_RECONNECT_DELAY_MS = 100;
112
+ const CHAT_STREAM_MAX_EMPTY_BACKOFF_MS = 2_000;
113
+
114
+ function emptyActiveStreamBackoffMs(consecutiveEmptyResponses: number): number {
115
+ const exponent = Math.max(0, Math.min(consecutiveEmptyResponses - 1, 10));
116
+ return Math.min(
117
+ CHAT_STREAM_RECONNECT_DELAY_MS * 2 ** exponent,
118
+ CHAT_STREAM_MAX_EMPTY_BACKOFF_MS,
119
+ );
120
+ }
121
+
111
122
  /**
112
123
  * Subscribe to the durable chat stream and pretty-print useful chunks
113
124
  * until the stream closes. Mirrors the chunk types the chat panel
@@ -131,37 +142,25 @@ async function followStream(args: {
131
142
  runId?: string;
132
143
  threadId?: string;
133
144
  }): Promise<void> {
134
- const url = args.runId
145
+ const baseUrl = args.runId
135
146
  ? buildStreamUrlByRunId(args.host, args.runId)
136
147
  : args.threadId
137
148
  ? buildStreamUrlByThreadId(args.host, args.threadId)
138
149
  : null;
139
- if (!url) {
150
+ if (!baseUrl) {
140
151
  throw new ChatSpawnError(
141
152
  "followStream requires either { runId } or { threadId }",
142
153
  );
143
154
  }
144
- const response = await fetch(url, {
145
- method: "GET",
146
- headers: {
147
- accept: "text/event-stream",
148
- authorization: `Bearer ${args.bearerToken}`,
149
- },
150
- });
151
- if (!response.ok || !response.body) {
152
- throw new ChatSpawnError(
153
- `Failed to open stream: ${response.status} ${response.statusText}`,
154
- );
155
- }
156
-
157
- const reader = response.body.getReader();
158
- const decoder = new TextDecoder();
159
- let buffer = "";
160
155
  // Tool calls in flight, keyed by toolCallId, so we can render
161
156
  // a single line per call ("→ tool_name … done") instead of
162
157
  // dumping every chunk verbatim.
163
158
  const inFlightTools = new Map<string, { toolName: string }>();
164
159
  let textBuffer = "";
160
+ let segmentWorkflowRunId: string | null = null;
161
+ let segmentOrdinal: number | null = null;
162
+ let startIndex = 0;
163
+ let consecutiveEmptyActiveResponses = 0;
165
164
 
166
165
  const flushText = () => {
167
166
  if (textBuffer) {
@@ -170,104 +169,199 @@ async function followStream(args: {
170
169
  }
171
170
  };
172
171
 
173
- const handleChunk = (raw: string) => {
174
- if (!raw.startsWith("data:")) return;
175
- const data = raw.slice("data:".length).trim();
176
- if (!data || data === "[DONE]") return;
177
- let parsed: unknown;
178
- try {
179
- parsed = JSON.parse(data);
180
- } catch {
181
- return;
172
+ const closeInFlightToolLines = (suffix: string) => {
173
+ if (inFlightTools.size === 0) return false;
174
+ process.stdout.write(suffix);
175
+ inFlightTools.clear();
176
+ return true;
177
+ };
178
+
179
+ while (true) {
180
+ const url = new URL(baseUrl);
181
+ if (startIndex > 0) {
182
+ url.searchParams.set("startIndex", String(startIndex));
182
183
  }
183
- if (!parsed || typeof parsed !== "object") return;
184
- const c = parsed as {
185
- type?: unknown;
186
- delta?: unknown;
187
- text?: unknown;
188
- toolName?: unknown;
189
- toolCallId?: unknown;
190
- input?: unknown;
191
- output?: unknown;
192
- errorText?: unknown;
193
- };
194
- switch (c.type) {
195
- case "text-delta":
196
- case "reasoning-delta": {
197
- if (typeof c.delta === "string") {
198
- textBuffer += c.delta;
199
- }
200
- break;
184
+ const response: Response = await fetch(url, {
185
+ method: "GET",
186
+ headers: {
187
+ accept: "text/event-stream",
188
+ authorization: `Bearer ${args.bearerToken}`,
189
+ ...(segmentWorkflowRunId
190
+ ? {
191
+ "x-chat-segment-workflow-run-id": segmentWorkflowRunId,
192
+ }
193
+ : {}),
194
+ ...(segmentOrdinal !== null
195
+ ? { "x-chat-segment-ordinal": String(segmentOrdinal) }
196
+ : {}),
197
+ },
198
+ });
199
+ const responseWorkflowRunId: string | null =
200
+ response.headers.get("x-chat-segment-workflow-run-id") ??
201
+ response.headers.get("x-workflow-run-id");
202
+ const rawOrdinal = response.headers.get("x-chat-segment-ordinal");
203
+ const responseOrdinal =
204
+ rawOrdinal !== null && Number.isSafeInteger(Number(rawOrdinal))
205
+ ? Number(rawOrdinal)
206
+ : null;
207
+ const segmentChanged =
208
+ (responseWorkflowRunId !== null &&
209
+ responseWorkflowRunId !== segmentWorkflowRunId) ||
210
+ (responseOrdinal !== null && responseOrdinal !== segmentOrdinal);
211
+ if (segmentChanged) {
212
+ if (segmentWorkflowRunId !== null || segmentOrdinal !== null) {
213
+ closeInFlightToolLines(" continued in next segment\n");
201
214
  }
202
- case "tool-input-start": {
203
- flushText();
204
- if (
205
- typeof c.toolCallId === "string" &&
206
- typeof c.toolName === "string"
207
- ) {
208
- inFlightTools.set(c.toolCallId, { toolName: c.toolName });
209
- process.stdout.write(`\n→ ${c.toolName} …`);
210
- }
211
- break;
215
+ startIndex = 0;
216
+ consecutiveEmptyActiveResponses = 0;
217
+ }
218
+ segmentWorkflowRunId = responseWorkflowRunId ?? segmentWorkflowRunId;
219
+ segmentOrdinal = responseOrdinal ?? segmentOrdinal;
220
+
221
+ if (response.status === 202) {
222
+ const retryAfterSeconds = Number(response.headers.get("retry-after"));
223
+ const retryDelayMs =
224
+ Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0
225
+ ? Math.max(50, retryAfterSeconds * 1_000)
226
+ : 100;
227
+ await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
228
+ continue;
229
+ }
230
+ if (!response.ok || !response.body) {
231
+ throw new ChatSpawnError(
232
+ `Failed to open stream: ${response.status} ${response.statusText}`,
233
+ );
234
+ }
235
+
236
+ const reader = response.body.getReader();
237
+ const decoder = new TextDecoder();
238
+ let buffer = "";
239
+ let responseChunkCount = 0;
240
+ let gotFinish = false;
241
+
242
+ const handleChunk = (raw: string) => {
243
+ if (!raw.startsWith("data:")) return;
244
+ const data = raw.slice("data:".length).trim();
245
+ if (!data || data === "[DONE]") return;
246
+ let parsed: unknown;
247
+ try {
248
+ parsed = JSON.parse(data);
249
+ } catch {
250
+ return;
212
251
  }
213
- case "tool-output-available": {
214
- flushText();
215
- if (typeof c.toolCallId === "string") {
216
- const meta = inFlightTools.get(c.toolCallId);
217
- inFlightTools.delete(c.toolCallId);
218
- process.stdout.write(` done (${meta?.toolName ?? "tool"})\n`);
252
+ if (!parsed || typeof parsed !== "object") return;
253
+ responseChunkCount += 1;
254
+ const c = parsed as {
255
+ type?: unknown;
256
+ delta?: unknown;
257
+ text?: unknown;
258
+ toolName?: unknown;
259
+ toolCallId?: unknown;
260
+ input?: unknown;
261
+ output?: unknown;
262
+ errorText?: unknown;
263
+ };
264
+ switch (c.type) {
265
+ case "text-delta":
266
+ case "reasoning-delta": {
267
+ if (typeof c.delta === "string") {
268
+ textBuffer += c.delta;
269
+ }
270
+ break;
219
271
  }
220
- break;
221
- }
222
- case "tool-output-error": {
223
- flushText();
224
- if (typeof c.toolCallId === "string") {
225
- const meta = inFlightTools.get(c.toolCallId);
226
- inFlightTools.delete(c.toolCallId);
227
- process.stdout.write(
228
- ` error (${meta?.toolName ?? "tool"}): ${
229
- typeof c.errorText === "string" ? c.errorText : "unknown"
230
- }\n`,
231
- );
272
+ case "tool-input-start": {
273
+ flushText();
274
+ if (
275
+ typeof c.toolCallId === "string" &&
276
+ typeof c.toolName === "string"
277
+ ) {
278
+ inFlightTools.set(c.toolCallId, { toolName: c.toolName });
279
+ process.stdout.write(`\n→ ${c.toolName} …`);
280
+ }
281
+ break;
232
282
  }
233
- break;
234
- }
235
- case "finish": {
236
- flushText();
237
- process.stdout.write("\n");
238
- break;
283
+ case "tool-output-available": {
284
+ flushText();
285
+ if (typeof c.toolCallId === "string") {
286
+ const meta = inFlightTools.get(c.toolCallId);
287
+ inFlightTools.delete(c.toolCallId);
288
+ process.stdout.write(` done (${meta?.toolName ?? "tool"})\n`);
289
+ }
290
+ break;
291
+ }
292
+ case "tool-output-error": {
293
+ flushText();
294
+ if (typeof c.toolCallId === "string") {
295
+ const meta = inFlightTools.get(c.toolCallId);
296
+ inFlightTools.delete(c.toolCallId);
297
+ process.stdout.write(
298
+ ` error (${meta?.toolName ?? "tool"}): ${
299
+ typeof c.errorText === "string" ? c.errorText : "unknown"
300
+ }\n`,
301
+ );
302
+ }
303
+ break;
304
+ }
305
+ case "finish": {
306
+ gotFinish = true;
307
+ flushText();
308
+ if (!closeInFlightToolLines(" ended\n")) {
309
+ process.stdout.write("\n");
310
+ }
311
+ break;
312
+ }
313
+ case "error": {
314
+ flushText();
315
+ const msg =
316
+ typeof c.errorText === "string" ? c.errorText : "stream error";
317
+ process.stderr.write(`\n[error] ${msg}\n`);
318
+ break;
319
+ }
320
+ default:
321
+ break;
239
322
  }
240
- case "error": {
241
- flushText();
242
- const msg =
243
- typeof c.errorText === "string" ? c.errorText : "stream error";
244
- process.stderr.write(`\n[error] ${msg}\n`);
245
- break;
323
+ };
324
+
325
+ // Read SSE-style chunks. The route uses Vercel AI SDK's
326
+ // `createUIMessageStreamResponse` which emits `data: {…}\n\n`
327
+ // lines, so we split on double newlines.
328
+ while (true) {
329
+ const { value, done } = await reader.read();
330
+ if (done) break;
331
+ buffer += decoder.decode(value, { stream: true });
332
+ let nextBreak = buffer.indexOf("\n\n");
333
+ while (nextBreak !== -1) {
334
+ const event = buffer.slice(0, nextBreak);
335
+ buffer = buffer.slice(nextBreak + 2);
336
+ // Each event may contain multiple `data:` lines.
337
+ for (const line of event.split("\n")) {
338
+ if (line.trim().length > 0) handleChunk(line);
339
+ }
340
+ nextBreak = buffer.indexOf("\n\n");
246
341
  }
247
- default:
248
- break;
249
342
  }
250
- };
343
+ flushText();
344
+ startIndex += responseChunkCount;
251
345
 
252
- // Read SSE-style chunks. The route uses Vercel AI SDK's
253
- // `createUIMessageStreamResponse` which emits `data: {…}\n\n`
254
- // lines, so we split on double newlines.
255
- while (true) {
256
- const { value, done } = await reader.read();
257
- if (done) break;
258
- buffer += decoder.decode(value, { stream: true });
259
- let nextBreak = buffer.indexOf("\n\n");
260
- while (nextBreak !== -1) {
261
- const event = buffer.slice(0, nextBreak);
262
- buffer = buffer.slice(nextBreak + 2);
263
- // Each event may contain multiple `data:` lines.
264
- for (const line of event.split("\n")) {
265
- if (line.trim().length > 0) handleChunk(line);
266
- }
267
- nextBreak = buffer.indexOf("\n\n");
346
+ const streamStatus = response.headers.get("x-workflow-stream-status");
347
+ const runActive = response.headers.get("X-Run-Active") !== "false";
348
+ if (gotFinish || !runActive || streamStatus === "terminal") {
349
+ closeInFlightToolLines(" ended\n");
350
+ return;
268
351
  }
352
+ // Segment rollover intentionally closes without `finish`; reconnect to
353
+ // the thread/run route, which resolves the new segment and resets the
354
+ // durable stream index when the segment identity changes. Empty active
355
+ // responses can occur during that handoff; back them off so a delayed
356
+ // rollover cannot turn into a tight polling loop.
357
+ consecutiveEmptyActiveResponses =
358
+ responseChunkCount === 0 ? consecutiveEmptyActiveResponses + 1 : 0;
359
+ const reconnectDelayMs =
360
+ responseChunkCount === 0
361
+ ? emptyActiveStreamBackoffMs(consecutiveEmptyActiveResponses)
362
+ : CHAT_STREAM_RECONNECT_DELAY_MS;
363
+ await new Promise((resolve) => setTimeout(resolve, reconnectDelayMs));
269
364
  }
270
- flushText();
271
365
  }
272
366
 
273
367
  function joinPositionalsExceptFlags(args: string[]): string {
@@ -315,17 +409,20 @@ async function runChatNew(ctx: ChatSpawnContext): Promise<void> {
315
409
  const payload = result.payload as {
316
410
  threadId?: string;
317
411
  runId?: string;
412
+ workflowRunId?: string;
318
413
  threadUrl?: string;
319
414
  streamUrl?: string;
320
415
  } | null;
321
416
  out(ctx, payload);
322
417
 
323
- if (follow && payload?.runId) {
418
+ if (follow && (payload?.threadId || payload?.workflowRunId)) {
324
419
  if (!ctx.jsonOutput) console.error("\n--- following stream ---");
325
420
  await followStream({
326
421
  host: ctx.runtime.host,
327
422
  bearerToken: ctx.runtime.bearerToken,
328
- runId: payload.runId,
423
+ ...(payload.threadId
424
+ ? { threadId: payload.threadId }
425
+ : { runId: payload.workflowRunId }),
329
426
  });
330
427
  }
331
428
  }
@@ -371,17 +468,20 @@ async function runChatSend(ctx: ChatSpawnContext): Promise<void> {
371
468
  const payload = result.payload as {
372
469
  threadId?: string;
373
470
  runId?: string;
471
+ workflowRunId?: string;
374
472
  threadUrl?: string;
375
473
  streamUrl?: string;
376
474
  } | null;
377
475
  out(ctx, payload);
378
476
 
379
- if (follow && payload?.runId) {
477
+ if (follow && (payload?.threadId || payload?.workflowRunId)) {
380
478
  if (!ctx.jsonOutput) console.error("\n--- following stream ---");
381
479
  await followStream({
382
480
  host: ctx.runtime.host,
383
481
  bearerToken: ctx.runtime.bearerToken,
384
- runId: payload.runId,
482
+ ...(payload.threadId
483
+ ? { threadId: payload.threadId }
484
+ : { runId: payload.workflowRunId }),
385
485
  });
386
486
  }
387
487
  }
@@ -444,4 +544,4 @@ export async function runChatSpawnCommand(opts: {
444
544
  await runChatSend(ctx);
445
545
  }
446
546
 
447
- export { ChatSpawnError };
547
+ export { ChatSpawnError, followStream };
package/crm.ts CHANGED
@@ -90,8 +90,109 @@ type CrmCliContext = {
90
90
  sessionToken?: string;
91
91
  runId?: string;
92
92
  enrichmentGateway?: Pick<EnrichmentGatewayOptions, "apiKey" | "baseUrl">;
93
+ /** App origin used to build absolute entry deep links (e.g. https://www.dench.com). */
94
+ host?: string;
95
+ /** Workspace slug for absolute entry deep links (e.g. dench). */
96
+ workspaceSlug?: string;
93
97
  };
94
98
 
99
+ /**
100
+ * Canonical CRM entry deep links.
101
+ *
102
+ * Absolute (shareable): `https://…/<slug>?path=<object>&entry=<object>:<id>`
103
+ * Relative (in-app): `/?path=<object>&entry=<object>:<id>`
104
+ *
105
+ * Never invent `?object=` or a bare `entry=<id>` without the object prefix.
106
+ * `view` / `hidden` are optional UI chrome — not part of the canonical link.
107
+ */
108
+ export function buildCrmEntryDeepLink(args: {
109
+ host?: string;
110
+ workspaceSlug?: string;
111
+ objectName: string;
112
+ entryId: string;
113
+ }): { href: string; url?: string } {
114
+ const objectName = args.objectName.trim();
115
+ const entryId = args.entryId.trim();
116
+ // Keep a literal `:` between object and id (docs + in-app links); only
117
+ // encode the segments themselves so Convex ids stay readable.
118
+ const entryParam = `${encodeURIComponent(objectName)}:${encodeURIComponent(entryId)}`;
119
+ const pathParam = encodeURIComponent(objectName);
120
+ const href = `/?path=${pathParam}&entry=${entryParam}`;
121
+ const slug = args.workspaceSlug?.trim();
122
+ const host = args.host?.trim();
123
+ if (!slug || !host) {
124
+ return { href };
125
+ }
126
+ const withProtocol = /^https?:\/\//.test(host) ? host : `https://${host}`;
127
+ let base: string;
128
+ try {
129
+ base = new URL(withProtocol).origin;
130
+ } catch {
131
+ return { href };
132
+ }
133
+ return {
134
+ href,
135
+ url: `${base}/${encodeURIComponent(slug)}?path=${pathParam}&entry=${entryParam}`,
136
+ };
137
+ }
138
+
139
+ function entryIdFromUnknown(value: unknown): string | undefined {
140
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
141
+ return undefined;
142
+ }
143
+ const record = value as JsonRecord;
144
+ for (const key of ["entryId", "id"]) {
145
+ const candidate = record[key];
146
+ if (typeof candidate === "string" && candidate.trim()) {
147
+ return candidate.trim();
148
+ }
149
+ }
150
+ return undefined;
151
+ }
152
+
153
+ /**
154
+ * Canonical object name echoed by `entries.get` / `entries.create` /
155
+ * `batch.apply`. The server resolves display labels, case, and plurals
156
+ * (`lib/crm/resolveObject.ts`), so the name the caller typed is often NOT
157
+ * the one the workspace URL resolver matches on — it compares object names
158
+ * exactly. Always prefer this over the raw CLI argument.
159
+ */
160
+ function canonicalObjectNameFromUnknown(value: unknown): string | undefined {
161
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
162
+ return undefined;
163
+ }
164
+ const candidate = (value as JsonRecord).objectName;
165
+ return typeof candidate === "string" && candidate.trim()
166
+ ? candidate.trim()
167
+ : undefined;
168
+ }
169
+
170
+ function withCrmEntryDeepLinks(
171
+ ctx: CrmCliContext,
172
+ fallbackObjectName: string,
173
+ value: unknown,
174
+ ): unknown {
175
+ const entryId = entryIdFromUnknown(value);
176
+ if (!entryId || !value || typeof value !== "object" || Array.isArray(value)) {
177
+ return value;
178
+ }
179
+ // Older deployments don't echo `objectName`; the raw arg is the only
180
+ // thing left, and it's correct whenever the caller used the canonical name.
181
+ const objectName =
182
+ canonicalObjectNameFromUnknown(value) ?? fallbackObjectName;
183
+ const links = buildCrmEntryDeepLink({
184
+ host: ctx.host,
185
+ workspaceSlug: ctx.workspaceSlug,
186
+ objectName,
187
+ entryId,
188
+ });
189
+ return {
190
+ ...(value as JsonRecord),
191
+ href: links.href,
192
+ ...(links.url ? { url: links.url } : {}),
193
+ };
194
+ }
195
+
95
196
  // Adapt the shared helpers to throw CrmCliError so existing call sites
96
197
  // don't need to change their try/catch shape.
97
198
  class CrmCliError extends Error {}
@@ -291,18 +392,22 @@ async function applyBatchOpsInChunks(
291
392
  ops: CrmBatchOp[],
292
393
  idempotencyKey: string | undefined,
293
394
  allowUnknown = false,
294
- ): Promise<number> {
395
+ ): Promise<{ chunks: number; results: unknown[] }> {
295
396
  const chunks = chunkArray(ops, CRM_BATCH_CHUNK_SIZE);
397
+ const results: unknown[] = [];
296
398
  for (const [chunkIndex, chunk] of chunks.entries()) {
297
399
  const chunkKey = chunkIdempotencyKey(idempotencyKey, chunkIndex);
298
- await callMutation(ctx, api.batch.apply, {
400
+ const batchResult = (await callMutation(ctx, api.batch.apply, {
299
401
  ops: chunk,
300
402
  ...runContextArgs(ctx),
301
403
  ...(chunkKey ? { idempotencyKey: chunkKey } : {}),
302
404
  ...(allowUnknown ? { allowUnknown: true } : {}),
303
- });
405
+ })) as { results?: unknown[] } | null | undefined;
406
+ if (Array.isArray(batchResult?.results)) {
407
+ results.push(...batchResult.results);
408
+ }
304
409
  }
305
- return chunks.length;
410
+ return { chunks: chunks.length, results };
306
411
  }
307
412
 
308
413
  function runContextArgs(ctx: CrmCliContext): Record<string, string> {
@@ -568,6 +673,20 @@ const api = {
568
673
  deleteView: makeFunctionReference<"mutation">(
569
674
  "functions/crm/objects:deleteView",
570
675
  ),
676
+ setArchived: makeFunctionReference<"mutation">(
677
+ "functions/crm/objects:setArchived",
678
+ ),
679
+ setViewPinned: makeFunctionReference<"mutation">(
680
+ "functions/crm/objects:setViewPinned",
681
+ ),
682
+ },
683
+ sidebar: {
684
+ describe: makeFunctionReference<"query">(
685
+ "functions/workspaceNav:describeMySidebar",
686
+ ),
687
+ reposition: makeFunctionReference<"mutation">(
688
+ "functions/workspaceNav:repositionMyNavItem",
689
+ ),
571
690
  },
572
691
  fields: {
573
692
  list: makeFunctionReference<"query">("functions/crm/fields:list"),
@@ -672,6 +791,8 @@ export async function runCrmCommand(opts: {
672
791
  args: string[];
673
792
  sessionToken?: string;
674
793
  enrichmentGateway?: Pick<EnrichmentGatewayOptions, "apiKey" | "baseUrl">;
794
+ host?: string;
795
+ workspaceSlug?: string;
675
796
  }): Promise<void> {
676
797
  const args = [...opts.args];
677
798
  const jsonOutput = hasFlag(args, "--json");
@@ -682,6 +803,8 @@ export async function runCrmCommand(opts: {
682
803
  sessionToken: opts.sessionToken,
683
804
  runId: process.env.DENCH_RUN_ID?.trim() || undefined,
684
805
  enrichmentGateway: opts.enrichmentGateway,
806
+ host: opts.host,
807
+ workspaceSlug: opts.workspaceSlug,
685
808
  };
686
809
  const subcommand = args.shift();
687
810
  if (!subcommand || subcommand === "help" || subcommand === "--help") {
@@ -693,6 +816,8 @@ export async function runCrmCommand(opts: {
693
816
  return await runObjectsCommand(ctx);
694
817
  case "views":
695
818
  return await runViewsCommand(ctx);
819
+ case "sidebar":
820
+ return await runSidebarCommand(ctx);
696
821
  case "fields":
697
822
  return await runFieldsCommand(ctx);
698
823
  case "entries":
@@ -833,6 +958,50 @@ async function runCrmMembersCommand(ctx: CrmCliContext): Promise<void> {
833
958
  });
834
959
  }
835
960
 
961
+ /**
962
+ * `dench crm sidebar …` — the workspace app rail.
963
+ *
964
+ * Row VISIBILITY is team-shared and lives on the CRM records themselves
965
+ * (`crm objects archive`, `crm views pin/archive`). Row ORDER is a
966
+ * per-member preference, which is what `move` writes. `list` shows both
967
+ * halves: the visible rows with their 1-based positions, plus what's
968
+ * hidden and why.
969
+ */
970
+ async function runSidebarCommand(ctx: CrmCliContext): Promise<void> {
971
+ const verb = ctx.args.shift() ?? "list";
972
+ switch (verb) {
973
+ case "list": {
974
+ assertNoUnknownFlags(ctx.args, "crm sidebar list");
975
+ out(ctx, await callQuery(ctx, api.sidebar.describe, {}));
976
+ return;
977
+ }
978
+ case "move": {
979
+ const navId = shift(ctx.args, "nav id");
980
+ const rawPosition = shift(ctx.args, "position");
981
+ assertNoUnknownFlags(ctx.args, "crm sidebar move");
982
+ let position: string | number;
983
+ if (rawPosition === "top" || rawPosition === "bottom") {
984
+ position = rawPosition;
985
+ } else {
986
+ const parsed = Number.parseInt(rawPosition, 10);
987
+ if (!Number.isFinite(parsed) || parsed < 1) {
988
+ throw new CrmCliError(
989
+ `position must be "top", "bottom", or a positive integer, got ${rawPosition}`,
990
+ );
991
+ }
992
+ position = parsed;
993
+ }
994
+ out(
995
+ ctx,
996
+ await callMutation(ctx, api.sidebar.reposition, { navId, position }),
997
+ );
998
+ return;
999
+ }
1000
+ default:
1001
+ throw new CrmCliError(`Unknown crm sidebar verb: ${verb}`);
1002
+ }
1003
+ }
1004
+
836
1005
  async function runObjectsCommand(ctx: CrmCliContext): Promise<void> {
837
1006
  const verb = ctx.args.shift();
838
1007
  switch (verb) {
@@ -902,6 +1071,22 @@ async function runObjectsCommand(ctx: CrmCliContext): Promise<void> {
902
1071
  out(ctx, await callMutation(ctx, api.objects.remove, { name }));
903
1072
  return;
904
1073
  }
1074
+ // Archive is the sidebar's "remove from my rail", NOT a delete: the
1075
+ // table, its rows and every link to it keep working, the row just
1076
+ // moves into the rail's Archived flyout. Team-shared.
1077
+ case "archive":
1078
+ case "unarchive": {
1079
+ const name = shift(ctx.args, "object name");
1080
+ assertNoUnknownFlags(ctx.args, `crm objects ${verb}`);
1081
+ out(
1082
+ ctx,
1083
+ await callMutation(ctx, api.objects.setArchived, {
1084
+ name,
1085
+ archived: verb === "archive",
1086
+ }),
1087
+ );
1088
+ return;
1089
+ }
905
1090
  default:
906
1091
  throw new CrmCliError(`Unknown crm objects verb: ${verb ?? "<none>"}`);
907
1092
  }
@@ -1363,6 +1548,39 @@ async function runViewsCommand(ctx: CrmCliContext): Promise<void> {
1363
1548
  );
1364
1549
  return;
1365
1550
  }
1551
+ // Only `pinned` views render as workspace sidebar rows. Pinning also
1552
+ // clears any archive stamp, since a pinned-but-archived view still
1553
+ // wouldn't show up.
1554
+ case "pin":
1555
+ case "unpin": {
1556
+ const objectName = shift(ctx.args, "object name");
1557
+ const viewName = shift(ctx.args, "view name");
1558
+ assertNoUnknownFlags(ctx.args, `crm views ${verb}`);
1559
+ out(
1560
+ ctx,
1561
+ await callMutation(ctx, api.objects.setViewPinned, {
1562
+ objectName,
1563
+ viewName,
1564
+ pinned: verb === "pin",
1565
+ }),
1566
+ );
1567
+ return;
1568
+ }
1569
+ case "archive":
1570
+ case "unarchive": {
1571
+ const objectName = shift(ctx.args, "object name");
1572
+ const viewName = shift(ctx.args, "view name");
1573
+ assertNoUnknownFlags(ctx.args, `crm views ${verb}`);
1574
+ out(
1575
+ ctx,
1576
+ await callMutation(ctx, api.objects.setArchived, {
1577
+ name: objectName,
1578
+ viewName,
1579
+ archived: verb === "archive",
1580
+ }),
1581
+ );
1582
+ return;
1583
+ }
1366
1584
  default:
1367
1585
  throw new CrmCliError(`Unknown crm views verb: ${verb ?? "<none>"}`);
1368
1586
  }
@@ -1624,10 +1842,14 @@ async function runEntriesCommand(ctx: CrmCliContext): Promise<void> {
1624
1842
  assertNoUnknownFlags(ctx.args, "crm entries get");
1625
1843
  out(
1626
1844
  ctx,
1627
- await callQuery(ctx, api.entries.get, {
1845
+ withCrmEntryDeepLinks(
1846
+ ctx,
1628
1847
  objectName,
1629
- entryId: entryId as any,
1630
- }),
1848
+ await callQuery(ctx, api.entries.get, {
1849
+ objectName,
1850
+ entryId: entryId as any,
1851
+ }),
1852
+ ),
1631
1853
  );
1632
1854
  return;
1633
1855
  }
@@ -1657,12 +1879,16 @@ async function runEntriesCommand(ctx: CrmCliContext): Promise<void> {
1657
1879
  // pre-fetch — that would add a round-trip to every create.
1658
1880
  out(
1659
1881
  ctx,
1660
- await callMutation(ctx, api.entries.create, {
1882
+ withCrmEntryDeepLinks(
1883
+ ctx,
1661
1884
  objectName,
1662
- data,
1663
- ...runContextArgs(ctx),
1664
- ...(allowUnknown ? { allowUnknown: true } : {}),
1665
- }),
1885
+ await callMutation(ctx, api.entries.create, {
1886
+ objectName,
1887
+ data,
1888
+ ...runContextArgs(ctx),
1889
+ ...(allowUnknown ? { allowUnknown: true } : {}),
1890
+ }),
1891
+ ),
1666
1892
  );
1667
1893
  return;
1668
1894
  }
@@ -1680,7 +1906,7 @@ async function runEntriesCommand(ctx: CrmCliContext): Promise<void> {
1680
1906
  }),
1681
1907
  );
1682
1908
  await assertKnownDataKeys(ctx, objectName, rows, allowUnknown);
1683
- const chunks = await applyBatchOpsInChunks(
1909
+ const { chunks, results } = await applyBatchOpsInChunks(
1684
1910
  ctx,
1685
1911
  rows.map((data) => ({
1686
1912
  type: "entries.create",
@@ -1690,7 +1916,41 @@ async function runEntriesCommand(ctx: CrmCliContext): Promise<void> {
1690
1916
  idempotencyKey,
1691
1917
  allowUnknown,
1692
1918
  );
1693
- out(ctx, { created: rows.length, chunks });
1919
+ // Only claim per-row links when we actually got a result per row. A
1920
+ // silently-empty `results` is indistinguishable from "this object has
1921
+ // no links" and sends the agent right back to inventing URLs — the
1922
+ // exact failure this command's `url`/`href` output exists to prevent.
1923
+ const linked = results.length;
1924
+ out(ctx, {
1925
+ created: rows.length,
1926
+ chunks,
1927
+ ...(linked > 0
1928
+ ? {
1929
+ results: results.map((result) =>
1930
+ withCrmEntryDeepLinks(ctx, objectName, result),
1931
+ ),
1932
+ }
1933
+ : {}),
1934
+ ...(linked === rows.length
1935
+ ? {}
1936
+ : {
1937
+ linksUnavailable: true,
1938
+ // The recovery path has to be followable. With zero results
1939
+ // there is no entryId to hand to `entries get`, so point at
1940
+ // `entries list` (it returns `id` per row) instead — a hint
1941
+ // the agent can't act on just sends it back to guessing.
1942
+ linksHint:
1943
+ `Got ${linked} link(s) for ${rows.length} row(s). ` +
1944
+ (linked === 0
1945
+ ? `Run \`dench crm entries list ${objectName} --json\` to get each row's ` +
1946
+ `\`id\`, then \`dench crm entries get ${objectName} <id> --json\` for its ` +
1947
+ "canonical url"
1948
+ : `The rows above carry \`url\`/\`href\`; for the rest run ` +
1949
+ `\`dench crm entries list ${objectName} --json\` to get their \`id\`, then ` +
1950
+ `\`dench crm entries get ${objectName} <id> --json\``) +
1951
+ " — do not construct a url by hand.",
1952
+ }),
1953
+ });
1694
1954
  return;
1695
1955
  }
1696
1956
  case "update": {
@@ -1743,7 +2003,7 @@ async function runEntriesCommand(ctx: CrmCliContext): Promise<void> {
1743
2003
  updates.map((update) => update.data),
1744
2004
  allowUnknown,
1745
2005
  );
1746
- const chunks = await applyBatchOpsInChunks(
2006
+ const { chunks } = await applyBatchOpsInChunks(
1747
2007
  ctx,
1748
2008
  updates.map((update) => ({
1749
2009
  type: "entries.update",
@@ -1874,7 +2134,7 @@ async function runCellsCommand(ctx: CrmCliContext): Promise<void> {
1874
2134
  data,
1875
2135
  }),
1876
2136
  );
1877
- const chunks = await applyBatchOpsInChunks(ctx, ops, idempotencyKey);
2137
+ const { chunks } = await applyBatchOpsInChunks(ctx, ops, idempotencyKey);
1878
2138
  out(ctx, { updated: updates.length, entriesUpdated: ops.length, chunks });
1879
2139
  return;
1880
2140
  }
@@ -3287,6 +3547,10 @@ Objects (CRM tables):
3287
3547
  command/API identifier (works on protected objects too).
3288
3548
  dench crm objects rename <from> <to>
3289
3549
  dench crm objects delete <name>
3550
+ dench crm objects archive <name> | unarchive <name>
3551
+ Hides/restores the object's workspace SIDEBAR row. Not a delete —
3552
+ the table, its rows and every link keep working; the row just moves
3553
+ into the rail's Archived flyout. Team-shared.
3290
3554
 
3291
3555
  Views (saved CRM subsets):
3292
3556
  dench crm views list <object> [--json]
@@ -3306,6 +3570,22 @@ Views (saved CRM subsets):
3306
3570
  dench crm views delete <object> <view-name>
3307
3571
  Idempotent; also clears the active-view pointer if it referenced
3308
3572
  the deleted view.
3573
+ dench crm views pin <object> <view-name> | unpin <object> <view-name>
3574
+ Only pinned views render as workspace sidebar rows. Pinning also
3575
+ clears any archive stamp.
3576
+ dench crm views archive <object> <view-name> | unarchive <object> <view-name>
3577
+ Moves the view's sidebar row into the Archived flyout. Unarchiving
3578
+ re-asserts the pin so the row comes back.
3579
+
3580
+ Sidebar (the workspace app rail):
3581
+ dench crm sidebar list [--json]
3582
+ Visible rows in order with 1-based positions, plus what's hidden
3583
+ from the rail and why (archived vs. an unpinned view).
3584
+ dench crm sidebar move <nav-id> <top|bottom|N>
3585
+ Reposition one row. Nav ids come from 'sidebar list' — e.g.
3586
+ 'crm-object:task', 'crm-view:task:Due%20today', 'crm-people',
3587
+ 'automation'. Row ORDER is per-member; row visibility (above) is
3588
+ team-shared.
3309
3589
 
3310
3590
  Fields (columns):
3311
3591
  dench crm fields list <object> [--json]
@@ -3368,6 +3648,18 @@ Entries (rows):
3368
3648
  being silently dropped. Verify names with 'crm fields list <object>'
3369
3649
  first. Pass --allow-unknown to write the raw value anyway, and on
3370
3650
  create the object's required fields must be present.
3651
+ create / get / create-many also return deep links when the
3652
+ workspace slug is known: \`url\` (absolute shareable) and \`href\`
3653
+ (relative in-app). Paste those — never invent \`?object=\` or a
3654
+ bare \`entry=<id>\` without the \`<object>:\` prefix. Canonical form:
3655
+ /<slug>?path=<object>&entry=<object>:<entryId>
3656
+ The link uses the object's CANONICAL name (echoed back as
3657
+ \`objectName\`), not the alias you typed — \`create Tasks\` resolves
3658
+ server-side but only \`task\` opens the record. If a response has no
3659
+ \`url\`/\`href\`, run 'crm entries get <object> <entryId> --json' for
3660
+ one. If create-many reports \`linksUnavailable\`, the unlinked rows
3661
+ have no id in that response — run 'crm entries list <object> --json'
3662
+ to recover their \`id\` first.
3371
3663
 
3372
3664
  Cells (single-field updates):
3373
3665
  dench crm cells get <object> <entryId> <field>
@@ -3393,11 +3685,14 @@ Workspace members (for user-type fields):
3393
3685
  List \`{ userId, role, name, email }\` for every member of the
3394
3686
  current org. Use the userIds when setting \`Owner\`, \`Assignee\`,
3395
3687
  or any other \`user\`-type field so the UI shows a real linked
3396
- avatar instead of an unlinked literal-string badge. Check the
3688
+ avatar instead of an unlinked literal-string badge. Pass one id for
3689
+ one assignee or a JSON array for multiple assignees; never drop a
3690
+ co-owner from the source data. Check the
3397
3691
  object's actual field names first (\`crm fields list <object>\`) —
3398
3692
  the \`task\` object uses \`Assignee\`, not \`Owner\`, and some
3399
3693
  workspaces rename \`Name\` to \`Title\`:
3400
3694
  dench crm cells set task <entryId> Assignee '"<userId>"'
3695
+ dench crm cells set task <entryId> Assignee '["<userId1>","<userId2>"]'
3401
3696
  dench crm entries create task --data '{"Name":"X","Assignee":"<userId>"}'
3402
3697
 
3403
3698
  Batch:
package/dench.ts CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  writeFile,
11
11
  } from "node:fs/promises";
12
12
  import { homedir, hostname, userInfo } from "node:os";
13
- import { basename, join, relative, resolve } from "node:path";
13
+ import { basename, dirname, join, relative, resolve } from "node:path";
14
14
  import { fileURLToPath } from "node:url";
15
15
  import { ConvexHttpClient } from "convex/browser";
16
16
  import { makeFunctionReference } from "convex/server";
@@ -579,6 +579,7 @@ returns ENOSYS on the FUSE-backed volume mount):
579
579
  dench files ls <path> [--json]
580
580
  dench files mv <src> <dst> [--json]
581
581
  dench files rm <path> [--recursive] [--json]
582
+ dench files download <path> [<local-dest>] [--url] [--json]
582
583
 
583
584
  Persist scratch -> workspace (cp from /tmp into /workspace + Convex sync):
584
585
  dench stage <local-path> [<workspace-dest>] [--clean] [--json]
@@ -3412,19 +3413,151 @@ async function runFilesRm() {
3412
3413
  });
3413
3414
  }
3414
3415
 
3416
+ async function writeLocalFileBytes(localPath: string, bytes: Uint8Array) {
3417
+ const parent = dirname(localPath);
3418
+ if (parent && parent !== ".") {
3419
+ await mkdir(parent, { recursive: true });
3420
+ }
3421
+ await writeFile(localPath, bytes);
3422
+ }
3423
+
3424
+ async function runFilesDownload() {
3425
+ const runtime = await getRuntime();
3426
+ const apiKey = await requireFilesApiKey(runtime, "dench files download");
3427
+ const targetRaw = positional(2);
3428
+ if (!targetRaw) {
3429
+ throw new CliError(
3430
+ "Usage: dench files download <path> [<local-dest>] [--url]",
3431
+ { code: "files_download_usage" },
3432
+ );
3433
+ }
3434
+ const target = normalizeFilesPath(targetRaw);
3435
+ const jsonOut = hasFlag("--json");
3436
+
3437
+ if (hasFlag("--url")) {
3438
+ const url = (await runtime.client.action(
3439
+ api.functions.files.getFileSignedDownloadUrl,
3440
+ { path: target, apiKey } as never,
3441
+ )) as string | null;
3442
+ if (!url) {
3443
+ throw new CliError(`No downloadable bytes for ${target}`, {
3444
+ code: "files_download_url_not_found",
3445
+ nextActions: [
3446
+ "Directories have no signed URL; run `dench files download <dir>` without --url to fetch the tree.",
3447
+ "Run `dench files ls` on the parent directory to confirm the path.",
3448
+ ],
3449
+ });
3450
+ }
3451
+ if (jsonOut) {
3452
+ print({ ok: true, path: target, url, note: "Signed URL expires." });
3453
+ } else {
3454
+ process.stdout.write(`${url}\n`);
3455
+ }
3456
+ return;
3457
+ }
3458
+
3459
+ const self =
3460
+ target === "/"
3461
+ ? null
3462
+ : (await listConvexTreeAt(runtime, apiKey, parentOfPath(target))).find(
3463
+ (row) => row.path === target,
3464
+ );
3465
+ const isDir = target === "/" || self?.isDir === true;
3466
+
3467
+ if (!isDir) {
3468
+ const bytes = await downloadConvexFileBytes(runtime, apiKey, target);
3469
+ if (!bytes) {
3470
+ throw new CliError(`Not found in Convex: ${target}`, {
3471
+ code: "files_download_not_found",
3472
+ nextActions: [
3473
+ "Run `dench files ls` on the parent directory to confirm.",
3474
+ "If the file only exists on the sandbox FS, run `dench fs sync --initial` first.",
3475
+ ],
3476
+ });
3477
+ }
3478
+ let dest = resolve(positional(3) ?? basename(target));
3479
+ const destStat = await stat(dest).catch(() => null);
3480
+ if (destStat?.isDirectory()) {
3481
+ dest = join(dest, basename(target));
3482
+ }
3483
+ await writeLocalFileBytes(dest, bytes);
3484
+ if (jsonOut) {
3485
+ print({ ok: true, path: target, dest, bytes: bytes.byteLength });
3486
+ } else {
3487
+ process.stdout.write(
3488
+ `dench files download: ${target} -> ${dest} (${bytes.byteLength}B)\n`,
3489
+ );
3490
+ }
3491
+ return;
3492
+ }
3493
+
3494
+ // Directory: recreate the tree locally under <local-dest> (defaults to
3495
+ // the directory's basename in the cwd).
3496
+ const destRoot = resolve(
3497
+ positional(3) ?? (target === "/" ? "workspace" : basename(target)),
3498
+ );
3499
+ const rows = await listConvexTreeRecursive(runtime, apiKey, target);
3500
+ const prefixLength = target === "/" ? 1 : target.length + 1;
3501
+ let downloaded = 0;
3502
+ let bytesTotal = 0;
3503
+ const skipped: string[] = [];
3504
+ await mkdir(destRoot, { recursive: true });
3505
+ for (const row of rows) {
3506
+ const rel = row.path.slice(prefixLength);
3507
+ if (!rel) continue;
3508
+ const local = join(destRoot, rel);
3509
+ if (row.isDir) {
3510
+ await mkdir(local, { recursive: true });
3511
+ continue;
3512
+ }
3513
+ const bytes = await downloadConvexFileBytes(runtime, apiKey, row.path);
3514
+ if (!bytes) {
3515
+ skipped.push(row.path);
3516
+ continue;
3517
+ }
3518
+ await writeLocalFileBytes(local, bytes);
3519
+ downloaded += 1;
3520
+ bytesTotal += bytes.byteLength;
3521
+ if (!jsonOut && downloaded % 25 === 0) {
3522
+ process.stdout.write(
3523
+ `dench files download: ${downloaded} files, ${bytesTotal}B…\n`,
3524
+ );
3525
+ }
3526
+ }
3527
+ if (jsonOut) {
3528
+ print({
3529
+ ok: true,
3530
+ path: target,
3531
+ dest: destRoot,
3532
+ files: downloaded,
3533
+ bytes: bytesTotal,
3534
+ skipped,
3535
+ });
3536
+ } else {
3537
+ process.stdout.write(
3538
+ `dench files download: ${downloaded} files (${bytesTotal}B) -> ${destRoot}${
3539
+ skipped.length > 0 ? ` (${skipped.length} skipped)` : ""
3540
+ }\n`,
3541
+ );
3542
+ }
3543
+ }
3544
+
3415
3545
  async function runFilesCommand() {
3416
3546
  const sub = positional(1);
3417
3547
  if (!sub || sub === "help" || hasFlag("--help")) {
3418
3548
  process.stdout.write(
3419
3549
  [
3420
- "dench files <ls|mv|rm> — workspace file ops that update the UI tree",
3550
+ "dench files <ls|mv|rm|download> — workspace file ops that update the UI tree",
3421
3551
  "",
3422
3552
  " dench files ls [<path>] [--recursive] [--json]",
3423
3553
  " dench files mv <src> <dst> [--json]",
3424
3554
  " dench files rm <path> [--recursive] [--json]",
3555
+ " dench files download <path> [<local-dest>] [--url] [--json]",
3425
3556
  "",
3426
3557
  "Paths are POSIX paths under /workspace; you can pass either form.",
3427
3558
  "These mutate Convex directly (and the daemon syncs to the volume).",
3559
+ "download fetches canonical bytes from Convex (dirs recurse);",
3560
+ "--url prints a short-lived signed URL instead of saving.",
3428
3561
  ].join("\n"),
3429
3562
  );
3430
3563
  return;
@@ -3432,6 +3565,7 @@ async function runFilesCommand() {
3432
3565
  if (sub === "ls") return await runFilesLs();
3433
3566
  if (sub === "mv") return await runFilesMv();
3434
3567
  if (sub === "rm") return await runFilesRm();
3568
+ if (sub === "download") return await runFilesDownload();
3435
3569
  throw new CliError(`Unknown subcommand: dench files ${sub}`, {
3436
3570
  code: "files_unknown_subcommand",
3437
3571
  nextActions: ["Run dench files help for usage."],
@@ -3901,6 +4035,8 @@ async function main() {
3901
4035
  convex: runtime.client,
3902
4036
  args: subArgs,
3903
4037
  sessionToken: encodeDevCrmSessionToken(runtime.workspaceArgs),
4038
+ host: resolveApiHost(LOCAL_HOST),
4039
+ workspaceSlug: runtime.workspaceArgs.workspaceSlug,
3904
4040
  enrichmentGateway: gatewayAuth
3905
4041
  ? {
3906
4042
  apiKey: gatewayAuth.bearerToken,
@@ -3919,6 +4055,11 @@ async function main() {
3919
4055
  convex: authedRuntime.client,
3920
4056
  args: subArgs,
3921
4057
  sessionToken: authedRuntime.sessionToken,
4058
+ host: authedRuntime.host,
4059
+ workspaceSlug:
4060
+ authedRuntime.organization?.slug ??
4061
+ process.env.DENCH_ORG_SLUG?.trim() ??
4062
+ undefined,
3922
4063
  enrichmentGateway: gatewayAuth
3923
4064
  ? {
3924
4065
  apiKey: gatewayAuth.bearerToken,
@@ -44,7 +44,7 @@ export const FIELD_TYPES = [
44
44
  const fieldType = z
45
45
  .enum(FIELD_TYPES)
46
46
  .describe(
47
- "Field data type. `relation` needs relatedObjectName + relationshipType; `enum` uses enumValues/enumColors; `user` cells store a member userId; long prose belongs in the protected `Notes` richtext field.",
47
+ "Field data type. `relation` needs relatedObjectName + relationshipType; `enum` uses enumValues/enumColors; `user` cells store one member userId or an array of member userIds; long prose belongs in the protected `Notes` richtext field.",
48
48
  );
49
49
 
50
50
  const viewKind = z
@@ -139,12 +139,14 @@ const scheduleSpec = z
139
139
 
140
140
  const dataMap = z
141
141
  .record(z.string(), z.unknown())
142
- .describe("Field-name -> value map. Use member userIds for `user` fields.");
142
+ .describe(
143
+ "Field-name -> value map. For `user` fields use one member userId or a userId array; preserve every assignee.",
144
+ );
143
145
 
144
146
  const cellValue = z
145
147
  .unknown()
146
148
  .describe(
147
- "Cell value. Enum=option string, tags=string array, user=member userId, relation=related entry id, boolean=true/false.",
149
+ "Cell value. Enum=option string, tags=string array, user=one member userId or a userId array, relation=related entry id, boolean=true/false.",
148
150
  );
149
151
 
150
152
  const enrichmentConfig = z
@@ -2124,8 +2126,14 @@ export const responseSchemas: Record<string, ZodTypeAny> = {
2124
2126
  id: z.string(),
2125
2127
  role: z.enum(["user", "assistant", "system"]),
2126
2128
  createdAt: z.number(),
2129
+ updatedAt: z.number(),
2127
2130
  text: z.string(),
2128
2131
  runId: z.string().nullable(),
2132
+ status: z
2133
+ .enum(["streaming", "complete", "error", "canceled"])
2134
+ .nullable(),
2135
+ revision: z.number(),
2136
+ isDraft: z.boolean(),
2129
2137
  }),
2130
2138
  ),
2131
2139
  }),
@@ -2277,7 +2285,6 @@ export const responseSchemas: Record<string, ZodTypeAny> = {
2277
2285
  _creationTime: z.number(),
2278
2286
  objectId: z.string(),
2279
2287
  data: fieldsMap,
2280
- searchableText: z.string(),
2281
2288
  sortOrder: z.number(),
2282
2289
  createdAt: z.number(),
2283
2290
  updatedAt: z.number(),
@@ -2796,7 +2803,11 @@ export const requestExamples: Record<string, unknown> = {
2796
2803
  "crm.fields.enum.color": { color: "#22c55e" },
2797
2804
  "crm.fields.enum.add": { value: "Nurturing", color: "#eab308", position: 2 },
2798
2805
  "crm.entries.create": {
2799
- data: { Name: "Ada Lovelace", Email: "ada@example.com", Status: "New" },
2806
+ data: {
2807
+ Name: "Joint review",
2808
+ Assignee: ["user_kumar", "user_mark"],
2809
+ Status: "New",
2810
+ },
2800
2811
  },
2801
2812
  "crm.entries.createMany": {
2802
2813
  entries: [
@@ -2804,7 +2815,7 @@ export const requestExamples: Record<string, unknown> = {
2804
2815
  { Name: "Bob", Email: "bob@example.com" },
2805
2816
  ],
2806
2817
  },
2807
- "crm.cells.set": { value: "In progress" },
2818
+ "crm.cells.set": { value: ["user_kumar", "user_mark"] },
2808
2819
  "crm.query": {
2809
2820
  objectName: "lead",
2810
2821
  dslJson:
@@ -3125,15 +3136,23 @@ export const responseExamples: Record<string, unknown> = {
3125
3136
  id: "cm_def456",
3126
3137
  role: "user",
3127
3138
  createdAt: 1765526400000,
3139
+ updatedAt: 1765526400000,
3128
3140
  text: "How do we deploy?",
3129
3141
  runId: null,
3142
+ status: "complete",
3143
+ revision: 0,
3144
+ isDraft: false,
3130
3145
  },
3131
3146
  {
3132
3147
  id: "cm_ghi789",
3133
3148
  role: "assistant",
3134
3149
  createdAt: 1765526460000,
3150
+ updatedAt: 1765526460000,
3135
3151
  text: "Push to main; Vercel auto-deploys.",
3136
3152
  runId: "rn_jkl012",
3153
+ status: "complete",
3154
+ revision: 1,
3155
+ isDraft: false,
3137
3156
  },
3138
3157
  ],
3139
3158
  },
@@ -3368,7 +3387,6 @@ export const responseExamples: Record<string, unknown> = {
3368
3387
  _creationTime: 1765526400000,
3369
3388
  objectId: "obj_lead01",
3370
3389
  data: { Name: "Acme Corp", Status: "Qualified" },
3371
- searchableText: "Acme Corp Qualified",
3372
3390
  sortOrder: 0,
3373
3391
  createdAt: 1765526400000,
3374
3392
  updatedAt: 1765612800000,
@@ -1961,7 +1961,7 @@ const rawApiOperations = [
1961
1961
  id: "files.downloadUrl",
1962
1962
  group: "files",
1963
1963
  summary: "Get a signed file download URL.",
1964
- cli: "dench files ls",
1964
+ cli: "dench files download",
1965
1965
  method: "GET",
1966
1966
  path: "/files/download-url",
1967
1967
  requestSchema: noBody,
package/members.ts CHANGED
@@ -168,7 +168,9 @@ function membersHelp(): void {
168
168
 
169
169
  Workspace members for the active organization. Use these userIds when
170
170
  assigning CRM \`user\`-type cells (e.g. \`Owner\`, \`Assignee\`) so the
171
- UI shows a real linked avatar instead of an unlinked literal string.
171
+ UI shows real linked avatars instead of unlinked literal strings. Use one id
172
+ for one assignee or a JSON id array for multiple assignees; preserve every
173
+ owner from the source data.
172
174
 
173
175
  Subcommands:
174
176
  dench members list [--json]
@@ -185,6 +187,7 @@ After picking a userId (confirm field names with
185
187
  \`dench crm fields list <object>\` — the default \`task\` uses
186
188
  \`Assignee\`, not \`Owner\`, and some workspaces rename \`Name\` to \`Title\`):
187
189
  dench crm cells set task <entryId> Assignee '"<userId>"'
190
+ dench crm cells set task <entryId> Assignee '["<userId1>","<userId2>"]'
188
191
  dench crm entries create task --data '{"Name":"Ship CRM","Assignee":"<userId>"}'
189
192
 
190
193
  The server best-effort-resolves a name / email passed to a \`user\`
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@dench.com/cli",
3
- "version": "2.7.0",
4
- "description": "Dench agent workspace CLI. v2 unifies auth behind `dench signin`; the legacy `dench login` / `dench onboard` / `dench setup` / `dench what-can-i-do` / `dench register` commands now error with a redirect.",
3
+ "version": "2.7.2",
4
+ "description": "Dench agent workspace CLI.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "dench": "dench",