conduyt 1.3.0 → 1.4.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/dist/client.js CHANGED
@@ -20,7 +20,14 @@ export class ConduytClient {
20
20
  const text = await res.text();
21
21
  const json = text ? safeParse(text) : null;
22
22
  if (!res.ok) {
23
- const msg = json?.error || res.statusText || `HTTP ${res.status}`;
23
+ // Preserve structured non-2xx bodies: several endpoints return the data
24
+ // a caller needs to proceed IN the error body (e.g. custom-field delete
25
+ // 409 carries valueCount/dependencyCount for the --confirm flow).
26
+ // Reducing those to statusText would strand the user.
27
+ const structured = json && typeof json === "object" && !json.error
28
+ ? JSON.stringify(json)
29
+ : null;
30
+ const msg = json?.error || structured || res.statusText || `HTTP ${res.status}`;
24
31
  throw new Error(`Conduyt API ${res.status}: ${msg}`);
25
32
  }
26
33
  return json;
@@ -37,6 +44,85 @@ export class ConduytClient {
37
44
  del(path) {
38
45
  return this.request("DELETE", path);
39
46
  }
47
+ // Stream `ai chat`'s text/event-stream response to stdout as clean answer
48
+ // text. The server frames SSE records as `data: {"text":"..."}\n\n`, ends
49
+ // with `data: [DONE]\n\n`, and reports in-stream failures as
50
+ // `data: {"error":"..."}\n\n` — so we parse events (buffering across chunk
51
+ // splits), print only the text deltas, stop cleanly on [DONE], and THROW on
52
+ // an error event so the command exits non-zero instead of "succeeding" with
53
+ // raw protocol noise.
54
+ async stream(method, path, body) {
55
+ const url = `${this.baseUrl}${path.startsWith("/") ? path : `/${path}`}`;
56
+ const res = await fetch(url, {
57
+ method,
58
+ headers: {
59
+ Authorization: `Bearer ${this.apiKey}`,
60
+ "Content-Type": "application/json",
61
+ Accept: "text/event-stream",
62
+ },
63
+ body: body !== undefined ? JSON.stringify(body) : undefined,
64
+ });
65
+ if (!res.ok || !res.body) {
66
+ const text = await res.text();
67
+ const json = text ? safeParse(text) : null;
68
+ const msg = json?.error || res.statusText || `HTTP ${res.status}`;
69
+ throw new Error(`Conduyt API ${res.status}: ${msg}`);
70
+ }
71
+ const reader = res.body.getReader();
72
+ const decoder = new TextDecoder();
73
+ let buffer = "";
74
+ let wroteAny = false;
75
+ const handleEvent = (payload) => {
76
+ if (payload === "[DONE]")
77
+ return "done";
78
+ const parsed = safeParse(payload);
79
+ if (parsed && typeof parsed === "object") {
80
+ const rec = parsed;
81
+ if (typeof rec.error === "string" && rec.error) {
82
+ if (wroteAny)
83
+ process.stdout.write("\n");
84
+ throw new Error(`AI stream error: ${rec.error}`);
85
+ }
86
+ if (typeof rec.text === "string" && rec.text) {
87
+ process.stdout.write(rec.text);
88
+ wroteAny = true;
89
+ }
90
+ }
91
+ return undefined;
92
+ };
93
+ const drainBuffer = () => {
94
+ // SSE events are separated by a blank line; fields we care about are
95
+ // `data: <payload>` lines (multi-line data joins with \n per spec).
96
+ let sep;
97
+ while ((sep = buffer.indexOf("\n\n")) !== -1) {
98
+ const rawEvent = buffer.slice(0, sep);
99
+ buffer = buffer.slice(sep + 2);
100
+ const dataLines = rawEvent
101
+ .split("\n")
102
+ .filter((l) => l.startsWith("data:"))
103
+ .map((l) => l.slice(5).replace(/^ /, ""));
104
+ if (dataLines.length === 0)
105
+ continue;
106
+ if (handleEvent(dataLines.join("\n")) === "done")
107
+ return "done";
108
+ }
109
+ return undefined;
110
+ };
111
+ outer: for (;;) {
112
+ const { done, value } = await reader.read();
113
+ if (value) {
114
+ buffer += decoder.decode(value, { stream: true });
115
+ if (drainBuffer() === "done")
116
+ break outer;
117
+ }
118
+ if (done)
119
+ break;
120
+ }
121
+ buffer += decoder.decode();
122
+ drainBuffer();
123
+ if (wroteAny)
124
+ process.stdout.write("\n");
125
+ }
40
126
  }
41
127
  function safeParse(text) {
42
128
  try {
package/dist/index.js CHANGED
@@ -99,6 +99,34 @@ contacts
99
99
  Object.assign(body, jsonArg(opts.json, "--json"));
100
100
  return client.post("/api/v1/contacts", body);
101
101
  }));
102
+ contacts
103
+ .command("update <id>")
104
+ .description("Update a contact (PATCH — only the fields you pass change)")
105
+ .option("--first <name>", "first name")
106
+ .option("--last <name>", "last name")
107
+ .option("--email <email>", "email")
108
+ .option("--phone <phone>", "phone")
109
+ .option("--company <company>", "company name (auto-creates/links a company; pass '' to clear)")
110
+ .option("--json <json>", "full JSON body, merged over the flags above (for any field)")
111
+ .action(run(async (client, id, opts) => {
112
+ assertUuid(id, "contact id");
113
+ const body = {};
114
+ if (opts.first !== undefined)
115
+ body.firstName = opts.first;
116
+ if (opts.last !== undefined)
117
+ body.lastName = opts.last;
118
+ if (opts.email !== undefined)
119
+ body.email = opts.email;
120
+ if (opts.phone !== undefined)
121
+ body.phone = opts.phone;
122
+ if (opts.company !== undefined)
123
+ body.company = opts.company;
124
+ if (opts.json !== undefined)
125
+ Object.assign(body, jsonArg(opts.json, "--json"));
126
+ if (Object.keys(body).length === 0)
127
+ throw new Error("Nothing to update. Pass at least one field flag or --json.");
128
+ return client.patch(`/api/v1/contacts/${encodeURIComponent(id)}`, body);
129
+ }));
102
130
  contacts
103
131
  .command("delete <id>")
104
132
  .description("Delete a contact by id (soft-delete; retained for audit, the contact's deals are unaffected)")
@@ -272,6 +300,57 @@ deals
272
300
  assertFiniteValue(body.value);
273
301
  return client.post("/api/v1/deals", body);
274
302
  }));
303
+ deals
304
+ .command("get <id>")
305
+ .description("Get a single deal by id")
306
+ .action(run(async (client, id) => {
307
+ assertUuid(id, "deal id");
308
+ return client.get(`/api/v1/deals/${encodeURIComponent(id)}`);
309
+ }));
310
+ deals
311
+ .command("update <id>")
312
+ .description("Update a deal (PATCH — only the fields you pass change). --stage moves the deal (won/lost status auto-derives from the target stage's flags)")
313
+ .option("--title <title>", "deal title")
314
+ .option("--value <n>", "deal value (number)")
315
+ .option("--currency <code>", "ISO currency, e.g. GBP")
316
+ .option("--stage <name-or-uuid>", "move to this stage — accepts a stage name (resolved via /automations/resolve) or a stage UUID")
317
+ .option("--pipeline <id>", "move to this pipeline UUID")
318
+ .option("--contact <id>", "primary contact UUID")
319
+ .option("--status <status>", "explicit status (open|won|lost); overrides the status auto-derived from --stage")
320
+ .option("--json <json>", "full JSON body, merged over the flags")
321
+ .action(run(async (client, id, opts) => {
322
+ assertUuid(id, "deal id");
323
+ const body = {};
324
+ if (opts.title !== undefined)
325
+ body.title = opts.title;
326
+ if (opts.value !== undefined) {
327
+ const trimmed = opts.value.trim();
328
+ const n = Number(trimmed);
329
+ if (trimmed === "" || !Number.isFinite(n)) {
330
+ throw new Error("--value must be a finite number.");
331
+ }
332
+ body.value = n;
333
+ }
334
+ if (opts.currency !== undefined)
335
+ body.currency = opts.currency;
336
+ if (opts.pipeline !== undefined)
337
+ body.pipelineId = opts.pipeline;
338
+ if (opts.contact !== undefined)
339
+ body.contactId = opts.contact;
340
+ if (opts.status !== undefined)
341
+ body.status = opts.status;
342
+ if (opts.stage !== undefined) {
343
+ if (opts.stage.trim() === "")
344
+ throw new Error("--stage was provided but is blank. Omit it or pass a stage name/UUID.");
345
+ body.stageId = await resolveStageId(client, opts.stage);
346
+ }
347
+ if (opts.json !== undefined)
348
+ Object.assign(body, jsonArg(opts.json, "--json"));
349
+ assertFiniteValue(body.value);
350
+ if (Object.keys(body).length === 0)
351
+ throw new Error("Nothing to update. Pass at least one field flag or --json.");
352
+ return client.patch(`/api/v1/deals/${encodeURIComponent(id)}`, body);
353
+ }));
275
354
  deals
276
355
  .command("delete <id>")
277
356
  .description("Delete a deal by id (soft-delete; drops out of lists/boards and pipeline totals, retained for audit)")
@@ -335,7 +414,9 @@ program
335
414
  // ---- insights ----
336
415
  program
337
416
  .command("insights <type>")
338
- .description("Run an AI insight query (e.g. summary, forecast)")
417
+ .description("Run an AI insight query. Types: sms_drip_status, sms_responses_untouched, " +
418
+ "agent_followups, pipeline_health, email_performance, smart_view_summary, " +
419
+ "team_activity, contact_import_history")
339
420
  .option("--input <json>", "JSON payload to send with the request")
340
421
  .action(run(async (client, type, opts) => {
341
422
  let extra = {};
@@ -347,7 +428,10 @@ program
347
428
  fail("--input must be valid JSON");
348
429
  }
349
430
  }
350
- return client.post("/api/v1/ai/insights", { type, ...extra });
431
+ // The /ai/insights endpoint reads the insight name from `query`, not
432
+ // `type` — sending `type` returned "Unsupported query type" for every
433
+ // call, so the CLI's insights command was fully broken.
434
+ return client.post("/api/v1/ai/insights", { query: type, ...extra });
351
435
  }));
352
436
  // ---- users (team members) ----
353
437
  const users = program.command("users").description("Manage team members");
@@ -436,6 +520,1030 @@ customFields
436
520
  assertOptionsShape(body.options);
437
521
  return client.post("/api/v1/custom-fields", body);
438
522
  }));
523
+ // ---- tasks ----
524
+ const tasks = program.command("tasks").description("Manage tasks");
525
+ tasks
526
+ .command("list")
527
+ .description("List tasks")
528
+ .option("--status <status>", "filter by status, e.g. todo|in_progress|done")
529
+ .option("--priority <priority>", "filter by priority, e.g. low|medium|high|urgent")
530
+ .option("--assigned <id>", "filter by assignee user UUID")
531
+ .option("--contact <id>", "filter by contact UUID")
532
+ .option("--deal <id>", "filter by deal UUID")
533
+ .option("--search <q>", "search title/description")
534
+ .option("--tab <tab>", "my_tasks | all | completed")
535
+ .option("--overdue", "only overdue, not-done tasks")
536
+ .option("--due-from <date>", "due on/after this date")
537
+ .option("--due-to <date>", "due on/before this date")
538
+ .option("--sort <field>", "sort field (e.g. dueDate, priority, createdAt)")
539
+ .option("--order <dir>", "asc | desc")
540
+ .option("--page <n>", "page number")
541
+ .option("--limit <n>", "results per page (per_page)")
542
+ .action(run(async (client, opts) => client.get(`/api/v1/tasks${buildQuery({
543
+ status: opts.status,
544
+ priority: opts.priority,
545
+ assigned_to: opts.assigned,
546
+ contact_id: opts.contact,
547
+ deal_id: opts.deal,
548
+ search: opts.search,
549
+ tab: opts.tab,
550
+ overdue: opts.overdue ? "true" : undefined,
551
+ dueFrom: opts.dueFrom,
552
+ dueTo: opts.dueTo,
553
+ sort: opts.sort,
554
+ order: opts.order,
555
+ page: opts.page,
556
+ per_page: opts.limit,
557
+ })}`)));
558
+ tasks
559
+ .command("get <id>")
560
+ .description("Get a single task by id")
561
+ .action(run(async (client, id) => {
562
+ assertUuid(id, "task id");
563
+ return client.get(`/api/v1/tasks/${encodeURIComponent(id)}`);
564
+ }));
565
+ tasks
566
+ .command("create")
567
+ .description("Create a task (--assigned accepts a user UUID, or the magic strings @me / @owner)")
568
+ .option("--title <title>", "task title (required)")
569
+ .option("--description <text>", "description")
570
+ .option("--status <status>", "todo|in_progress|done (default todo)")
571
+ .option("--priority <priority>", "low|medium|high|urgent (default medium)")
572
+ .option("--due <date>", "due date (ISO or date string)")
573
+ .option("--assigned <id>", "assignee user UUID, or @me / @owner")
574
+ .option("--contact <id>", "link to contact UUID")
575
+ .option("--deal <id>", "link to deal UUID")
576
+ .option("--json <json>", "full JSON body, merged over the flags")
577
+ .action(run(async (client, opts) => {
578
+ const body = {};
579
+ if (opts.title !== undefined)
580
+ body.title = opts.title;
581
+ if (opts.description !== undefined)
582
+ body.description = opts.description;
583
+ if (opts.status !== undefined)
584
+ body.status = opts.status;
585
+ if (opts.priority !== undefined)
586
+ body.priority = opts.priority;
587
+ if (opts.due !== undefined)
588
+ body.dueDate = opts.due;
589
+ if (opts.assigned !== undefined)
590
+ body.assignedTo = opts.assigned;
591
+ if (opts.contact !== undefined)
592
+ body.contactId = opts.contact;
593
+ if (opts.deal !== undefined)
594
+ body.dealId = opts.deal;
595
+ if (opts.json !== undefined)
596
+ Object.assign(body, jsonArg(opts.json, "--json"));
597
+ return client.post("/api/v1/tasks", body);
598
+ }));
599
+ tasks
600
+ .command("update <id>")
601
+ .description("Update a task (PATCH — only the fields you pass change)")
602
+ .option("--title <title>", "task title")
603
+ .option("--description <text>", "description")
604
+ .option("--status <status>", "todo|in_progress|done")
605
+ .option("--priority <priority>", "low|medium|high|urgent")
606
+ .option("--due <date>", "due date")
607
+ .option("--assigned <id>", "assignee user UUID, or @me / @owner")
608
+ .option("--contact <id>", "link to contact UUID")
609
+ .option("--deal <id>", "link to deal UUID")
610
+ .option("--json <json>", "full JSON body, merged over the flags")
611
+ .action(run(async (client, id, opts) => {
612
+ assertUuid(id, "task id");
613
+ const body = {};
614
+ if (opts.title !== undefined)
615
+ body.title = opts.title;
616
+ if (opts.description !== undefined)
617
+ body.description = opts.description;
618
+ if (opts.status !== undefined)
619
+ body.status = opts.status;
620
+ if (opts.priority !== undefined)
621
+ body.priority = opts.priority;
622
+ if (opts.due !== undefined)
623
+ body.dueDate = opts.due;
624
+ if (opts.assigned !== undefined)
625
+ body.assignedTo = opts.assigned;
626
+ if (opts.contact !== undefined)
627
+ body.contactId = opts.contact;
628
+ if (opts.deal !== undefined)
629
+ body.dealId = opts.deal;
630
+ if (opts.json !== undefined)
631
+ Object.assign(body, jsonArg(opts.json, "--json"));
632
+ if (Object.keys(body).length === 0)
633
+ throw new Error("Nothing to update. Pass at least one field flag or --json.");
634
+ return client.patch(`/api/v1/tasks/${encodeURIComponent(id)}`, body);
635
+ }));
636
+ tasks
637
+ .command("complete <id>")
638
+ .description("Mark a task done (PATCH status=done)")
639
+ .action(run(async (client, id) => {
640
+ assertUuid(id, "task id");
641
+ return client.patch(`/api/v1/tasks/${encodeURIComponent(id)}`, { status: "done" });
642
+ }));
643
+ tasks
644
+ .command("delete <id>")
645
+ .description("Delete a task by id (hard delete)")
646
+ .action(run(async (client, id) => {
647
+ assertUuid(id, "task id");
648
+ return client.del(`/api/v1/tasks/${encodeURIComponent(id)}`);
649
+ }));
650
+ // ---- messages / conversations ----
651
+ const messages = program.command("messages").description("Send and read messages");
652
+ messages
653
+ .command("list")
654
+ .description("List logged messages")
655
+ .option("--contact <id>", "filter by contact UUID")
656
+ .option("--channel <channel>", "sms | email")
657
+ .option("--direction <direction>", "inbound | outbound")
658
+ .option("--status <status>", "filter by status")
659
+ .option("--page <n>", "page number")
660
+ .option("--limit <n>", "results per page (per_page)")
661
+ .action(run(async (client, opts) => client.get(`/api/v1/messages${buildQuery({
662
+ contactId: opts.contact,
663
+ channel: opts.channel,
664
+ direction: opts.direction,
665
+ status: opts.status,
666
+ page: opts.page,
667
+ per_page: opts.limit,
668
+ })}`)));
669
+ messages
670
+ .command("send")
671
+ .description("Log/create a message (POST /messages). NOTE: outbound SMS is rejected here — use `messages send-sms` for real SMS delivery")
672
+ .option("--contact <id>", "contact UUID (required)")
673
+ .option("--channel <channel>", "sms | email (required)")
674
+ .option("--direction <direction>", "inbound | outbound (required)")
675
+ .option("--body <text>", "message body (required)")
676
+ .option("--subject <subject>", "subject (email)")
677
+ .option("--from <from>", "from number/email")
678
+ .option("--to <to>", "to number/email")
679
+ .option("--json <json>", "full JSON body, merged over the flags")
680
+ .action(run(async (client, opts) => {
681
+ const body = {};
682
+ if (opts.contact !== undefined)
683
+ body.contactId = opts.contact;
684
+ if (opts.channel !== undefined)
685
+ body.channel = opts.channel;
686
+ if (opts.direction !== undefined)
687
+ body.direction = opts.direction;
688
+ if (opts.body !== undefined)
689
+ body.body = opts.body;
690
+ if (opts.subject !== undefined)
691
+ body.subject = opts.subject;
692
+ if (opts.from !== undefined) {
693
+ if (opts.channel === "email")
694
+ body.fromEmail = opts.from;
695
+ else
696
+ body.fromNumber = opts.from;
697
+ }
698
+ if (opts.to !== undefined) {
699
+ if (opts.channel === "email")
700
+ body.toEmail = opts.to;
701
+ else
702
+ body.toNumber = opts.to;
703
+ }
704
+ if (opts.json !== undefined)
705
+ Object.assign(body, jsonArg(opts.json, "--json"));
706
+ return client.post("/api/v1/messages", body);
707
+ }));
708
+ messages
709
+ .command("send-sms")
710
+ .description("Send an outbound SMS via Twilio (POST /messages/sms/send)")
711
+ .option("--contact <id>", "contact UUID (required)")
712
+ .option("--body <text>", "SMS body, 1-1600 chars (required)")
713
+ .option("--from <number>", "from number — must be an account-owned Twilio number/agent DID")
714
+ .option("--provider <id>", "smsProviderId UUID")
715
+ .option("--json <json>", "full JSON body, merged over the flags")
716
+ .action(run(async (client, opts) => {
717
+ const body = {};
718
+ if (opts.contact !== undefined)
719
+ body.contactId = opts.contact;
720
+ if (opts.body !== undefined)
721
+ body.body = opts.body;
722
+ if (opts.from !== undefined)
723
+ body.fromNumber = opts.from;
724
+ if (opts.provider !== undefined)
725
+ body.smsProviderId = opts.provider;
726
+ if (opts.json !== undefined)
727
+ Object.assign(body, jsonArg(opts.json, "--json"));
728
+ return client.post("/api/v1/messages/sms/send", body);
729
+ }));
730
+ messages
731
+ .command("conversations")
732
+ .description("List conversation threads")
733
+ .option("--channel <channel>", "sms | email")
734
+ .option("--search <q>", "search query")
735
+ .option("--status <status>", "filter by conversation status")
736
+ .option("--page <n>", "page number")
737
+ .option("--limit <n>", "results per page (per_page)")
738
+ .action(run(async (client, opts) => client.get(`/api/v1/conversations${buildQuery({
739
+ channel: opts.channel,
740
+ search: opts.search,
741
+ status: opts.status,
742
+ page: opts.page,
743
+ per_page: opts.limit,
744
+ })}`)));
745
+ messages
746
+ .command("conversation <contactId>")
747
+ .description("Read a contact's conversation (messages + notes)")
748
+ .option("--channel <channel>", "sms | email")
749
+ .option("--cursor <messageId>", "paginate backward from this message id")
750
+ .option("--around <messageId>", "center the window on this message id")
751
+ .option("--limit <n>", "messages per page (per_page)")
752
+ .action(run(async (client, contactId, opts) => {
753
+ assertUuid(contactId, "contact id");
754
+ return client.get(`/api/v1/conversations/${encodeURIComponent(contactId)}${buildQuery({
755
+ channel: opts.channel,
756
+ cursor: opts.cursor,
757
+ around: opts.around,
758
+ per_page: opts.limit,
759
+ })}`);
760
+ }));
761
+ // ---- appointments ----
762
+ const appointments = program.command("appointments").description("Manage appointments");
763
+ appointments
764
+ .command("list")
765
+ .description("List appointments")
766
+ .option("--status <status>", "filter by status")
767
+ .option("--calendar <id>", "filter by calendar UUID")
768
+ .option("--contact <id>", "filter by contact UUID")
769
+ .option("--assigned <id>", "filter by assignee user UUID")
770
+ .option("--booking-page <id>", "filter by booking page UUID")
771
+ .option("--from <date>", "start of date range")
772
+ .option("--to <date>", "end of date range")
773
+ .option("--page <n>", "page number")
774
+ .option("--limit <n>", "results per page (per_page)")
775
+ .action(run(async (client, opts) => client.get(`/api/v1/appointments${buildQuery({
776
+ status: opts.status,
777
+ calendar_id: opts.calendar,
778
+ contact_id: opts.contact,
779
+ assigned_to: opts.assigned,
780
+ booking_page_id: opts.bookingPage,
781
+ from: opts.from,
782
+ to: opts.to,
783
+ page: opts.page,
784
+ per_page: opts.limit,
785
+ })}`)));
786
+ appointments
787
+ .command("get <id>")
788
+ .description("Get a single appointment by id")
789
+ .action(run(async (client, id) => {
790
+ assertUuid(id, "appointment id");
791
+ return client.get(`/api/v1/appointments/${encodeURIComponent(id)}`);
792
+ }));
793
+ appointments
794
+ .command("create")
795
+ .description("Create an appointment. --start/--end must be ISO-8601 WITH a timezone offset (e.g. 2026-07-10T15:00:00-07:00)")
796
+ .option("--title <title>", "title (required)")
797
+ .option("--start <iso>", "start time, ISO-8601 with tz offset (required)")
798
+ .option("--end <iso>", "end time, ISO-8601 with tz offset (required)")
799
+ .option("--description <text>", "description")
800
+ .option("--calendar <id>", "calendar UUID")
801
+ .option("--contact <id>", "contact UUID")
802
+ .option("--assigned <id>", "assignee user UUID")
803
+ .option("--location <location>", "location")
804
+ .option("--status <status>", "status (default confirmed)")
805
+ .option("--json <json>", "full JSON body, merged over the flags")
806
+ .action(run(async (client, opts) => {
807
+ const body = {};
808
+ if (opts.title !== undefined)
809
+ body.title = opts.title;
810
+ if (opts.start !== undefined)
811
+ body.startTime = opts.start;
812
+ if (opts.end !== undefined)
813
+ body.endTime = opts.end;
814
+ if (opts.description !== undefined)
815
+ body.description = opts.description;
816
+ if (opts.calendar !== undefined)
817
+ body.calendarId = opts.calendar;
818
+ if (opts.contact !== undefined)
819
+ body.contactId = opts.contact;
820
+ if (opts.assigned !== undefined)
821
+ body.assignedTo = opts.assigned;
822
+ if (opts.location !== undefined)
823
+ body.location = opts.location;
824
+ if (opts.status !== undefined)
825
+ body.status = opts.status;
826
+ if (opts.json !== undefined)
827
+ Object.assign(body, jsonArg(opts.json, "--json"));
828
+ return client.post("/api/v1/appointments", body);
829
+ }));
830
+ appointments
831
+ .command("update <id>")
832
+ .description("Update an appointment (PATCH). --start/--end reschedule; ISO-8601 with tz offset")
833
+ .option("--title <title>", "title")
834
+ .option("--start <iso>", "start time, ISO-8601 with tz offset")
835
+ .option("--end <iso>", "end time, ISO-8601 with tz offset")
836
+ .option("--description <text>", "description")
837
+ .option("--location <location>", "location")
838
+ .option("--status <status>", "status (e.g. confirmed, cancelled)")
839
+ .option("--assigned <id>", "assignee user UUID (or empty to unassign)")
840
+ .option("--json <json>", "full JSON body, merged over the flags")
841
+ .action(run(async (client, id, opts) => {
842
+ assertUuid(id, "appointment id");
843
+ const body = {};
844
+ if (opts.title !== undefined)
845
+ body.title = opts.title;
846
+ if (opts.start !== undefined)
847
+ body.startTime = opts.start;
848
+ if (opts.end !== undefined)
849
+ body.endTime = opts.end;
850
+ if (opts.description !== undefined)
851
+ body.description = opts.description;
852
+ if (opts.location !== undefined)
853
+ body.location = opts.location;
854
+ if (opts.status !== undefined)
855
+ body.status = opts.status;
856
+ if (opts.assigned !== undefined)
857
+ body.assignedTo = opts.assigned;
858
+ if (opts.json !== undefined)
859
+ Object.assign(body, jsonArg(opts.json, "--json"));
860
+ if (Object.keys(body).length === 0)
861
+ throw new Error("Nothing to update. Pass at least one field flag or --json.");
862
+ return client.patch(`/api/v1/appointments/${encodeURIComponent(id)}`, body);
863
+ }));
864
+ appointments
865
+ .command("cancel <id>")
866
+ .description("Cancel an appointment (soft-cancel; sets status=cancelled)")
867
+ .action(run(async (client, id) => {
868
+ assertUuid(id, "appointment id");
869
+ return client.del(`/api/v1/appointments/${encodeURIComponent(id)}`);
870
+ }));
871
+ // ---- calendars ----
872
+ const calendars = program.command("calendars").description("Manage calendars");
873
+ calendars
874
+ .command("list")
875
+ .description("List calendars")
876
+ .option("--page <n>", "page number")
877
+ .option("--limit <n>", "results per page (per_page)")
878
+ .action(run(async (client, opts) => client.get(`/api/v1/calendars${buildQuery({ page: opts.page, per_page: opts.limit })}`)));
879
+ calendars
880
+ .command("create")
881
+ .description("Create a calendar")
882
+ .option("--name <name>", "calendar name (required)")
883
+ .option("--description <text>", "description")
884
+ .option("--timezone <tz>", "IANA timezone (default America/Los_Angeles)")
885
+ .option("--default", "make this the default calendar")
886
+ .option("--json <json>", "full JSON body, merged over the flags")
887
+ .action(run(async (client, opts) => {
888
+ const body = {};
889
+ if (opts.name !== undefined)
890
+ body.name = opts.name;
891
+ if (opts.description !== undefined)
892
+ body.description = opts.description;
893
+ if (opts.timezone !== undefined)
894
+ body.timezone = opts.timezone;
895
+ if (opts.default)
896
+ body.isDefault = true;
897
+ if (opts.json !== undefined)
898
+ Object.assign(body, jsonArg(opts.json, "--json"));
899
+ return client.post("/api/v1/calendars", body);
900
+ }));
901
+ // ---- automations (the "AI builds a workflow" loop) ----
902
+ const automations = program.command("automations").description("Build and run automations/workflows");
903
+ automations
904
+ .command("list")
905
+ .description("List automations")
906
+ .option("--page <n>", "page number")
907
+ .option("--limit <n>", "results per page (per_page)")
908
+ .action(run(async (client, opts) => client.get(`/api/v1/automations${buildQuery({ page: opts.page, per_page: opts.limit })}`)));
909
+ automations
910
+ .command("schema")
911
+ .description("Get the node/field/operator schema catalog (node types, action rules, graph shape)")
912
+ .action(run(async (client) => client.get("/api/v1/automations/schema")));
913
+ automations
914
+ .command("resolve")
915
+ .description("Resolve human-readable names to UUIDs. Pass comma lists via flags and/or a full JSON body")
916
+ .option("--tags <list>", "comma-separated tag names")
917
+ .option("--pipelines <list>", "comma-separated pipeline names")
918
+ .option("--stages <list>", "comma-separated stage names")
919
+ .option("--users <list>", "comma-separated user names or emails")
920
+ .option("--json <json>", "full JSON body (e.g. {\"templates\":[\"Welcome\"]}), merged over the flags")
921
+ .action(run(async (client, opts) => {
922
+ const body = {};
923
+ if (opts.tags !== undefined)
924
+ body.tags = csvArg(opts.tags, "--tags");
925
+ if (opts.pipelines !== undefined)
926
+ body.pipelines = csvArg(opts.pipelines, "--pipelines");
927
+ if (opts.stages !== undefined)
928
+ body.stages = csvArg(opts.stages, "--stages");
929
+ if (opts.users !== undefined)
930
+ body.users = csvArg(opts.users, "--users");
931
+ if (opts.json !== undefined)
932
+ Object.assign(body, jsonArg(opts.json, "--json"));
933
+ if (Object.keys(body).length === 0)
934
+ throw new Error("Nothing to resolve. Pass --tags/--pipelines/--stages/--users or --json.");
935
+ return client.post("/api/v1/automations/resolve", body);
936
+ }));
937
+ automations
938
+ .command("validate")
939
+ .description("Validate a workflow graph before saving. Provide the body via --json or --file (needs triggerEvent + graph)")
940
+ .option("--json <json>", "full request body as JSON")
941
+ .option("--file <path>", "read the request body from a JSON file")
942
+ .action(run(async (client, opts) => client.post("/api/v1/automations/validate", bodyFromJsonOrFile(opts))));
943
+ automations
944
+ .command("import")
945
+ .description("Create a workflow (with optional name resolution). Provide the body via --json or --file (needs name + triggerEvent + graph)")
946
+ .option("--json <json>", "full request body as JSON")
947
+ .option("--file <path>", "read the request body from a JSON file")
948
+ .option("--resolve-names", "set resolveNames=true so name fields in node configs are converted to ids")
949
+ .action(run(async (client, opts) => {
950
+ const body = bodyFromJsonOrFile(opts);
951
+ if (opts.resolveNames)
952
+ body.resolveNames = true;
953
+ return client.post("/api/v1/automations/import", body);
954
+ }));
955
+ automations
956
+ .command("dry-run <id>")
957
+ .description("Test a workflow without side effects")
958
+ .option("--contact <id>", "run against this contact UUID")
959
+ .option("--appointment <id>", "seed with this appointment UUID")
960
+ .option("--source <source>", "draft | published (default draft)")
961
+ .option("--mode <mode>", "single | all-paths (default single)")
962
+ .action(run(async (client, id, opts) => {
963
+ assertUuid(id, "automation id");
964
+ const body = {};
965
+ if (opts.contact !== undefined)
966
+ body.contactId = opts.contact;
967
+ if (opts.appointment !== undefined)
968
+ body.appointmentId = opts.appointment;
969
+ return client.post(`/api/v1/automations/${encodeURIComponent(id)}/dry-run${buildQuery({ source: opts.source, mode: opts.mode })}`, body);
970
+ }));
971
+ automations
972
+ .command("publish <id>")
973
+ .description("Publish a draft workflow to live")
974
+ .action(run(async (client, id) => {
975
+ assertUuid(id, "automation id");
976
+ return client.post(`/api/v1/automations/${encodeURIComponent(id)}/publish`, {});
977
+ }));
978
+ automations
979
+ .command("step-logs [id]")
980
+ .description("Execution step logs. With an automation id, logs for that one; without, account-wide (filter with --automation)")
981
+ .option("--automation <id>", "account-wide: filter to this automation UUID (ignored when [id] is given)")
982
+ .option("--status <status>", "filter by step status")
983
+ .option("--action-type <type>", "filter by action type")
984
+ .option("--search <q>", "fuzzy search on contact / automation name")
985
+ .option("--date-from <iso>", "startedAt >= this")
986
+ .option("--date-to <iso>", "startedAt <= this")
987
+ .option("--limit <n>", "max results (default 50, max 200)")
988
+ .option("--offset <n>", "pagination offset")
989
+ .action(run(async (client, id, opts) => {
990
+ if (id !== undefined)
991
+ assertUuid(id, "automation id");
992
+ const q = buildQuery({
993
+ automationId: id === undefined ? opts.automation : undefined,
994
+ status: opts.status,
995
+ actionType: opts.actionType,
996
+ search: opts.search,
997
+ dateFrom: opts.dateFrom,
998
+ dateTo: opts.dateTo,
999
+ limit: opts.limit,
1000
+ offset: opts.offset,
1001
+ });
1002
+ const path = id !== undefined
1003
+ ? `/api/v1/automations/${encodeURIComponent(id)}/step-logs${q}`
1004
+ : `/api/v1/automations/step-logs${q}`;
1005
+ return client.get(path);
1006
+ }));
1007
+ // ---- companies ----
1008
+ const companies = program.command("companies").description("Manage companies");
1009
+ companies
1010
+ .command("list")
1011
+ .description("List companies")
1012
+ .option("--search <q>", "search by name/domain")
1013
+ .option("--industry <industry>", "filter by industry")
1014
+ .option("--size <size>", "filter by size band (e.g. 11-50)")
1015
+ .option("--sort <field>", "createdAt|updatedAt|name|healthScore|annualRevenue|totalValue|dealCount")
1016
+ .option("--order <dir>", "asc | desc")
1017
+ .option("--page <n>", "page number")
1018
+ .option("--limit <n>", "results per page (per_page)")
1019
+ .action(run(async (client, opts) => client.get(`/api/v1/companies${buildQuery({
1020
+ search: opts.search,
1021
+ industry: opts.industry,
1022
+ size: opts.size,
1023
+ sort: opts.sort,
1024
+ order: opts.order,
1025
+ page: opts.page,
1026
+ per_page: opts.limit,
1027
+ })}`)));
1028
+ companies
1029
+ .command("get <id>")
1030
+ .description("Get a single company by id")
1031
+ .action(run(async (client, id) => {
1032
+ assertUuid(id, "company id");
1033
+ return client.get(`/api/v1/companies/${encodeURIComponent(id)}`);
1034
+ }));
1035
+ companies
1036
+ .command("create")
1037
+ .description("Create a company")
1038
+ .option("--name <name>", "company name (required)")
1039
+ .option("--domain <domain>", "domain")
1040
+ .option("--industry <industry>", "industry")
1041
+ .option("--size <size>", "size band: 1-10|11-50|51-200|201-500|501-1000|1001+")
1042
+ .option("--website <url>", "website URL")
1043
+ .option("--phone <phone>", "phone")
1044
+ .option("--city <city>", "city")
1045
+ .option("--state <state>", "state")
1046
+ .option("--country <cc>", "ISO-3166 alpha-2 country code (default US)")
1047
+ .option("--description <text>", "description")
1048
+ .option("--json <json>", "full JSON body, merged over the flags")
1049
+ .action(run(async (client, opts) => {
1050
+ const body = {};
1051
+ if (opts.name !== undefined)
1052
+ body.name = opts.name;
1053
+ if (opts.domain !== undefined)
1054
+ body.domain = opts.domain;
1055
+ if (opts.industry !== undefined)
1056
+ body.industry = opts.industry;
1057
+ if (opts.size !== undefined)
1058
+ body.size = opts.size;
1059
+ if (opts.website !== undefined)
1060
+ body.website = opts.website;
1061
+ if (opts.phone !== undefined)
1062
+ body.phone = opts.phone;
1063
+ if (opts.city !== undefined)
1064
+ body.city = opts.city;
1065
+ if (opts.state !== undefined)
1066
+ body.state = opts.state;
1067
+ if (opts.country !== undefined)
1068
+ body.country = opts.country;
1069
+ if (opts.description !== undefined)
1070
+ body.description = opts.description;
1071
+ if (opts.json !== undefined)
1072
+ Object.assign(body, jsonArg(opts.json, "--json"));
1073
+ return client.post("/api/v1/companies", body);
1074
+ }));
1075
+ companies
1076
+ .command("update <id>")
1077
+ .description("Update a company (PATCH — only the fields you pass change)")
1078
+ .option("--name <name>", "company name")
1079
+ .option("--domain <domain>", "domain")
1080
+ .option("--industry <industry>", "industry")
1081
+ .option("--size <size>", "size band")
1082
+ .option("--website <url>", "website URL")
1083
+ .option("--phone <phone>", "phone")
1084
+ .option("--city <city>", "city")
1085
+ .option("--state <state>", "state")
1086
+ .option("--country <cc>", "ISO-3166 alpha-2 country code")
1087
+ .option("--description <text>", "description")
1088
+ .option("--json <json>", "full JSON body, merged over the flags")
1089
+ .action(run(async (client, id, opts) => {
1090
+ assertUuid(id, "company id");
1091
+ const body = {};
1092
+ if (opts.name !== undefined)
1093
+ body.name = opts.name;
1094
+ if (opts.domain !== undefined)
1095
+ body.domain = opts.domain;
1096
+ if (opts.industry !== undefined)
1097
+ body.industry = opts.industry;
1098
+ if (opts.size !== undefined)
1099
+ body.size = opts.size;
1100
+ if (opts.website !== undefined)
1101
+ body.website = opts.website;
1102
+ if (opts.phone !== undefined)
1103
+ body.phone = opts.phone;
1104
+ if (opts.city !== undefined)
1105
+ body.city = opts.city;
1106
+ if (opts.state !== undefined)
1107
+ body.state = opts.state;
1108
+ if (opts.country !== undefined)
1109
+ body.country = opts.country;
1110
+ if (opts.description !== undefined)
1111
+ body.description = opts.description;
1112
+ if (opts.json !== undefined)
1113
+ Object.assign(body, jsonArg(opts.json, "--json"));
1114
+ if (Object.keys(body).length === 0)
1115
+ throw new Error("Nothing to update. Pass at least one field flag or --json.");
1116
+ return client.patch(`/api/v1/companies/${encodeURIComponent(id)}`, body);
1117
+ }));
1118
+ // ---- tags ----
1119
+ // ---- saved filters (personal, Velocify-style) ----
1120
+ const savedFilters = program
1121
+ .command("saved-filters")
1122
+ .description("Manage YOUR personal saved contact filters (the web app's Saved dropdown; requires the 'saved-filters' key scope)");
1123
+ savedFilters
1124
+ .command("list")
1125
+ .description("List your saved filters")
1126
+ .option("--entity <type>", "entity type (default contact)")
1127
+ .action(run(async (client, opts) => client.get(`/api/v1/saved-filters${buildQuery({ entity_type: opts.entity })}`)));
1128
+ savedFilters
1129
+ .command("create")
1130
+ .description("Save a named filter (grouped payload via --filters JSON)")
1131
+ .option("--name <name>", "display name (required, unique per user, max 60 chars)")
1132
+ .option("--filters <json>", `grouped filter payload JSON, e.g. [{"conjunction":"AND","rules":[{"field":"source","operator":"equals","value":"facebook"}]}]`)
1133
+ .option("--sort-field <field>", "sort field (default createdAt)")
1134
+ .option("--sort-order <dir>", "asc | desc (default desc)")
1135
+ .option("--entity <type>", "entity type (default contact)")
1136
+ .option("--json <json>", "full JSON body, merged over the flags")
1137
+ .action(run(async (client, opts) => {
1138
+ const body = {};
1139
+ if (opts.name !== undefined)
1140
+ body.name = opts.name;
1141
+ if (opts.filters !== undefined)
1142
+ body.filters = jsonArrayArg(opts.filters, "--filters");
1143
+ if (opts.sortField !== undefined)
1144
+ body.sortField = opts.sortField;
1145
+ if (opts.sortOrder !== undefined)
1146
+ body.sortOrder = opts.sortOrder;
1147
+ if (opts.entity !== undefined)
1148
+ body.entityType = opts.entity;
1149
+ if (opts.json !== undefined)
1150
+ Object.assign(body, jsonArg(opts.json, "--json"));
1151
+ // A --json merge can clobber filters with a non-array; re-assert the
1152
+ // shape after ALL merges so a malformed payload fails locally.
1153
+ if (body.filters !== undefined && !Array.isArray(body.filters)) {
1154
+ throw new Error("filters must be a JSON ARRAY (grouped payload or flat rules).");
1155
+ }
1156
+ return client.post("/api/v1/saved-filters", body);
1157
+ }));
1158
+ savedFilters
1159
+ .command("update <id>")
1160
+ .description("Rename a saved filter or replace its filters/sort (owner-only)")
1161
+ .option("--name <name>", "new display name")
1162
+ .option("--filters <json>", "grouped filter payload JSON")
1163
+ .option("--sort-field <field>", "sort field")
1164
+ .option("--sort-order <dir>", "asc | desc")
1165
+ .option("--json <json>", "full JSON body, merged over the flags")
1166
+ .action(run(async (client, id, opts) => {
1167
+ assertUuid(id, "saved filter id");
1168
+ const body = {};
1169
+ if (opts.name !== undefined)
1170
+ body.name = opts.name;
1171
+ if (opts.filters !== undefined)
1172
+ body.filters = jsonArrayArg(opts.filters, "--filters");
1173
+ if (opts.sortField !== undefined)
1174
+ body.sortField = opts.sortField;
1175
+ if (opts.sortOrder !== undefined)
1176
+ body.sortOrder = opts.sortOrder;
1177
+ if (opts.json !== undefined)
1178
+ Object.assign(body, jsonArg(opts.json, "--json"));
1179
+ // Same post-merge shape assertion as create — see above.
1180
+ if (body.filters !== undefined && !Array.isArray(body.filters)) {
1181
+ throw new Error("filters must be a JSON ARRAY (grouped payload or flat rules).");
1182
+ }
1183
+ return client.patch(`/api/v1/saved-filters/${encodeURIComponent(id)}`, body);
1184
+ }));
1185
+ savedFilters
1186
+ .command("delete <id>")
1187
+ .description("Delete one of your saved filters (owner-only)")
1188
+ .action(run(async (client, id) => {
1189
+ assertUuid(id, "saved filter id");
1190
+ return client.del(`/api/v1/saved-filters/${encodeURIComponent(id)}`);
1191
+ }));
1192
+ const tags = program.command("tags").description("Manage tags");
1193
+ tags
1194
+ .command("list")
1195
+ .description("List tags")
1196
+ .option("--name <name>", "exact (case-insensitive) name match")
1197
+ .option("--search <q>", "name contains")
1198
+ .option("--page <n>", "page number")
1199
+ .option("--limit <n>", "results per page (per_page)")
1200
+ .action(run(async (client, opts) => client.get(`/api/v1/tags${buildQuery({ name: opts.name, search: opts.search, page: opts.page, per_page: opts.limit })}`)));
1201
+ tags
1202
+ .command("create")
1203
+ .description("Create a tag")
1204
+ .option("--name <name>", "tag name (required)")
1205
+ .option("--color <hex>", "hex color (default #6B7280)")
1206
+ .action(run(async (client, opts) => {
1207
+ const body = {};
1208
+ if (opts.name !== undefined)
1209
+ body.name = opts.name;
1210
+ if (opts.color !== undefined)
1211
+ body.color = opts.color;
1212
+ return client.post("/api/v1/tags", body);
1213
+ }));
1214
+ // ---- notes ----
1215
+ const notes = program.command("notes").description("Manage notes");
1216
+ notes
1217
+ .command("list")
1218
+ .description("List notes (filter by contact or deal)")
1219
+ .option("--contact <id>", "filter by contact UUID")
1220
+ .option("--deal <id>", "filter by deal UUID")
1221
+ .option("--page <n>", "page number")
1222
+ .option("--limit <n>", "results per page (per_page)")
1223
+ .action(run(async (client, opts) => client.get(`/api/v1/notes${buildQuery({ contact_id: opts.contact, deal_id: opts.deal, page: opts.page, per_page: opts.limit })}`)));
1224
+ notes
1225
+ .command("create")
1226
+ .description("Create a note on a contact and/or deal")
1227
+ .option("--body <text>", "note body (required)")
1228
+ .option("--contact <id>", "attach to contact UUID")
1229
+ .option("--deal <id>", "attach to deal UUID")
1230
+ .option("--pinned", "pin the note")
1231
+ .option("--json <json>", "full JSON body, merged over the flags")
1232
+ .action(run(async (client, opts) => {
1233
+ const body = {};
1234
+ if (opts.body !== undefined)
1235
+ body.body = opts.body;
1236
+ if (opts.contact !== undefined)
1237
+ body.contactId = opts.contact;
1238
+ if (opts.deal !== undefined)
1239
+ body.dealId = opts.deal;
1240
+ if (opts.pinned)
1241
+ body.isPinned = true;
1242
+ if (opts.json !== undefined)
1243
+ Object.assign(body, jsonArg(opts.json, "--json"));
1244
+ return client.post("/api/v1/notes", body);
1245
+ }));
1246
+ // ---- activities ----
1247
+ const activities = program.command("activities").description("Manage activities (timeline events)");
1248
+ activities
1249
+ .command("list")
1250
+ .description("List activities")
1251
+ .option("--contact <id>", "filter by contact UUID")
1252
+ .option("--deal <id>", "filter by deal UUID")
1253
+ .option("--type <type>", "filter by type (buckets: task, deal_update)")
1254
+ .option("--search <q>", "search")
1255
+ .option("--from <date>", "fromDate YYYY-MM-DD")
1256
+ .option("--to <date>", "toDate YYYY-MM-DD")
1257
+ .option("--page <n>", "page number")
1258
+ .option("--limit <n>", "results per page (per_page)")
1259
+ .action(run(async (client, opts) => client.get(`/api/v1/activities${buildQuery({
1260
+ contact_id: opts.contact,
1261
+ deal_id: opts.deal,
1262
+ type: opts.type,
1263
+ search: opts.search,
1264
+ fromDate: opts.from,
1265
+ toDate: opts.to,
1266
+ page: opts.page,
1267
+ per_page: opts.limit,
1268
+ })}`)));
1269
+ activities
1270
+ .command("create")
1271
+ .description("Log an activity (type call/email/sms/meeting are stored as manual activities)")
1272
+ .option("--type <type>", "activity type (required)")
1273
+ .option("--title <title>", "title (required)")
1274
+ .option("--description <text>", "description")
1275
+ .option("--contact <id>", "contact UUID")
1276
+ .option("--deal <id>", "deal UUID")
1277
+ .option("--json <json>", "full JSON body, merged over the flags")
1278
+ .action(run(async (client, opts) => {
1279
+ const body = {};
1280
+ if (opts.type !== undefined)
1281
+ body.type = opts.type;
1282
+ if (opts.title !== undefined)
1283
+ body.title = opts.title;
1284
+ if (opts.description !== undefined)
1285
+ body.description = opts.description;
1286
+ if (opts.contact !== undefined)
1287
+ body.contactId = opts.contact;
1288
+ if (opts.deal !== undefined)
1289
+ body.dealId = opts.deal;
1290
+ if (opts.json !== undefined)
1291
+ Object.assign(body, jsonArg(opts.json, "--json"));
1292
+ return client.post("/api/v1/activities", body);
1293
+ }));
1294
+ // ---- users: update / deactivate (added to the existing `users` group) ----
1295
+ users
1296
+ .command("update <id>")
1297
+ .description("Update a team member (PATCH). Cannot change active status here — use `users deactivate`")
1298
+ .option("--first <name>", "first name")
1299
+ .option("--last <name>", "last name")
1300
+ .option("--email <email>", "email")
1301
+ .option("--role <role>", "member | admin | owner")
1302
+ .option("--phone <phone>", "phone")
1303
+ .option("--json <json>", "full JSON body, merged over the flags")
1304
+ .action(run(async (client, id, opts) => {
1305
+ assertUuid(id, "user id");
1306
+ const body = {};
1307
+ if (opts.first !== undefined)
1308
+ body.firstName = opts.first;
1309
+ if (opts.last !== undefined)
1310
+ body.lastName = opts.last;
1311
+ if (opts.email !== undefined)
1312
+ body.email = opts.email;
1313
+ if (opts.role !== undefined)
1314
+ body.role = opts.role;
1315
+ if (opts.phone !== undefined)
1316
+ body.phone = opts.phone;
1317
+ if (opts.json !== undefined)
1318
+ Object.assign(body, jsonArg(opts.json, "--json"));
1319
+ if (Object.keys(body).length === 0)
1320
+ throw new Error("Nothing to update. Pass at least one field flag or --json.");
1321
+ return client.patch(`/api/v1/users/${encodeURIComponent(id)}`, body);
1322
+ }));
1323
+ users
1324
+ .command("deactivate <id>")
1325
+ .description("Soft-deactivate a team member (revokes access; use --reassign to hand off their records)")
1326
+ .option("--reassign <id>", "reassign this user's records to this user UUID")
1327
+ .action(run(async (client, id, opts) => {
1328
+ assertUuid(id, "user id");
1329
+ return client.post(`/api/v1/users/${encodeURIComponent(id)}/deactivate${buildQuery({ reassign_to: opts.reassign })}`, {});
1330
+ }));
1331
+ // ---- custom-fields: list / update / delete (added to the existing group) ----
1332
+ customFields
1333
+ .command("list")
1334
+ .description("List custom field definitions")
1335
+ .option("--entity <type>", "entityType: contact | deal | company")
1336
+ .option("--page <n>", "page number")
1337
+ .option("--limit <n>", "results per page (per_page)")
1338
+ .action(run(async (client, opts) => client.get(`/api/v1/custom-fields${buildQuery({ entityType: opts.entity, page: opts.page, per_page: opts.limit })}`)));
1339
+ customFields
1340
+ .command("update <id>")
1341
+ .description("Update a custom field definition (label/section/options/required/order are mutable; type & key are not)")
1342
+ .option("--label <label>", "display label")
1343
+ .option("--section <section>", "section grouping")
1344
+ .option("--options <json>", "JSON array of options (for select/radio/multiselect)")
1345
+ .option("--required", "mark the field required")
1346
+ .option("--not-required", "mark the field not required")
1347
+ .option("--sort <n>", "sort order (integer)")
1348
+ .option("--json <json>", "full JSON body, merged over the flags")
1349
+ .action(run(async (client, id, opts) => {
1350
+ assertUuid(id, "custom field id");
1351
+ const body = {};
1352
+ if (opts.label !== undefined)
1353
+ body.label = opts.label;
1354
+ if (opts.section !== undefined)
1355
+ body.section = opts.section;
1356
+ if (opts.options !== undefined) {
1357
+ if (opts.options.trim() === "")
1358
+ throw new Error("--options must be valid JSON (got an empty string).");
1359
+ let o;
1360
+ try {
1361
+ o = JSON.parse(opts.options);
1362
+ }
1363
+ catch {
1364
+ throw new Error("--options must be valid JSON.");
1365
+ }
1366
+ if (!Array.isArray(o) || o.length === 0) {
1367
+ throw new Error("--options must be a non-empty JSON array.");
1368
+ }
1369
+ body.options = o;
1370
+ }
1371
+ if (opts.required)
1372
+ body.isRequired = true;
1373
+ if (opts.notRequired)
1374
+ body.isRequired = false;
1375
+ if (opts.sort !== undefined) {
1376
+ const n = Number(opts.sort);
1377
+ if (!Number.isInteger(n))
1378
+ throw new Error("--sort must be an integer.");
1379
+ body.sortOrder = n;
1380
+ }
1381
+ if (opts.json !== undefined)
1382
+ Object.assign(body, jsonArg(opts.json, "--json"));
1383
+ if (Object.keys(body).length === 0)
1384
+ throw new Error("Nothing to update. Pass at least one field flag or --json.");
1385
+ return client.patch(`/api/v1/custom-fields/${encodeURIComponent(id)}`, body);
1386
+ }));
1387
+ customFields
1388
+ .command("delete <id>")
1389
+ .description("Delete a custom field definition. If it has stored values/dependencies the API returns a 409 with the counts — re-run with --confirm --values <n> --deps <n>")
1390
+ .option("--confirm", "confirm deletion of a field that has impact")
1391
+ .option("--values <n>", "expectedValueCount (from the 409 response)")
1392
+ .option("--deps <n>", "expectedDependencyCount (from the 409 response)")
1393
+ .action(run(async (client, id, opts) => {
1394
+ assertUuid(id, "custom field id");
1395
+ let body;
1396
+ if (opts.confirm || opts.values !== undefined || opts.deps !== undefined) {
1397
+ body = { confirmImpact: true };
1398
+ if (opts.values !== undefined) {
1399
+ const n = Number(opts.values);
1400
+ if (!Number.isInteger(n) || n < 0)
1401
+ throw new Error("--values must be a non-negative integer.");
1402
+ body.expectedValueCount = n;
1403
+ }
1404
+ if (opts.deps !== undefined) {
1405
+ const n = Number(opts.deps);
1406
+ if (!Number.isInteger(n) || n < 0)
1407
+ throw new Error("--deps must be a non-negative integer.");
1408
+ body.expectedDependencyCount = n;
1409
+ }
1410
+ }
1411
+ return client.request("DELETE", `/api/v1/custom-fields/${encodeURIComponent(id)}`, body);
1412
+ }));
1413
+ // ---- webhooks ----
1414
+ const webhooks = program.command("webhooks").description("Manage outbound webhooks");
1415
+ webhooks
1416
+ .command("list")
1417
+ .description("List registered webhooks (secrets omitted)")
1418
+ .option("--page <n>", "page number")
1419
+ .option("--limit <n>", "results per page (per_page)")
1420
+ .action(run(async (client, opts) => client.get(`/api/v1/webhooks/manage${buildQuery({ page: opts.page, per_page: opts.limit })}`)));
1421
+ webhooks
1422
+ .command("register")
1423
+ .description("Register an outbound webhook. The signing secret (whsec_...) is returned ONCE in the response — store it now")
1424
+ .option("--url <url>", "delivery URL (https in prod) (required)")
1425
+ .option("--events <list>", "comma-separated event names (required)")
1426
+ .option("--description <text>", "description")
1427
+ .option("--inactive", "create it disabled (isActive=false)")
1428
+ .option("--json <json>", "full JSON body, merged over the flags")
1429
+ .action(run(async (client, opts) => {
1430
+ const body = {};
1431
+ if (opts.url !== undefined)
1432
+ body.url = opts.url;
1433
+ if (opts.events !== undefined)
1434
+ body.events = csvArg(opts.events, "--events");
1435
+ if (opts.description !== undefined)
1436
+ body.description = opts.description;
1437
+ if (opts.inactive)
1438
+ body.isActive = false;
1439
+ if (opts.json !== undefined)
1440
+ Object.assign(body, jsonArg(opts.json, "--json"));
1441
+ return client.post("/api/v1/webhooks/manage", body);
1442
+ }));
1443
+ // ---- dnc (Do Not Call) ----
1444
+ // NOTE: an AI agent's API key must be granted the `dnc` scope to manage the DNC
1445
+ // list. Reading also requires dialer:view; adding a litigator entry and removals
1446
+ // are admin/owner. A non-admin agent may only DNC a number on a contact it owns
1447
+ // or a number it recently called.
1448
+ const dnc = program.command("dnc").description("Manage the Do Not Call list (API key needs the `dnc` scope)");
1449
+ dnc
1450
+ .command("list")
1451
+ .description("List/search DNC entries. Non-admins must pass --phone (block check only); account-wide browse needs broader access")
1452
+ .option("--phone <number>", "phone number to check/search")
1453
+ .option("--tier <tier>", "dnc | litigator")
1454
+ .option("--search <q>", "search by contact name/phone (admin)")
1455
+ .option("--page <n>", "page number")
1456
+ .option("--limit <n>", "results per page (per_page)")
1457
+ .action(run(async (client, opts) => client.get(`/api/v1/dnc${buildQuery({ phone: opts.phone, tier: opts.tier, search: opts.search, page: opts.page, per_page: opts.limit })}`)));
1458
+ dnc
1459
+ .command("add")
1460
+ .description("Add a number/contact to the DNC list (needs the `dnc` scope; litigator tier is admin-only)")
1461
+ .option("--contact <id>", "contact UUID (contactId or --phone required)")
1462
+ .option("--phone <number>", "phone number (contactId or --phone required)")
1463
+ .option("--tier <tier>", "dnc | litigator (default dnc)")
1464
+ .option("--reason <text>", "reason")
1465
+ .action(run(async (client, opts) => {
1466
+ const body = {};
1467
+ if (opts.contact !== undefined)
1468
+ body.contactId = opts.contact;
1469
+ if (opts.phone !== undefined)
1470
+ body.phone = opts.phone;
1471
+ body.tier = opts.tier ?? "dnc";
1472
+ if (opts.reason !== undefined)
1473
+ body.reason = opts.reason;
1474
+ return client.post("/api/v1/dnc", body);
1475
+ }));
1476
+ dnc
1477
+ .command("remove <id>")
1478
+ .description("Remove a DNC entry by id (admin/owner)")
1479
+ .action(run(async (client, id) => {
1480
+ assertUuid(id, "dnc id");
1481
+ return client.del(`/api/v1/dnc/${encodeURIComponent(id)}`);
1482
+ }));
1483
+ // ---- ai ----
1484
+ const ai = program.command("ai").description("AI assistant and insight endpoints");
1485
+ ai
1486
+ .command("chat <message>")
1487
+ .description("Ask the CRM AI assistant. Streams a server-sent event (SSE) response straight to stdout")
1488
+ .option("--context <text>", "extra page/context string (treated as untrusted data by the assistant)")
1489
+ .action(async (message, opts) => {
1490
+ try {
1491
+ if (message.trim() === "")
1492
+ throw new Error("message must not be empty.");
1493
+ const client = new ConduytClient();
1494
+ const body = { messages: [{ role: "user", content: message }] };
1495
+ if (opts.context !== undefined)
1496
+ body.context = opts.context;
1497
+ await client.stream("POST", "/api/v1/ai/chat", body);
1498
+ }
1499
+ catch (err) {
1500
+ fail(err);
1501
+ }
1502
+ });
1503
+ ai
1504
+ .command("next-actions")
1505
+ .description("AI-recommended next actions for a user (defaults to you; other users need admin/owner)")
1506
+ .option("--user <id>", "user UUID")
1507
+ .action(run(async (client, opts) => client.get(`/api/v1/ai/next-actions${buildQuery({ userId: opts.user })}`)));
1508
+ ai
1509
+ .command("daily-brief")
1510
+ .description("Your AI daily brief (task-focused summary)")
1511
+ .action(run(async (client) => client.get("/api/v1/ai/daily-brief")));
1512
+ ai
1513
+ .command("summarize-contact <contactId>")
1514
+ .description("Generate an AI summary of a contact")
1515
+ .action(run(async (client, contactId) => {
1516
+ assertUuid(contactId, "contact id");
1517
+ return client.post("/api/v1/ai/summarize-contact", { contactId });
1518
+ }));
1519
+ ai
1520
+ .command("enrich")
1521
+ .description("AI-enrich a contact identity (needs at least one of --email / --first / --company)")
1522
+ .option("--email <email>", "email")
1523
+ .option("--first <name>", "first name")
1524
+ .option("--last <name>", "last name")
1525
+ .option("--company <company>", "company")
1526
+ .option("--title <title>", "job title")
1527
+ .option("--phone <phone>", "phone")
1528
+ .option("--json <json>", "full JSON body, merged over the flags")
1529
+ .action(run(async (client, opts) => {
1530
+ const body = {};
1531
+ if (opts.email !== undefined)
1532
+ body.email = opts.email;
1533
+ if (opts.first !== undefined)
1534
+ body.firstName = opts.first;
1535
+ if (opts.last !== undefined)
1536
+ body.lastName = opts.last;
1537
+ if (opts.company !== undefined)
1538
+ body.company = opts.company;
1539
+ if (opts.title !== undefined)
1540
+ body.jobTitle = opts.title;
1541
+ if (opts.phone !== undefined)
1542
+ body.phone = opts.phone;
1543
+ if (opts.json !== undefined)
1544
+ Object.assign(body, jsonArg(opts.json, "--json"));
1545
+ return client.post("/api/v1/ai/enrich-contact", body);
1546
+ }));
439
1547
  // ---- raw escape hatch ----
440
1548
  program
441
1549
  .command("api <method> <path>")
@@ -523,6 +1631,22 @@ function rejectProtoPollution(obj, flag) {
523
1631
  }
524
1632
  }
525
1633
  }
1634
+ function jsonArrayArg(raw, flag) {
1635
+ if (raw.trim() === "") {
1636
+ throw new Error(`${flag} must be valid JSON (got an empty string).`);
1637
+ }
1638
+ let v;
1639
+ try {
1640
+ v = JSON.parse(raw);
1641
+ }
1642
+ catch {
1643
+ throw new Error(`${flag} must be valid JSON.`);
1644
+ }
1645
+ if (!Array.isArray(v)) {
1646
+ throw new Error(`${flag} must be a JSON ARRAY (e.g. [{"conjunction":"AND","rules":[...]}]).`);
1647
+ }
1648
+ return v;
1649
+ }
526
1650
  function jsonArg(raw, flag) {
527
1651
  if (raw.trim() === "") {
528
1652
  throw new Error(`${flag} must be valid JSON (got an empty string).`);
@@ -543,6 +1667,58 @@ function jsonArg(raw, flag) {
543
1667
  rejectProtoPollution(v, flag);
544
1668
  return v;
545
1669
  }
1670
+ // Non-throwing UUID check (assertUuid throws; this just tests). Used to decide
1671
+ // whether a value is already an id or a human name that needs resolving.
1672
+ function looksLikeUuid(s) {
1673
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s.trim());
1674
+ }
1675
+ // Resolve a stage name-or-UUID to a stage UUID, mirroring how the API/MCP let an
1676
+ // agent use human-readable names: a UUID passes through; a name is resolved via
1677
+ // POST /automations/resolve (the canonical name->id endpoint). Fails closed with
1678
+ // a clear message rather than PATCHing a bad stageId.
1679
+ async function resolveStageId(client, value) {
1680
+ const v = value.trim();
1681
+ if (looksLikeUuid(v))
1682
+ return v;
1683
+ const res = (await client.post("/api/v1/automations/resolve", { stages: [v] }));
1684
+ const id = res?.data?.stages?.[v];
1685
+ if (typeof id === "string" && looksLikeUuid(id))
1686
+ return id;
1687
+ throw new Error(`Could not resolve stage "${v}" to a stage id. Check the name (see \`conduyt pipelines list\`) or pass a stage UUID to --stage.`);
1688
+ }
1689
+ // Split a comma-separated flag value into a non-empty array of trimmed strings.
1690
+ // Used for array-valued request fields (webhook events, resolve name lists).
1691
+ function csvArg(raw, flag) {
1692
+ const items = raw.split(",").map((s) => s.trim()).filter((s) => s !== "");
1693
+ if (items.length === 0) {
1694
+ throw new Error(`${flag} must be a non-empty comma-separated list.`);
1695
+ }
1696
+ return items;
1697
+ }
1698
+ // Build a full request body from either --json (inline) or --file (a path to a
1699
+ // JSON file), requiring exactly one. Used by automations validate/import whose
1700
+ // bodies (a workflow graph) are too large to express as flat flags. The parsed
1701
+ // value must be a plain JSON object; proto-polluting keys are rejected.
1702
+ function bodyFromJsonOrFile(opts) {
1703
+ if (opts.json !== undefined && opts.file !== undefined) {
1704
+ throw new Error("Pass either --json or --file, not both.");
1705
+ }
1706
+ if (opts.json !== undefined)
1707
+ return jsonArg(opts.json, "--json");
1708
+ if (opts.file !== undefined) {
1709
+ if (opts.file.trim() === "")
1710
+ throw new Error("--file must be a path (got an empty string).");
1711
+ let raw;
1712
+ try {
1713
+ raw = readFileSync(opts.file, "utf8");
1714
+ }
1715
+ catch {
1716
+ throw new Error(`--file could not be read: ${opts.file}`);
1717
+ }
1718
+ return jsonArg(raw, "--file");
1719
+ }
1720
+ throw new Error("A request body is required. Pass --json '<json>' or --file <path>.");
1721
+ }
546
1722
  function run(handler) {
547
1723
  return async (...args) => {
548
1724
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conduyt",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Command-line interface for Conduyt CRM — manage contacts, deals, pipelines, and run insight queries from your terminal.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",