@dench.com/cli 0.2.0

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/crm.ts ADDED
@@ -0,0 +1,1402 @@
1
+ /**
2
+ * `dench crm <subcommand>` — CLI surface for the Convex CRM.
3
+ *
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.
7
+ *
8
+ * This is a thin command dispatcher; the heavy lifting lives in
9
+ * convex/functions/crm/* and convex/lib/crm/*.
10
+ *
11
+ * The full surface mirrors the plan's "dench-cli CRM surface" section:
12
+ * crm objects list / get / create / update / delete / rename
13
+ * crm fields list / create / update / delete / reorder
14
+ * crm entries list / get / create / update / delete / bulk-delete
15
+ * crm cells get / set / append
16
+ * crm query <object> --where … --select … --sort … --limit …
17
+ * crm aggregate <object> --field …
18
+ * crm batch --file ops.jsonl
19
+ * crm statuses list / set
20
+ * crm docs list / create / link
21
+ * crm actions list / run / runs
22
+ */
23
+ import { ConvexHttpClient } from "convex/browser";
24
+ import { makeFunctionReference } from "convex/server";
25
+ import {
26
+ CliArgError,
27
+ getFlag,
28
+ hasFlag,
29
+ parseJson as parseJsonRaw,
30
+ shift as shiftRaw,
31
+ } from "./lib/cli-args";
32
+
33
+ type JsonRecord = Record<string, unknown>;
34
+
35
+ type CrmCliContext = {
36
+ convex: ConvexHttpClient;
37
+ args: string[];
38
+ jsonOutput: boolean;
39
+ /**
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.
43
+ */
44
+ sessionToken?: string;
45
+ };
46
+
47
+ // Adapt the shared helpers to throw CrmCliError so existing call sites
48
+ // don't need to change their try/catch shape.
49
+ class CrmCliError extends Error {}
50
+
51
+ function shift(args: string[], expected: string): string {
52
+ try {
53
+ return shiftRaw(args, expected);
54
+ } catch (error) {
55
+ if (error instanceof CliArgError) throw new CrmCliError(error.message);
56
+ throw error;
57
+ }
58
+ }
59
+
60
+ function parseJson(value: string | undefined): unknown {
61
+ try {
62
+ return parseJsonRaw(value);
63
+ } catch (error) {
64
+ if (error instanceof CliArgError) throw new CrmCliError(error.message);
65
+ throw error;
66
+ }
67
+ }
68
+
69
+ async function callQuery(
70
+ ctx: CrmCliContext,
71
+ fn: Parameters<ConvexHttpClient["query"]>[0],
72
+ args: Record<string, unknown> = {},
73
+ ): Promise<unknown> {
74
+ return ctx.convex.query(fn, {
75
+ ...args,
76
+ ...(ctx.sessionToken ? { sessionToken: ctx.sessionToken } : {}),
77
+ } as never);
78
+ }
79
+
80
+ async function callMutation(
81
+ ctx: CrmCliContext,
82
+ fn: Parameters<ConvexHttpClient["mutation"]>[0],
83
+ args: Record<string, unknown> = {},
84
+ ): Promise<unknown> {
85
+ return ctx.convex.mutation(fn, {
86
+ ...args,
87
+ ...(ctx.sessionToken ? { sessionToken: ctx.sessionToken } : {}),
88
+ } as never);
89
+ }
90
+
91
+ function out(ctx: CrmCliContext, value: unknown): void {
92
+ if (ctx.jsonOutput) {
93
+ console.log(JSON.stringify(value, null, 2));
94
+ return;
95
+ }
96
+ if (value === null || value === undefined) {
97
+ console.log("(empty)");
98
+ return;
99
+ }
100
+ if (Array.isArray(value)) {
101
+ if (value.length === 0) {
102
+ console.log("(no rows)");
103
+ return;
104
+ }
105
+ console.log(JSON.stringify(value, null, 2));
106
+ return;
107
+ }
108
+ console.log(JSON.stringify(value, null, 2));
109
+ }
110
+
111
+ const api = {
112
+ transaction: {
113
+ begin: makeFunctionReference<"mutation">(
114
+ "functions/crm/transaction:begin",
115
+ ),
116
+ addOp: makeFunctionReference<"mutation">(
117
+ "functions/crm/transaction:addOp",
118
+ ),
119
+ commit: makeFunctionReference<"mutation">(
120
+ "functions/crm/transaction:commit",
121
+ ),
122
+ abort: makeFunctionReference<"mutation">(
123
+ "functions/crm/transaction:abort",
124
+ ),
125
+ inspect: makeFunctionReference<"query">(
126
+ "functions/crm/transaction:inspect",
127
+ ),
128
+ },
129
+ reports: {
130
+ generate: makeFunctionReference<"query">(
131
+ "functions/crm/reports:generate",
132
+ ),
133
+ },
134
+ enrich: {
135
+ requestCellEnrichment: makeFunctionReference<"mutation">(
136
+ "functions/crm/enrich:requestCellEnrichment",
137
+ ),
138
+ requestObjectEnrichment: makeFunctionReference<"mutation">(
139
+ "functions/crm/enrich:requestObjectEnrichment",
140
+ ),
141
+ },
142
+ objects: {
143
+ list: makeFunctionReference<"query">("functions/crm/objects:list"),
144
+ get: makeFunctionReference<"query">("functions/crm/objects:get"),
145
+ create: makeFunctionReference<"mutation">("functions/crm/objects:create"),
146
+ update: makeFunctionReference<"mutation">("functions/crm/objects:update"),
147
+ rename: makeFunctionReference<"mutation">("functions/crm/objects:rename"),
148
+ remove: makeFunctionReference<"mutation">("functions/crm/objects:remove"),
149
+ },
150
+ fields: {
151
+ list: makeFunctionReference<"query">("functions/crm/fields:list"),
152
+ create: makeFunctionReference<"mutation">("functions/crm/fields:create"),
153
+ update: makeFunctionReference<"mutation">("functions/crm/fields:update"),
154
+ remove: makeFunctionReference<"mutation">("functions/crm/fields:remove"),
155
+ reorder: makeFunctionReference<"mutation">(
156
+ "functions/crm/fields:reorder",
157
+ ),
158
+ },
159
+ entries: {
160
+ list: makeFunctionReference<"query">("functions/crm/entries:list"),
161
+ get: makeFunctionReference<"query">("functions/crm/entries:get"),
162
+ create: makeFunctionReference<"mutation">(
163
+ "functions/crm/entries:create",
164
+ ),
165
+ update: makeFunctionReference<"mutation">(
166
+ "functions/crm/entries:update",
167
+ ),
168
+ remove: makeFunctionReference<"mutation">(
169
+ "functions/crm/entries:remove",
170
+ ),
171
+ bulkDelete: makeFunctionReference<"mutation">(
172
+ "functions/crm/entries:bulkDelete",
173
+ ),
174
+ },
175
+ cells: {
176
+ get: makeFunctionReference<"query">("functions/crm/cells:get"),
177
+ set: makeFunctionReference<"mutation">("functions/crm/cells:set"),
178
+ append: makeFunctionReference<"mutation">(
179
+ "functions/crm/cells:append",
180
+ ),
181
+ },
182
+ query: {
183
+ queryEntries: makeFunctionReference<"query">(
184
+ "functions/crm/query:queryEntries",
185
+ ),
186
+ aggregate: makeFunctionReference<"query">("functions/crm/query:aggregate"),
187
+ search: makeFunctionReference<"query">("functions/crm/query:search"),
188
+ },
189
+ statuses: {
190
+ list: makeFunctionReference<"query">("functions/crm/statuses:list"),
191
+ upsertMany: makeFunctionReference<"mutation">(
192
+ "functions/crm/statuses:upsertMany",
193
+ ),
194
+ },
195
+ batch: {
196
+ apply: makeFunctionReference<"mutation">("functions/crm/batch:apply"),
197
+ },
198
+ documents: {
199
+ list: makeFunctionReference<"query">("functions/crm/documents:list"),
200
+ create: makeFunctionReference<"mutation">(
201
+ "functions/crm/documents:create",
202
+ ),
203
+ link: makeFunctionReference<"mutation">("functions/crm/documents:link"),
204
+ },
205
+ actions: {
206
+ list: makeFunctionReference<"query">("functions/crm/actions:list"),
207
+ startActionRun: makeFunctionReference<"mutation">(
208
+ "functions/crm/actions:startActionRun",
209
+ ),
210
+ },
211
+ };
212
+
213
+ export async function runCrmCommand(opts: {
214
+ convex: ConvexHttpClient;
215
+ args: string[];
216
+ sessionToken?: string;
217
+ }): Promise<void> {
218
+ const args = [...opts.args];
219
+ const jsonOutput = hasFlag(args, "--json");
220
+ const ctx: CrmCliContext = {
221
+ convex: opts.convex,
222
+ args,
223
+ jsonOutput,
224
+ sessionToken: opts.sessionToken,
225
+ };
226
+ const subcommand = args.shift();
227
+ if (!subcommand || subcommand === "help" || subcommand === "--help") {
228
+ crmHelp();
229
+ return;
230
+ }
231
+ switch (subcommand) {
232
+ case "objects":
233
+ return await runObjectsCommand(ctx);
234
+ case "fields":
235
+ return await runFieldsCommand(ctx);
236
+ case "entries":
237
+ return await runEntriesCommand(ctx);
238
+ case "cells":
239
+ return await runCellsCommand(ctx);
240
+ case "query":
241
+ return await runQueryCommand(ctx);
242
+ case "sql":
243
+ return await runSqlCommand(ctx);
244
+ case "aggregate":
245
+ return await runAggregateCommand(ctx);
246
+ case "search":
247
+ return await runSearchCommand(ctx);
248
+ case "statuses":
249
+ return await runStatusesCommand(ctx);
250
+ case "batch":
251
+ return await runBatchCommand(ctx);
252
+ case "import":
253
+ return await runImportCommand(ctx);
254
+ case "export":
255
+ return await runExportCommand(ctx);
256
+ case "transaction":
257
+ return await runTransactionCommand(ctx);
258
+ case "people":
259
+ return await runPeopleCommand(ctx);
260
+ case "companies":
261
+ return await runCompaniesCommand(ctx);
262
+ case "enrich":
263
+ return await runEnrichCommand(ctx);
264
+ case "reports":
265
+ return await runReportsCommand(ctx);
266
+ case "docs":
267
+ case "documents":
268
+ return await runDocsCommand(ctx);
269
+ case "actions":
270
+ return await runActionsCommand(ctx);
271
+ default:
272
+ throw new CrmCliError(`Unknown crm subcommand: ${subcommand}`);
273
+ }
274
+ }
275
+
276
+ async function runObjectsCommand(ctx: CrmCliContext): Promise<void> {
277
+ const verb = ctx.args.shift();
278
+ switch (verb) {
279
+ case "list": {
280
+ out(ctx, await callQuery(ctx, api.objects.list, {}));
281
+ return;
282
+ }
283
+ case "get": {
284
+ const name = shift(ctx.args, "object name");
285
+ out(ctx, await callQuery(ctx, api.objects.get, { name }));
286
+ return;
287
+ }
288
+ case "create": {
289
+ const name = shift(ctx.args, "object name");
290
+ const description = getFlag(ctx.args, "--description");
291
+ const defaultView = getFlag(ctx.args, "--default-view") as any;
292
+ const icon = getFlag(ctx.args, "--icon");
293
+ out(
294
+ ctx,
295
+ await callMutation(ctx, api.objects.create, {
296
+ name,
297
+ description,
298
+ defaultView,
299
+ icon,
300
+ }),
301
+ );
302
+ return;
303
+ }
304
+ case "update": {
305
+ const name = shift(ctx.args, "object name");
306
+ const description = getFlag(ctx.args, "--description");
307
+ const defaultView = getFlag(ctx.args, "--default-view") as any;
308
+ const icon = getFlag(ctx.args, "--icon");
309
+ out(
310
+ ctx,
311
+ await callMutation(ctx, api.objects.update, {
312
+ name,
313
+ description,
314
+ defaultView,
315
+ icon,
316
+ }),
317
+ );
318
+ return;
319
+ }
320
+ case "rename": {
321
+ const from = shift(ctx.args, "current name");
322
+ const to = shift(ctx.args, "new name");
323
+ out(ctx, await callMutation(ctx, api.objects.rename, { from, to }));
324
+ return;
325
+ }
326
+ case "delete":
327
+ case "remove": {
328
+ const name = shift(ctx.args, "object name");
329
+ out(ctx, await callMutation(ctx, api.objects.remove, { name }));
330
+ return;
331
+ }
332
+ default:
333
+ throw new CrmCliError(`Unknown crm objects verb: ${verb ?? "<none>"}`);
334
+ }
335
+ }
336
+
337
+ async function runFieldsCommand(ctx: CrmCliContext): Promise<void> {
338
+ const verb = ctx.args.shift();
339
+ switch (verb) {
340
+ case "list": {
341
+ const objectName = shift(ctx.args, "object name");
342
+ out(ctx, await callQuery(ctx, api.fields.list, { objectName }));
343
+ return;
344
+ }
345
+ case "create": {
346
+ const objectName = shift(ctx.args, "object name");
347
+ const name = shift(ctx.args, "field name");
348
+ const type = (getFlag(ctx.args, "--type") ?? "text") as any;
349
+ const required = hasFlag(ctx.args, "--required");
350
+ const description = getFlag(ctx.args, "--description");
351
+ const enumValues = parseJson(getFlag(ctx.args, "--enum-values")) as
352
+ | string[]
353
+ | undefined;
354
+ const enumColors = parseJson(getFlag(ctx.args, "--enum-colors")) as
355
+ | string[]
356
+ | undefined;
357
+ const enumMultiple = hasFlag(ctx.args, "--enum-multiple");
358
+ const relatedObjectName = getFlag(ctx.args, "--related-object");
359
+ const relationshipType = getFlag(ctx.args, "--relationship") as any;
360
+ const indexed = hasFlag(ctx.args, "--indexed")
361
+ ? true
362
+ : hasFlag(ctx.args, "--no-indexed")
363
+ ? false
364
+ : undefined;
365
+ out(
366
+ ctx,
367
+ await callMutation(ctx, api.fields.create, {
368
+ objectName,
369
+ name,
370
+ type,
371
+ required,
372
+ description,
373
+ enumValues,
374
+ enumColors,
375
+ enumMultiple,
376
+ relatedObjectName,
377
+ relationshipType,
378
+ indexed,
379
+ }),
380
+ );
381
+ return;
382
+ }
383
+ case "update": {
384
+ const objectName = shift(ctx.args, "object name");
385
+ const fieldName = shift(ctx.args, "field name");
386
+ const description = getFlag(ctx.args, "--description");
387
+ const required = hasFlag(ctx.args, "--required");
388
+ const enumValues = parseJson(getFlag(ctx.args, "--enum-values")) as
389
+ | string[]
390
+ | undefined;
391
+ const indexed = hasFlag(ctx.args, "--indexed")
392
+ ? true
393
+ : hasFlag(ctx.args, "--no-indexed")
394
+ ? false
395
+ : undefined;
396
+ out(
397
+ ctx,
398
+ await callMutation(ctx, api.fields.update, {
399
+ objectName,
400
+ fieldName,
401
+ description,
402
+ required,
403
+ enumValues,
404
+ indexed,
405
+ }),
406
+ );
407
+ return;
408
+ }
409
+ case "delete":
410
+ case "remove": {
411
+ const objectName = shift(ctx.args, "object name");
412
+ const fieldName = shift(ctx.args, "field name");
413
+ out(
414
+ ctx,
415
+ await callMutation(ctx, api.fields.remove, {
416
+ objectName,
417
+ fieldName,
418
+ }),
419
+ );
420
+ return;
421
+ }
422
+ case "reorder": {
423
+ const objectName = shift(ctx.args, "object name");
424
+ const orderedFieldNames = ctx.args.filter((arg) => !arg.startsWith("--"));
425
+ out(
426
+ ctx,
427
+ await callMutation(ctx, api.fields.reorder, {
428
+ objectName,
429
+ orderedFieldNames,
430
+ }),
431
+ );
432
+ return;
433
+ }
434
+ default:
435
+ throw new CrmCliError(`Unknown crm fields verb: ${verb ?? "<none>"}`);
436
+ }
437
+ }
438
+
439
+ async function runEntriesCommand(ctx: CrmCliContext): Promise<void> {
440
+ const verb = ctx.args.shift();
441
+ switch (verb) {
442
+ case "list": {
443
+ const objectName = shift(ctx.args, "object name");
444
+ const limit = parseInt(getFlag(ctx.args, "--limit") ?? "100", 10);
445
+ out(
446
+ ctx,
447
+ await callQuery(ctx, api.entries.list, { objectName, limit }),
448
+ );
449
+ return;
450
+ }
451
+ case "get": {
452
+ const objectName = shift(ctx.args, "object name");
453
+ const entryId = shift(ctx.args, "entry id");
454
+ out(
455
+ ctx,
456
+ await callQuery(ctx, api.entries.get, {
457
+ objectName,
458
+ entryId: entryId as any,
459
+ }),
460
+ );
461
+ return;
462
+ }
463
+ case "create": {
464
+ const objectName = shift(ctx.args, "object name");
465
+ const data =
466
+ (parseJson(getFlag(ctx.args, "--data")) as JsonRecord | undefined) ??
467
+ {};
468
+ out(
469
+ ctx,
470
+ await callMutation(ctx, api.entries.create, { objectName, data }),
471
+ );
472
+ return;
473
+ }
474
+ case "update": {
475
+ const objectName = shift(ctx.args, "object name");
476
+ const entryId = shift(ctx.args, "entry id");
477
+ const data =
478
+ (parseJson(getFlag(ctx.args, "--data")) as JsonRecord | undefined) ??
479
+ {};
480
+ out(
481
+ ctx,
482
+ await callMutation(ctx, api.entries.update, {
483
+ objectName,
484
+ entryId: entryId as any,
485
+ data,
486
+ }),
487
+ );
488
+ return;
489
+ }
490
+ case "delete":
491
+ case "remove": {
492
+ const _objectName = shift(ctx.args, "object name");
493
+ const entryId = shift(ctx.args, "entry id");
494
+ out(
495
+ ctx,
496
+ await callMutation(ctx, api.entries.remove, {
497
+ entryId: entryId as any,
498
+ }),
499
+ );
500
+ return;
501
+ }
502
+ case "bulk-delete":
503
+ case "bulkDelete": {
504
+ const _objectName = shift(ctx.args, "object name");
505
+ const idsCsv = getFlag(ctx.args, "--ids") ?? "";
506
+ const entryIds = idsCsv.split(",").filter(Boolean);
507
+ out(
508
+ ctx,
509
+ await callMutation(ctx, api.entries.bulkDelete, {
510
+ entryIds: entryIds as any,
511
+ }),
512
+ );
513
+ return;
514
+ }
515
+ default:
516
+ throw new CrmCliError(`Unknown crm entries verb: ${verb ?? "<none>"}`);
517
+ }
518
+ }
519
+
520
+ async function runCellsCommand(ctx: CrmCliContext): Promise<void> {
521
+ const verb = ctx.args.shift();
522
+ const objectName = shift(ctx.args, "object name");
523
+ const entryId = shift(ctx.args, "entry id");
524
+ const fieldName = shift(ctx.args, "field name");
525
+ switch (verb) {
526
+ case "get": {
527
+ out(
528
+ ctx,
529
+ await callQuery(ctx, api.cells.get, {
530
+ objectName,
531
+ entryId: entryId as any,
532
+ fieldName,
533
+ }),
534
+ );
535
+ return;
536
+ }
537
+ case "set": {
538
+ const value = parseJson(ctx.args.shift()) ?? null;
539
+ out(
540
+ ctx,
541
+ await callMutation(ctx, api.cells.set, {
542
+ objectName,
543
+ entryId: entryId as any,
544
+ fieldName,
545
+ value,
546
+ }),
547
+ );
548
+ return;
549
+ }
550
+ case "append": {
551
+ const value = parseJson(ctx.args.shift()) ?? null;
552
+ out(
553
+ ctx,
554
+ await callMutation(ctx, api.cells.append, {
555
+ objectName,
556
+ entryId: entryId as any,
557
+ fieldName,
558
+ value,
559
+ }),
560
+ );
561
+ return;
562
+ }
563
+ default:
564
+ throw new CrmCliError(`Unknown crm cells verb: ${verb ?? "<none>"}`);
565
+ }
566
+ }
567
+
568
+ async function runQueryCommand(ctx: CrmCliContext): Promise<void> {
569
+ const objectName = shift(ctx.args, "object name");
570
+ const filter = parseJson(getFlag(ctx.args, "--where")) as
571
+ | JsonRecord
572
+ | undefined;
573
+ const select = getFlag(ctx.args, "--select")?.split(",").filter(Boolean);
574
+ const sort = getFlag(ctx.args, "--sort");
575
+ const limit = parseInt(getFlag(ctx.args, "--limit") ?? "100", 10);
576
+ // Convex's wire serializer rejects $-prefixed object keys, so we
577
+ // route the DSL through the dslJson string field whenever the filter
578
+ // has any operator-shaped clause.
579
+ const dsl = { filter, select, sort, limit };
580
+ const hasDollarKey = filter ? containsDollarKey(filter) : false;
581
+ out(
582
+ ctx,
583
+ await callQuery(ctx,
584
+ api.query.queryEntries,
585
+ hasDollarKey
586
+ ? { objectName, dslJson: JSON.stringify(dsl) }
587
+ : { objectName, dsl },
588
+ ),
589
+ );
590
+ }
591
+
592
+ export function containsDollarKey(value: unknown): boolean {
593
+ if (!value || typeof value !== "object") return false;
594
+ if (Array.isArray(value)) return value.some(containsDollarKey);
595
+ for (const key of Object.keys(value)) {
596
+ if (key.startsWith("$")) return true;
597
+ if (containsDollarKey((value as Record<string, unknown>)[key])) {
598
+ return true;
599
+ }
600
+ }
601
+ return false;
602
+ }
603
+
604
+ async function runAggregateCommand(ctx: CrmCliContext): Promise<void> {
605
+ const objectName = shift(ctx.args, "object name");
606
+ const fieldName = getFlag(ctx.args, "--field");
607
+ if (!fieldName) throw new CrmCliError("--field is required");
608
+ out(
609
+ ctx,
610
+ await callQuery(ctx, api.query.aggregate, { objectName, fieldName }),
611
+ );
612
+ }
613
+
614
+ async function runSearchCommand(ctx: CrmCliContext): Promise<void> {
615
+ const objectName = getFlag(ctx.args, "--object");
616
+ const text = ctx.args.join(" ").trim();
617
+ if (!text) throw new CrmCliError("Search text required");
618
+ const limit = parseInt(getFlag(ctx.args, "--limit") ?? "50", 10);
619
+ out(
620
+ ctx,
621
+ await callQuery(ctx, api.query.search, { text, objectName, limit }),
622
+ );
623
+ }
624
+
625
+ async function runStatusesCommand(ctx: CrmCliContext): Promise<void> {
626
+ const verb = ctx.args.shift();
627
+ switch (verb) {
628
+ case "list": {
629
+ const objectName = shift(ctx.args, "object name");
630
+ out(ctx, await callQuery(ctx, api.statuses.list, { objectName }));
631
+ return;
632
+ }
633
+ case "set": {
634
+ const objectName = shift(ctx.args, "object name");
635
+ const statuses = parseJson(getFlag(ctx.args, "--statuses")) as any[];
636
+ if (!Array.isArray(statuses)) {
637
+ throw new CrmCliError("--statuses (JSON array) is required");
638
+ }
639
+ out(
640
+ ctx,
641
+ await callMutation(ctx, api.statuses.upsertMany, {
642
+ objectName,
643
+ statuses,
644
+ }),
645
+ );
646
+ return;
647
+ }
648
+ default:
649
+ throw new CrmCliError(`Unknown crm statuses verb: ${verb ?? "<none>"}`);
650
+ }
651
+ }
652
+
653
+ async function runBatchCommand(ctx: CrmCliContext): Promise<void> {
654
+ const filePath = getFlag(ctx.args, "--file");
655
+ if (!filePath) {
656
+ throw new CrmCliError(
657
+ "dench crm batch requires --file <ops.jsonl> (one op per line)",
658
+ );
659
+ }
660
+ const fs = await import("node:fs/promises");
661
+ const text = await fs.readFile(filePath, "utf-8");
662
+ const ops: unknown[] = [];
663
+ for (const line of text.split("\n")) {
664
+ const trimmed = line.trim();
665
+ if (!trimmed || trimmed.startsWith("#")) continue;
666
+ ops.push(JSON.parse(trimmed));
667
+ }
668
+ if (ops.length === 0) {
669
+ throw new CrmCliError(`No operations found in ${filePath}`);
670
+ }
671
+ out(
672
+ ctx,
673
+ await callMutation(ctx, api.batch.apply, {
674
+ ops: ops as any,
675
+ }),
676
+ );
677
+ }
678
+
679
+ async function runDocsCommand(ctx: CrmCliContext): Promise<void> {
680
+ const verb = ctx.args.shift();
681
+ switch (verb) {
682
+ case "list": {
683
+ out(ctx, await callQuery(ctx, api.documents.list, {}));
684
+ return;
685
+ }
686
+ case "create": {
687
+ const filePath = shift(ctx.args, "file path");
688
+ const title = getFlag(ctx.args, "--title") ?? filePath.split("/").pop()!;
689
+ const icon = getFlag(ctx.args, "--icon");
690
+ const linkEntryId = getFlag(ctx.args, "--link-entry");
691
+ out(
692
+ ctx,
693
+ await callMutation(ctx, api.documents.create, {
694
+ filePath,
695
+ title,
696
+ icon,
697
+ entryId: linkEntryId as any,
698
+ }),
699
+ );
700
+ return;
701
+ }
702
+ case "link": {
703
+ const filePath = shift(ctx.args, "file path");
704
+ const objectName = getFlag(ctx.args, "--object");
705
+ const entryId = getFlag(ctx.args, "--entry");
706
+ out(
707
+ ctx,
708
+ await callMutation(ctx, api.documents.link, {
709
+ filePath,
710
+ objectName,
711
+ entryId: entryId as any,
712
+ }),
713
+ );
714
+ return;
715
+ }
716
+ default:
717
+ throw new CrmCliError(`Unknown crm docs verb: ${verb ?? "<none>"}`);
718
+ }
719
+ }
720
+
721
+ async function runActionsCommand(ctx: CrmCliContext): Promise<void> {
722
+ const verb = ctx.args.shift();
723
+ switch (verb) {
724
+ case "list": {
725
+ const entryId = shift(ctx.args, "entry id");
726
+ out(
727
+ ctx,
728
+ await callQuery(ctx, api.actions.list, {
729
+ entryId: entryId as any,
730
+ }),
731
+ );
732
+ return;
733
+ }
734
+ case "run": {
735
+ // Two-step happy path: insert crmActionRuns row → POST run-action
736
+ // route which creates the workflow run + starts agentRunWorkflow.
737
+ const actionId = shift(ctx.args, "action id");
738
+ const fieldId = getFlag(ctx.args, "--field-id");
739
+ const entryId = getFlag(ctx.args, "--entry-id");
740
+ const goal = getFlag(ctx.args, "--goal") ?? "";
741
+ const wait = hasFlag(ctx.args, "--wait");
742
+ if (!fieldId || !entryId) {
743
+ throw new CrmCliError(
744
+ "dench crm actions run requires --field-id and --entry-id",
745
+ );
746
+ }
747
+ const created = (await callMutation(ctx, api.actions.startActionRun, {
748
+ actionId,
749
+ fieldId: fieldId as any,
750
+ entryId: entryId as any,
751
+ })) as { id: string };
752
+ // Hop through /api/crm/run-action so the workflow actually starts.
753
+ const apiBase = (
754
+ process.env.DENCH_API_URL ?? "http://localhost:3000"
755
+ ).replace(/\/+$/, "");
756
+ const apiKey =
757
+ process.env.DENCH_API_KEY?.trim() ??
758
+ process.env.DENCH_FALLBACK_API_KEY?.trim() ??
759
+ "";
760
+ if (!apiKey) {
761
+ throw new CrmCliError(
762
+ "DENCH_API_KEY required to fire the action workflow",
763
+ );
764
+ }
765
+ const response = await fetch(`${apiBase}/api/crm/run-action`, {
766
+ method: "POST",
767
+ headers: {
768
+ "content-type": "application/json",
769
+ authorization: `Bearer ${apiKey}`,
770
+ },
771
+ body: JSON.stringify({ actionRunId: created.id, goal }),
772
+ });
773
+ const json = (await response.json().catch(() => ({}))) as {
774
+ runId?: string;
775
+ error?: string;
776
+ };
777
+ if (!response.ok) {
778
+ throw new CrmCliError(
779
+ `run-action failed (${response.status}): ${json.error ?? ""}`,
780
+ );
781
+ }
782
+ out(ctx, { actionRunId: created.id, runId: json.runId, wait });
783
+ return;
784
+ }
785
+ default:
786
+ throw new CrmCliError(`Unknown crm actions verb: ${verb ?? "<none>"}`);
787
+ }
788
+ }
789
+
790
+ // ── Transactions ─────────────────────────────────────────────────────────
791
+
792
+ async function runTransactionCommand(ctx: CrmCliContext): Promise<void> {
793
+ const verb = ctx.args.shift();
794
+ switch (verb) {
795
+ case "begin": {
796
+ out(ctx, await callMutation(ctx, api.transaction.begin, {}));
797
+ return;
798
+ }
799
+ case "add": {
800
+ const txnId = shift(ctx.args, "txn id");
801
+ const opType = shift(ctx.args, "op type (entries.create|update|delete)");
802
+ const objectName = ctx.args[0];
803
+ let op: any;
804
+ if (opType === "entries.create") {
805
+ op = {
806
+ type: "entries.create",
807
+ objectName: shift(ctx.args, "object name"),
808
+ data:
809
+ (parseJson(getFlag(ctx.args, "--data")) as JsonRecord | undefined) ??
810
+ {},
811
+ };
812
+ } else if (opType === "entries.update") {
813
+ op = {
814
+ type: "entries.update",
815
+ entryId: shift(ctx.args, "entry id"),
816
+ data:
817
+ (parseJson(getFlag(ctx.args, "--data")) as JsonRecord | undefined) ??
818
+ {},
819
+ };
820
+ } else if (opType === "entries.delete") {
821
+ op = { type: "entries.delete", entryId: shift(ctx.args, "entry id") };
822
+ } else {
823
+ throw new CrmCliError(`Unknown txn op type: ${opType}`);
824
+ }
825
+ out(
826
+ ctx,
827
+ await callMutation(ctx, api.transaction.addOp, {
828
+ txnId: txnId as any,
829
+ op,
830
+ }),
831
+ );
832
+ return;
833
+ }
834
+ case "commit": {
835
+ const txnId = shift(ctx.args, "txn id");
836
+ out(
837
+ ctx,
838
+ await callMutation(ctx, api.transaction.commit, {
839
+ txnId: txnId as any,
840
+ }),
841
+ );
842
+ return;
843
+ }
844
+ case "abort": {
845
+ const txnId = shift(ctx.args, "txn id");
846
+ out(
847
+ ctx,
848
+ await callMutation(ctx, api.transaction.abort, {
849
+ txnId: txnId as any,
850
+ }),
851
+ );
852
+ return;
853
+ }
854
+ case "inspect": {
855
+ const txnId = shift(ctx.args, "txn id");
856
+ out(
857
+ ctx,
858
+ await callQuery(ctx, api.transaction.inspect, {
859
+ txnId: txnId as any,
860
+ }),
861
+ );
862
+ return;
863
+ }
864
+ case undefined:
865
+ case "--file": {
866
+ // Single-shot: read JSON ops file, run begin → addOp* → commit.
867
+ ctx.args.unshift(verb ?? "");
868
+ const filePath = getFlag(ctx.args, "--file");
869
+ const commit = hasFlag(ctx.args, "--commit");
870
+ if (!filePath) {
871
+ throw new CrmCliError(
872
+ "dench crm transaction --file <ops.json> [--commit]",
873
+ );
874
+ }
875
+ const fs = await import("node:fs/promises");
876
+ const text = await fs.readFile(filePath, "utf-8");
877
+ const ops = JSON.parse(text) as unknown[];
878
+ const begun = (await callMutation(ctx,
879
+ api.transaction.begin,
880
+ {},
881
+ )) as { txnId: string };
882
+ for (const op of ops) {
883
+ await callMutation(ctx, api.transaction.addOp, {
884
+ txnId: begun.txnId as any,
885
+ op: op as any,
886
+ });
887
+ }
888
+ if (commit) {
889
+ out(
890
+ ctx,
891
+ await callMutation(ctx, api.transaction.commit, {
892
+ txnId: begun.txnId as any,
893
+ }),
894
+ );
895
+ } else {
896
+ out(ctx, { txnId: begun.txnId, queued: ops.length });
897
+ }
898
+ return;
899
+ }
900
+ default:
901
+ throw new CrmCliError(`Unknown crm transaction verb: ${verb}`);
902
+ }
903
+ }
904
+
905
+ // ── Import / export ──────────────────────────────────────────────────────
906
+
907
+ async function runImportCommand(ctx: CrmCliContext): Promise<void> {
908
+ const objectName = shift(ctx.args, "object name");
909
+ const csvPath = getFlag(ctx.args, "--csv");
910
+ const jsonlPath = getFlag(ctx.args, "--jsonl");
911
+ const mapJson = getFlag(ctx.args, "--map");
912
+ const onConflict = (getFlag(ctx.args, "--on-conflict") ?? "skip") as
913
+ | "skip"
914
+ | "update"
915
+ | "error";
916
+ if (!csvPath && !jsonlPath) {
917
+ throw new CrmCliError(
918
+ "dench crm import <object> requires --csv <path> or --jsonl <path>",
919
+ );
920
+ }
921
+ const fs = await import("node:fs/promises");
922
+ const colMap = mapJson ? (parseJson(mapJson) as Record<string, string>) : {};
923
+ const rows: Record<string, unknown>[] = [];
924
+ if (csvPath) {
925
+ const text = await fs.readFile(csvPath, "utf-8");
926
+ rows.push(...parseCsv(text, colMap));
927
+ }
928
+ if (jsonlPath) {
929
+ const text = await fs.readFile(jsonlPath, "utf-8");
930
+ for (const line of text.split("\n")) {
931
+ const trimmed = line.trim();
932
+ if (!trimmed || trimmed.startsWith("#")) continue;
933
+ const parsed = JSON.parse(trimmed) as Record<string, unknown>;
934
+ rows.push(remapKeys(parsed, colMap));
935
+ }
936
+ }
937
+ // Chunk into 100-op batches and call batch.apply repeatedly.
938
+ let imported = 0;
939
+ for (let i = 0; i < rows.length; i += 100) {
940
+ const chunk = rows.slice(i, i + 100);
941
+ const ops = chunk.map((data) => ({
942
+ type: "entries.create" as const,
943
+ objectName,
944
+ data,
945
+ }));
946
+ const result = (await callMutation(ctx, api.batch.apply, {
947
+ ops: ops as any,
948
+ idempotencyKey: `import:${objectName}:${Date.now()}:${i}`,
949
+ } as any)) as { count: number };
950
+ imported += result.count;
951
+ }
952
+ out(ctx, { imported, onConflict });
953
+ }
954
+
955
+ async function runExportCommand(ctx: CrmCliContext): Promise<void> {
956
+ const objectName = shift(ctx.args, "object name");
957
+ const format = (getFlag(ctx.args, "--format") ?? "jsonl") as
958
+ | "csv"
959
+ | "jsonl";
960
+ const limit = parseInt(getFlag(ctx.args, "--limit") ?? "10000", 10);
961
+ // Page through entries.list in 1000-row chunks.
962
+ const all: any[] = [];
963
+ let cursor = 0;
964
+ while (all.length < limit) {
965
+ const page = (await callQuery(ctx, api.entries.list, {
966
+ objectName,
967
+ limit: Math.min(1000, limit - all.length),
968
+ })) as any[];
969
+ if (!page || page.length === 0) break;
970
+ all.push(...page);
971
+ cursor += page.length;
972
+ if (page.length < 1000) break;
973
+ }
974
+ if (format === "csv") {
975
+ const fields = Object.keys(all[0]?.fields ?? {});
976
+ const header = ["_id", ...fields].join(",");
977
+ const lines = all.map((entry) => {
978
+ const cells = [
979
+ entry.id,
980
+ ...fields.map((f) => csvEscape(entry.fields[f])),
981
+ ];
982
+ return cells.join(",");
983
+ });
984
+ process.stdout.write(`${header}\n${lines.join("\n")}\n`);
985
+ } else {
986
+ for (const entry of all) {
987
+ process.stdout.write(
988
+ `${JSON.stringify({ _id: entry.id, ...entry.fields })}\n`,
989
+ );
990
+ }
991
+ }
992
+ }
993
+
994
+ export function parseCsv(
995
+ text: string,
996
+ colMap: Record<string, string>,
997
+ ): Record<string, unknown>[] {
998
+ // Tiny CSV parser: comma separator, double-quote escaping. Doesn't
999
+ // handle multi-line quoted cells (rare for CRM imports). Callers with
1000
+ // pathological CSVs should pre-clean via `csvkit` or similar.
1001
+ const rows: Record<string, unknown>[] = [];
1002
+ const lines = text.split(/\r?\n/).filter((line) => line.trim().length > 0);
1003
+ if (lines.length === 0) return rows;
1004
+ const header = parseCsvLine(lines[0]).map(
1005
+ (col) => colMap[col] ?? col,
1006
+ );
1007
+ for (let i = 1; i < lines.length; i++) {
1008
+ const cols = parseCsvLine(lines[i]);
1009
+ const row: Record<string, unknown> = {};
1010
+ for (let j = 0; j < header.length; j++) {
1011
+ row[header[j]] = cols[j] ?? null;
1012
+ }
1013
+ rows.push(row);
1014
+ }
1015
+ return rows;
1016
+ }
1017
+
1018
+ export function parseCsvLine(line: string): string[] {
1019
+ const out: string[] = [];
1020
+ let buf = "";
1021
+ let inQuotes = false;
1022
+ for (let i = 0; i < line.length; i++) {
1023
+ const ch = line[i];
1024
+ if (inQuotes) {
1025
+ if (ch === '"' && line[i + 1] === '"') {
1026
+ buf += '"';
1027
+ i++;
1028
+ } else if (ch === '"') {
1029
+ inQuotes = false;
1030
+ } else {
1031
+ buf += ch;
1032
+ }
1033
+ } else {
1034
+ if (ch === ",") {
1035
+ out.push(buf);
1036
+ buf = "";
1037
+ } else if (ch === '"' && buf === "") {
1038
+ inQuotes = true;
1039
+ } else {
1040
+ buf += ch;
1041
+ }
1042
+ }
1043
+ }
1044
+ out.push(buf);
1045
+ return out;
1046
+ }
1047
+
1048
+ export function csvEscape(value: unknown): string {
1049
+ if (value === null || value === undefined) return "";
1050
+ const text = Array.isArray(value) ? value.join(";") : String(value);
1051
+ if (text.includes(",") || text.includes('"') || text.includes("\n")) {
1052
+ return `"${text.replace(/"/g, '""')}"`;
1053
+ }
1054
+ return text;
1055
+ }
1056
+
1057
+ export function remapKeys(
1058
+ row: Record<string, unknown>,
1059
+ colMap: Record<string, string>,
1060
+ ): Record<string, unknown> {
1061
+ if (Object.keys(colMap).length === 0) return row;
1062
+ const out: Record<string, unknown> = {};
1063
+ for (const [key, value] of Object.entries(row)) {
1064
+ out[colMap[key] ?? key] = value;
1065
+ }
1066
+ return out;
1067
+ }
1068
+
1069
+ // ── SQL escape hatch ─────────────────────────────────────────────────────
1070
+
1071
+ async function runSqlCommand(ctx: CrmCliContext): Promise<void> {
1072
+ const sql = ctx.args.join(" ").trim();
1073
+ if (!sql) {
1074
+ throw new CrmCliError("dench crm sql '<SELECT ... FROM ...>'");
1075
+ }
1076
+ // v1: translate the simplest SELECT shape into the query DSL. Anything
1077
+ // beyond that ("SELECT ... FROM ... WHERE ... ORDER BY ... LIMIT N")
1078
+ // shells out to duckdb on the local file if available; otherwise we
1079
+ // surface a helpful error.
1080
+ const m =
1081
+ /^SELECT\s+(.+?)\s+FROM\s+([\w"]+)(?:\s+WHERE\s+(.+?))?(?:\s+ORDER\s+BY\s+(.+?))?(?:\s+LIMIT\s+(\d+))?\s*;?$/i.exec(
1082
+ sql,
1083
+ );
1084
+ if (!m) {
1085
+ throw new CrmCliError(
1086
+ "dench crm sql only supports flat SELECT for now. Use the DSL or duckdb directly.",
1087
+ );
1088
+ }
1089
+ const [, selectStr, tableRaw, _whereStr, sortStr, limitStr] = m;
1090
+ const objectName = tableRaw.replace(/"/g, "");
1091
+ const select =
1092
+ selectStr.trim() === "*"
1093
+ ? undefined
1094
+ : selectStr.split(",").map((s) => s.trim().replace(/"/g, ""));
1095
+ const limit = limitStr ? parseInt(limitStr, 10) : 100;
1096
+ const sort = sortStr
1097
+ ? sortStr
1098
+ .split(",")
1099
+ .map((s) => {
1100
+ const desc = /\s+DESC\s*$/i.test(s);
1101
+ return `${desc ? "-" : ""}${s.replace(/\s+(asc|desc)\s*$/i, "").trim().replace(/"/g, "")}`;
1102
+ })
1103
+ .join(",")
1104
+ : undefined;
1105
+ out(
1106
+ ctx,
1107
+ await callQuery(ctx, api.query.queryEntries, {
1108
+ objectName,
1109
+ dsl: { select, sort, limit },
1110
+ }),
1111
+ );
1112
+ }
1113
+
1114
+ // ── People + companies shortcuts ─────────────────────────────────────────
1115
+ //
1116
+ // These are thin wrappers around the standard objects/entries surface,
1117
+ // using the canonical "people" / "company" object names.
1118
+
1119
+ async function runPeopleCommand(ctx: CrmCliContext): Promise<void> {
1120
+ const verb = ctx.args.shift();
1121
+ switch (verb) {
1122
+ case "search": {
1123
+ const text = ctx.args.join(" ").trim();
1124
+ if (!text) throw new CrmCliError("Search text required");
1125
+ out(
1126
+ ctx,
1127
+ await callQuery(ctx, api.query.search, {
1128
+ text,
1129
+ objectName: "people",
1130
+ limit: 50,
1131
+ }),
1132
+ );
1133
+ return;
1134
+ }
1135
+ case "upsert": {
1136
+ const data =
1137
+ (parseJson(getFlag(ctx.args, "--data")) as JsonRecord | undefined) ??
1138
+ {};
1139
+ out(
1140
+ ctx,
1141
+ await callMutation(ctx, api.entries.create, {
1142
+ objectName: "people",
1143
+ data,
1144
+ }),
1145
+ );
1146
+ return;
1147
+ }
1148
+ case "enrich": {
1149
+ const entryId = shift(ctx.args, "person entry id");
1150
+ const provider = getFlag(ctx.args, "--provider");
1151
+ out(
1152
+ ctx,
1153
+ await callMutation(ctx, api.enrich.requestObjectEnrichment, {
1154
+ objectName: "people",
1155
+ fieldName: getFlag(ctx.args, "--field") ?? "Email Address",
1156
+ provider,
1157
+ }),
1158
+ );
1159
+ // Don't reference entryId in the request — Phase 3 v1 enrichment is
1160
+ // object-level only; cell-level lands once gateway enrichment ships.
1161
+ // Print entryId in the result so the caller has context.
1162
+ out(ctx, { entryId });
1163
+ return;
1164
+ }
1165
+ default:
1166
+ throw new CrmCliError(`Unknown crm people verb: ${verb ?? "<none>"}`);
1167
+ }
1168
+ }
1169
+
1170
+ async function runCompaniesCommand(ctx: CrmCliContext): Promise<void> {
1171
+ const verb = ctx.args.shift();
1172
+ switch (verb) {
1173
+ case "search": {
1174
+ const text = ctx.args.join(" ").trim();
1175
+ if (!text) throw new CrmCliError("Search text required");
1176
+ out(
1177
+ ctx,
1178
+ await callQuery(ctx, api.query.search, {
1179
+ text,
1180
+ objectName: "company",
1181
+ limit: 50,
1182
+ }),
1183
+ );
1184
+ return;
1185
+ }
1186
+ case "upsert": {
1187
+ const domain = getFlag(ctx.args, "--domain");
1188
+ const name = getFlag(ctx.args, "--name");
1189
+ const data: Record<string, unknown> = {};
1190
+ if (domain) data["Domain"] = domain;
1191
+ if (name) data["Name"] = name;
1192
+ out(
1193
+ ctx,
1194
+ await callMutation(ctx, api.entries.create, {
1195
+ objectName: "company",
1196
+ data,
1197
+ }),
1198
+ );
1199
+ return;
1200
+ }
1201
+ case "enrich": {
1202
+ const entryId = shift(ctx.args, "company entry id");
1203
+ const provider = getFlag(ctx.args, "--provider");
1204
+ out(
1205
+ ctx,
1206
+ await callMutation(ctx, api.enrich.requestObjectEnrichment, {
1207
+ objectName: "company",
1208
+ fieldName: getFlag(ctx.args, "--field") ?? "Domain",
1209
+ provider,
1210
+ }),
1211
+ );
1212
+ out(ctx, { entryId });
1213
+ return;
1214
+ }
1215
+ default:
1216
+ throw new CrmCliError(`Unknown crm companies verb: ${verb ?? "<none>"}`);
1217
+ }
1218
+ }
1219
+
1220
+ // ── Cell / row / object enrichment ───────────────────────────────────────
1221
+
1222
+ async function runEnrichCommand(ctx: CrmCliContext): Promise<void> {
1223
+ const verb = ctx.args.shift();
1224
+ switch (verb) {
1225
+ case "cell": {
1226
+ const objectName = shift(ctx.args, "object name");
1227
+ const entryId = shift(ctx.args, "entry id");
1228
+ const fieldName = shift(ctx.args, "field name");
1229
+ const provider = getFlag(ctx.args, "--provider");
1230
+ out(
1231
+ ctx,
1232
+ await callMutation(ctx, api.enrich.requestCellEnrichment, {
1233
+ objectName,
1234
+ entryId: entryId as any,
1235
+ fieldName,
1236
+ provider,
1237
+ }),
1238
+ );
1239
+ return;
1240
+ }
1241
+ case "object": {
1242
+ const objectName = shift(ctx.args, "object name");
1243
+ const fieldName = getFlag(ctx.args, "--field");
1244
+ const missingOnly = !hasFlag(ctx.args, "--all");
1245
+ const provider = getFlag(ctx.args, "--provider");
1246
+ if (!fieldName) {
1247
+ throw new CrmCliError(
1248
+ "dench crm enrich object <name> --field <field> [--provider ...] [--all]",
1249
+ );
1250
+ }
1251
+ out(
1252
+ ctx,
1253
+ await callMutation(ctx, api.enrich.requestObjectEnrichment, {
1254
+ objectName,
1255
+ fieldName,
1256
+ provider,
1257
+ missingOnly,
1258
+ }),
1259
+ );
1260
+ return;
1261
+ }
1262
+ default:
1263
+ throw new CrmCliError(`Unknown crm enrich verb: ${verb ?? "<none>"}`);
1264
+ }
1265
+ }
1266
+
1267
+ // ── Reports ──────────────────────────────────────────────────────────────
1268
+
1269
+ async function runReportsCommand(ctx: CrmCliContext): Promise<void> {
1270
+ const verb = ctx.args.shift();
1271
+ if (verb !== "generate") {
1272
+ throw new CrmCliError(
1273
+ "Usage: dench crm reports generate <name> --object <object> --type pie|bar|line|table --group-by <field> [--metric count|sum --metric-field <field>]",
1274
+ );
1275
+ }
1276
+ const _name = shift(ctx.args, "report name");
1277
+ const objectName = getFlag(ctx.args, "--object");
1278
+ const type = getFlag(ctx.args, "--type") as
1279
+ | "pie"
1280
+ | "bar"
1281
+ | "line"
1282
+ | "table";
1283
+ const groupBy = getFlag(ctx.args, "--group-by");
1284
+ const metric = getFlag(ctx.args, "--metric") as
1285
+ | "count"
1286
+ | "sum"
1287
+ | "avg"
1288
+ | "min"
1289
+ | "max"
1290
+ | undefined;
1291
+ const metricField = getFlag(ctx.args, "--metric-field");
1292
+ if (!objectName || !type || !groupBy) {
1293
+ throw new CrmCliError(
1294
+ "dench crm reports generate requires --object, --type, --group-by",
1295
+ );
1296
+ }
1297
+ out(
1298
+ ctx,
1299
+ await callQuery(ctx, api.reports.generate, {
1300
+ objectName,
1301
+ type,
1302
+ groupByField: groupBy,
1303
+ metric,
1304
+ metricField,
1305
+ }),
1306
+ );
1307
+ }
1308
+
1309
+ function crmHelp(): void {
1310
+ console.log(`Usage: dench crm <subcommand>
1311
+
1312
+ Objects (CRM tables):
1313
+ dench crm objects list [--json]
1314
+ dench crm objects get <name>
1315
+ dench crm objects create <name> [--description ...] [--default-view kanban] [--icon ...]
1316
+ dench crm objects update <name> [--default-view ...] [--icon ...]
1317
+ dench crm objects rename <from> <to>
1318
+ dench crm objects delete <name>
1319
+
1320
+ Fields (columns):
1321
+ dench crm fields list <object> [--json]
1322
+ dench crm fields create <object> <name> --type text|email|phone|url|number|boolean|date|enum|relation|user|tags|action|file|richtext \\
1323
+ [--required] [--description ...] [--enum-values JSON] [--enum-colors JSON] \\
1324
+ [--enum-multiple] [--related-object <name>] [--relationship many_to_one|many_to_many] \\
1325
+ [--indexed | --no-indexed]
1326
+ dench crm fields update <object> <field> [--description ...] [--required] [--enum-values JSON] [--indexed | --no-indexed]
1327
+ dench crm fields delete <object> <field>
1328
+ dench crm fields reorder <object> <field1> <field2> <field3> ...
1329
+
1330
+ Entries (rows):
1331
+ dench crm entries list <object> [--limit N] [--json]
1332
+ dench crm entries get <object> <entryId>
1333
+ dench crm entries create <object> --data '{"Field":"Value",...}'
1334
+ dench crm entries update <object> <entryId> --data '{...}'
1335
+ dench crm entries delete <object> <entryId>
1336
+ dench crm entries bulk-delete <object> --ids id1,id2,id3
1337
+
1338
+ Cells (single-field updates):
1339
+ dench crm cells get <object> <entryId> <field>
1340
+ dench crm cells set <object> <entryId> <field> '<JSON value>'
1341
+ dench crm cells append <object> <entryId> <field> '<JSON value>'
1342
+
1343
+ Query DSL:
1344
+ dench crm query <object> --where '{"Status":"New"}' --select Name,Email --sort -Score --limit 100
1345
+
1346
+ Aggregates:
1347
+ dench crm aggregate <object> --field Status
1348
+
1349
+ Full-text search:
1350
+ dench crm search [--object <name>] [--limit N] <text>
1351
+
1352
+ Statuses (kanban columns):
1353
+ dench crm statuses list <object>
1354
+ dench crm statuses set <object> --statuses '[{"name":"New","color":"#94a3b8"},...]'
1355
+
1356
+ Batch:
1357
+ dench crm batch --file ops.jsonl [--idempotency-key <key>]
1358
+
1359
+ Import / export:
1360
+ dench crm import <object> --csv leads.csv [--map '{"Email":"Email Address"}'] [--on-conflict skip|update|error]
1361
+ dench crm import <object> --jsonl rows.jsonl
1362
+ dench crm export <object> --format csv|jsonl [--limit N]
1363
+
1364
+ Transactions (multi-step, all-or-nothing):
1365
+ TXN=$(dench crm transaction begin)
1366
+ dench crm transaction add $TXN entries.create company --data '{...}'
1367
+ dench crm transaction add $TXN entries.update <entryId> --data '{...}'
1368
+ dench crm transaction commit $TXN
1369
+ dench crm transaction abort $TXN
1370
+ dench crm transaction --file ops.json --commit
1371
+
1372
+ SQL escape hatch (subset; flat SELECTs only):
1373
+ dench crm sql 'SELECT "Full Name","Email Address" FROM lead WHERE "Status"='New' ORDER BY _creationTime DESC LIMIT 10'
1374
+
1375
+ People + companies shortcuts:
1376
+ dench crm people search '<query>'
1377
+ dench crm people upsert --data '{"Email Address":"jane@example.com",...}'
1378
+ dench crm people enrich <entryId> [--provider apollo]
1379
+ dench crm companies search '<query>'
1380
+ dench crm companies upsert --domain acme.com [--name Acme]
1381
+ dench crm companies enrich <entryId>
1382
+
1383
+ Cell / row / object enrichment:
1384
+ dench crm enrich cell <object> <entryId> <field> [--provider ...]
1385
+ dench crm enrich object <object> --field <field> [--provider ...] [--all]
1386
+
1387
+ Reports (chart-ready JSON):
1388
+ dench crm reports generate '<name>' --object <object> --type pie|bar|line|table --group-by <field> [--metric count|sum --metric-field <field>]
1389
+
1390
+ Docs:
1391
+ dench crm docs list
1392
+ dench crm docs create <path> [--title ...] [--icon ...] [--link-entry <id>]
1393
+ dench crm docs link <path> --object <name> --entry <id>
1394
+
1395
+ Actions:
1396
+ dench crm actions list <entryId>
1397
+ dench crm actions run <actionId> --field-id <id> --entry-id <id>
1398
+
1399
+ Global flags:
1400
+ --json Output raw JSON instead of pretty-printed text.
1401
+ `);
1402
+ }