conduyt 1.65.0 → 1.66.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/README.md CHANGED
@@ -44,6 +44,8 @@ conduyt notifications list [--page n] [--per-page 1-200] # prints { data, meta
44
44
  conduyt deals list --pipeline <id> # list deals
45
45
  conduyt deals list --health-summary --pipeline <id> # Pipeline health strip for the filtered set (totalOpenDeals, atRiskValue, checks[]); add --health-check stale --health-limit 20 for one check's deals, --health-cursor <nextCursor> to page, --health-rows-only for just the page
46
46
  conduyt deals health --pipeline <id> --check needs_action # GET /deals/inspection: the six checks over open deals; --check adds its deals, --cursor/--offset pages
47
+ conduyt deals health-fix <dealId> --check stale --expected-updated-at <iso> --stage-id <uuid> # Pipeline health quick fix (PATCH /deals/{id} with checkOrigin): stale = --stage-id, unassigned = --assigned, missing_close_date = --close-date, missing_contact = --contact; 409 deal_changed / deal_check_cleared and 400 health_fix_mismatch exit 1 verbatim, never retried
48
+ conduyt deals health-next-action <dealId> --check needs_action --expected-updated-at <iso> --title "Call back" --idempotency-key <key> # "Set next action" (POST /tasks with checkOrigin): one non-done quick-fix task per deal, 409 deal_has_next_action / deal_changed / deal_check_cleared exit 1 verbatim
47
49
  conduyt deals card-fields set value,contact,custom:next_contact_at # the kanban card fields (1-8, built-ins or custom:<fieldKey>); `card-fields get` reads them
48
50
  conduyt pipelines stage-rules set <pipelineId> <stageId> --required-fields contactId,custom:loan_amount --required-documents 2 [--lost-reason-allow-other true] # what a deal needs to enter the stage; other stages untouched; `stage-rules get [pipelineId] [stageId]` reads
49
51
  conduyt deals bulk-update --file updates.json # update up to 100 deals at once ([{id, stageId, value, ...}]); per-item results, exit 1 if any failed
@@ -93,6 +95,24 @@ Pass `--input '{"...":"..."}'` to `insights` and `--body '{"...":"..."}'` to `ap
93
95
 
94
96
  All output is JSON, ready to pipe into `jq`.
95
97
 
98
+ ## Error output grammar
99
+
100
+ A refused call (HTTP 4xx/5xx) prints one line on stderr and exits 1:
101
+
102
+ ```
103
+ Error: Conduyt API <status>: <server message>[ [Retry-After: <N>s]][ <details>]
104
+ ```
105
+
106
+ `[Retry-After: Ns]` appears only when the server sent the header (shown, never acted on: nothing is retried). `<details>` appears
107
+ only when the body had more than `error`: it is ONE JSON object and the FINAL token. To extract it, try each `{` from the left
108
+ until the rest of the line parses as JSON (a server message may itself contain a brace). Scalars (`code`, revision) come
109
+ first, nested objects whole; `HealthFixRefusal.row` is always whole, any other nested object too large for the bound is left
110
+ out and named in `_omitted`. Every HTTP refusal takes this path (JSON calls, text exports and streams alike). The settings-conflict commands merge their hint and the current settings INTO that final object (`{...details, hint, current}`), never after it. Example:
111
+
112
+ ```
113
+ Error: Conduyt API 503: Contact merge in progress [Retry-After: 5s] {"code":"merge_in_progress"}
114
+ ```
115
+
96
116
  ## License
97
117
 
98
118
  MIT
package/dist/client.js CHANGED
@@ -7,6 +7,55 @@ function ambiguousTimeout(err, method, path, timeoutMs, what) {
7
7
  }
8
8
  return err;
9
9
  }
10
+ /** Room for the nested objects of an error body (a 409 contact_changed's `contact`, a stage-requirements list). */
11
+ const ERROR_DETAILS_BOUND = 1000;
12
+ /** Error-body keys that are the actionable state itself and are always rendered WHOLE: HealthFixRefusal.row (the deal's
13
+ * current health row, which a caller needs to choose again). */
14
+ const ALWAYS_WHOLE_KEYS = new Set(["row"]);
15
+ /** Everything but `error` as ONE valid JSON object: scalars first (a very long string is shortened inside its quotes), then
16
+ * nested objects whole while they fit ERROR_DETAILS_BOUND, plus ALWAYS_WHOLE_KEYS; one that does not fit is left out and
17
+ * named in `_omitted`. JSON is never cut mid-object. */
18
+ export function renderErrorDetails(body) {
19
+ const entries = Object.entries(body).filter(([k]) => k !== "error");
20
+ if (entries.length === 0)
21
+ return "";
22
+ const isScalar = (v) => v === null || typeof v !== "object";
23
+ const out = {};
24
+ for (const [k, v] of entries.filter(([, v]) => isScalar(v)))
25
+ out[k] = typeof v === "string" && v.length > ERROR_DETAILS_BOUND ? v.slice(0, ERROR_DETAILS_BOUND) + "...[shortened]" : v;
26
+ const omitted = [];
27
+ for (const [k, v] of entries.filter(([, v]) => !isScalar(v))) {
28
+ if (ALWAYS_WHOLE_KEYS.has(k) || JSON.stringify({ ...out, [k]: v }).length <= ERROR_DETAILS_BOUND)
29
+ out[k] = v;
30
+ else
31
+ omitted.push(k);
32
+ }
33
+ if (omitted.length > 0)
34
+ out._omitted = omitted;
35
+ return JSON.stringify(out);
36
+ }
37
+ /**
38
+ * The ONE place an HTTP refusal becomes an Error (request, postText and stream all use it). Grammar (stable):
39
+ * `Conduyt API <status>: <message>[ [Retry-After: <N>s]][ <details>]`; <details> is ONE JSON object and the FINAL token
40
+ * (renderErrorDetails). Retry-After is shown, never acted on. A structured body without `error` IS the details object.
41
+ */
42
+ export function formatHttpError(res, text) {
43
+ const json = text ? safeParse(text) : null;
44
+ const ra = res.headers.get("retry-after");
45
+ const raN = ra === null || ra.trim() === "" ? NaN : Number(ra);
46
+ const raTag = Number.isFinite(raN) && raN >= 0 ? ` [Retry-After: ${raN}s]` : "";
47
+ const obj = json && typeof json === "object" && !Array.isArray(json) ? json : null;
48
+ if (obj && typeof obj.error === "string" && obj.error) {
49
+ const extras = renderErrorDetails(obj);
50
+ return new Error(`Conduyt API ${res.status}: ${obj.error}${raTag}${extras ? ` ${extras}` : ""}`);
51
+ }
52
+ // several endpoints answer the data a caller needs IN the error body (custom-field delete 409 valueCount / dependencyCount)
53
+ const extras = obj ? renderErrorDetails(obj) : "";
54
+ if (extras)
55
+ return new Error(`Conduyt API ${res.status}:${raTag} ${extras}`);
56
+ const plain = typeof json === "string" && json ? json.slice(0, 500) : "";
57
+ return new Error(`Conduyt API ${res.status}: ${plain || res.statusText || `HTTP ${res.status}`}${raTag}`);
58
+ }
10
59
  export class ConduytClient {
11
60
  baseUrl;
12
61
  apiKey;
@@ -25,6 +74,7 @@ export class ConduytClient {
25
74
  res = await fetch(url, {
26
75
  method,
27
76
  headers: {
77
+ ...(opts?.headers ?? {}),
28
78
  Authorization: `Bearer ${this.apiKey}`,
29
79
  "Content-Type": "application/json",
30
80
  },
@@ -45,31 +95,7 @@ export class ConduytClient {
45
95
  }
46
96
  const json = text ? safeParse(text) : null;
47
97
  if (!res.ok) {
48
- // Preserve structured non-2xx bodies: several endpoints return the data
49
- // a caller needs to proceed IN the error body (e.g. custom-field delete
50
- // 409 carries valueCount/dependencyCount for the --confirm flow).
51
- // Reducing those to statusText would strand the user.
52
- const structured = json && typeof json === "object" && !json.error
53
- ? JSON.stringify(json)
54
- : null;
55
- let msg = json?.error || structured || res.statusText || `HTTP ${res.status}`;
56
- // Bodies with BOTH an error message and structured context (e.g. the
57
- // STAGE_REQUIREMENTS 422 carries lostReasonOptions) keep the context —
58
- // same principle as `structured` above, bounded against huge bodies.
59
- if (json && typeof json === "object" && json.error) {
60
- // Scalars first (code, revision), nested objects last: a 409 contact_changed also carries the whole current
61
- // `contact`, and the bound below may cut that, never the code or the current revision.
62
- const isScalar = (v) => v === null || typeof v !== "object";
63
- const entries = Object.entries(json).filter(([k]) => k !== "error");
64
- const extras = Object.fromEntries([
65
- ...entries.filter(([, v]) => isScalar(v)),
66
- ...entries.filter(([, v]) => !isScalar(v)),
67
- ]);
68
- if (Object.keys(extras).length > 0) {
69
- msg = `${msg} ${JSON.stringify(extras).slice(0, 1000)}`;
70
- }
71
- }
72
- throw new Error(`Conduyt API ${res.status}: ${msg}`);
98
+ throw formatHttpError(res, text);
73
99
  }
74
100
  return json;
75
101
  }
@@ -98,14 +124,8 @@ export class ConduytClient {
98
124
  body: body === undefined ? undefined : JSON.stringify(body),
99
125
  });
100
126
  const text = await res.text();
101
- if (!res.ok) {
102
- let msg = text.slice(0, 500);
103
- try {
104
- msg = JSON.parse(text).error ?? msg;
105
- }
106
- catch { /* keep the raw text */ }
107
- throw new Error(`Conduyt API ${res.status}: ${msg}`);
108
- }
127
+ if (!res.ok)
128
+ throw formatHttpError(res, text);
109
129
  return { text, filename: res.headers.get("x-report-filename"), contentType: res.headers.get("content-type") };
110
130
  }
111
131
  // Stream `ai chat`'s text/event-stream response to stdout as clean answer
@@ -127,10 +147,7 @@ export class ConduytClient {
127
147
  body: body !== undefined ? JSON.stringify(body) : undefined,
128
148
  });
129
149
  if (!res.ok || !res.body) {
130
- const text = await res.text();
131
- const json = text ? safeParse(text) : null;
132
- const msg = json?.error || res.statusText || `HTTP ${res.status}`;
133
- throw new Error(`Conduyt API ${res.status}: ${msg}`);
150
+ throw formatHttpError(res, await res.text());
134
151
  }
135
152
  const reader = res.body.getReader();
136
153
  const decoder = new TextDecoder();
package/dist/index.js CHANGED
@@ -536,6 +536,73 @@ deals
536
536
  const qs = usp.toString();
537
537
  return client.get(`/api/v1/deals/inspection${qs ? `?${qs}` : ""}`);
538
538
  }));
539
+ const HEALTH_FIX_REFUSALS = "Error line grammar: `Error: Conduyt API <status>: <message>[ [Retry-After: Ns]][ <one JSON object, the final token>]` (README, Error output grammar). Refusals exit 1 with the server's status, message and code printed verbatim, sent once (never retried): 409 deal_changed (the deal is no longer at --expected-updated-at; the error carries updatedAt and the deal's current health row: read it and choose again against row.updatedAt), 409 deal_check_cleared (the deal no longer fails that check), 400 health_fix_mismatch, 404";
540
+ deals
541
+ .command("health-fix <id>")
542
+ .description(`Pipeline health QUICK FIX for a deal row (PATCH /deals/{id} with checkOrigin). Send the check the row came from, --expected-updated-at (the deal's updatedAt as the health row carried it, REQUIRED) and ONLY that check's fix: stale = --stage-id (+ --expected-stage-id), unassigned = --assigned, missing_close_date = --close-date, missing_contact = --contact. Anything else is refused 400 health_fix_mismatch and nothing is read or written. For high_value_idle / needs_action use \`deals health-next-action\`. ${HEALTH_FIX_REFUSALS}; an uncoded 409 is a stage move by another writer, a stale --expected-stage-id or an in-flight publication`)
543
+ .requiredOption("--check <check>", "stale | unassigned | missing_close_date | missing_contact")
544
+ .requiredOption("--expected-updated-at <iso>", "the deal's updatedAt as the health row carried it (PipelineHealthDeal.updatedAt)")
545
+ .option("--stage-id <uuid>", "stale: the stage UUID to move the deal to")
546
+ .option("--expected-stage-id <uuid>", "stale: the stage the move was chosen against; 409 (nothing written) when the deal is no longer in it")
547
+ .option("--assigned <uuid>", "unassigned: the user (owner) UUID to assign")
548
+ .option("--close-date <date>", "missing_close_date: the expected close date (ISO 8601)")
549
+ .option("--contact <uuid>", "missing_contact: the contact UUID to link")
550
+ .action(run(async (client, id, opts) => {
551
+ const dealId = uuidOf(id, "deal id");
552
+ if (!["stale", "unassigned", "missing_close_date", "missing_contact"].includes(opts.check))
553
+ throw new Error("--check must be stale, unassigned, missing_close_date or missing_contact (for high_value_idle / needs_action use `deals health-next-action`).");
554
+ rejectBlankFlags([["--close-date", opts.closeDate]]);
555
+ const body = { checkOrigin: opts.check, expectedUpdatedAt: isoDate(opts.expectedUpdatedAt, "--expected-updated-at") };
556
+ if (opts.stageId !== undefined)
557
+ body.stageId = uuidOf(opts.stageId, "--stage-id");
558
+ if (opts.expectedStageId !== undefined)
559
+ body.expectedStageId = uuidOf(opts.expectedStageId, "--expected-stage-id");
560
+ if (opts.assigned !== undefined)
561
+ body.assignedTo = uuidOf(opts.assigned, "--assigned");
562
+ if (opts.closeDate !== undefined)
563
+ body.expectedCloseDate = opts.closeDate;
564
+ if (opts.contact !== undefined)
565
+ body.contactId = uuidOf(opts.contact, "--contact");
566
+ return client.patch(`/api/v1/deals/${encodeURIComponent(dealId)}`, body);
567
+ }));
568
+ deals
569
+ .command("health-next-action <id>")
570
+ .description(`Pipeline health QUICK FIX "Set next action" (POST /tasks with checkOrigin): creates a follow-up task on deal <id> from a High-value idle or Needs next action row. The task is created only while, under the deal's next-action lock, the deal is still at --expected-updated-at, still has no next action and still fails the check. Pass --idempotency-key (1-255 printable ASCII) so a retry after a lost response replays the first task; reuse it ONLY for the same request. The database allows at most one non-done quick-fix task per deal (a second is refused 409 deal_has_next_action); ordinary tasks (\`tasks create\`) are unconstrained and may coexist with or race a quick fix. ${HEALTH_FIX_REFUSALS.replace("409 deal_check_cleared", "409 deal_has_next_action (This deal already has a next action booked.), 409 deal_check_cleared")}; also an uncoded 409 (the contact was merged or deleted mid-request: retry against the surviving record) and the idempotency 409 idempotency_key_spent / 503 idempotency_claim_held / idempotency_outcome_unknown (verify the outcome before using a new key)`)
571
+ .requiredOption("--check <check>", "high_value_idle | needs_action")
572
+ .requiredOption("--expected-updated-at <iso>", "the deal's updatedAt as the health row carried it (sent as expectedDealUpdatedAt)")
573
+ .requiredOption("--title <title>", "task title (1-500 chars)")
574
+ .option("--description <text>", "description (max 5000)")
575
+ .option("--due <date>", "due date: YYYY-MM-DD is stored as noon UTC that day; a date-time as given")
576
+ .option("--priority <priority>", "low|medium|high|urgent (default medium)")
577
+ .option("--status <status>", "todo|in_progress|done (default todo)")
578
+ .option("--assigned <id>", "assignee user UUID, or @me / @owner (assigning to someone else needs tasks.assign)")
579
+ .option("--contact <uuid>", "link to contact UUID")
580
+ .option("--idempotency-key <key>", "sent as the Idempotency-Key header")
581
+ .action(run(async (client, id, opts) => {
582
+ const dealId = uuidOf(id, "deal id");
583
+ if (!["high_value_idle", "needs_action"].includes(opts.check))
584
+ throw new Error("--check must be high_value_idle or needs_action (for the other checks use `deals health-fix`).");
585
+ if (opts.title.trim() === "" || opts.title.length > 500)
586
+ throw new Error("--title must be 1 to 500 characters.");
587
+ if (opts.description !== undefined && opts.description.length > 5000)
588
+ throw new Error("--description must be at most 5000 characters.");
589
+ if (opts.idempotencyKey !== undefined && !/^[!-~](?:[ -~]{0,253}[!-~])?$/.test(opts.idempotencyKey))
590
+ throw new Error("--idempotency-key must be 1-255 printable ASCII characters, not blank.");
591
+ const body = { checkOrigin: opts.check, dealId, expectedDealUpdatedAt: isoDate(opts.expectedUpdatedAt, "--expected-updated-at"), title: opts.title };
592
+ if (opts.description !== undefined)
593
+ body.description = opts.description;
594
+ if (opts.due !== undefined)
595
+ body.dueDate = opts.due;
596
+ if (opts.priority !== undefined)
597
+ body.priority = opts.priority;
598
+ if (opts.status !== undefined)
599
+ body.status = opts.status;
600
+ if (opts.assigned !== undefined)
601
+ body.assignedTo = opts.assigned;
602
+ if (opts.contact !== undefined)
603
+ body.contactId = uuidOf(opts.contact, "--contact");
604
+ return client.post("/api/v1/tasks", body, opts.idempotencyKey !== undefined ? { headers: { "Idempotency-Key": opts.idempotencyKey } } : undefined);
605
+ }));
539
606
  // (BDA board 10-08) settings.dealCardFields: the fields on each kanban deal card, in order
540
607
  const cardFields = deals
541
608
  .command("card-fields")
@@ -1413,7 +1480,7 @@ tasks
1413
1480
  }));
1414
1481
  tasks
1415
1482
  .command("create")
1416
- .description("Create a task (--assigned accepts a user UUID, or the magic strings @me / @owner)")
1483
+ .description("Create a task (--assigned accepts a user UUID, or the magic strings @me / @owner). This is the ORDINARY task route: ordinary tasks are unconstrained and may coexist with or race a Pipeline health quick-fix task; the database allows at most one non-done quick-fix task per deal (a second is refused 409 deal_has_next_action). Create the quick fix itself with `deals health-next-action`. A 409 without a code means the contact was merged or deleted mid-request: retry against the surviving record")
1417
1484
  .option("--title <title>", "task title (required)")
1418
1485
  .option("--description <text>", "description")
1419
1486
  .option("--status <status>", "todo|in_progress|done (default todo)")
@@ -1447,7 +1514,7 @@ tasks
1447
1514
  }));
1448
1515
  tasks
1449
1516
  .command("update <id>")
1450
- .description("Update a task (PATCH — only the fields you pass change)")
1517
+ .description("Update a task (PATCH — only the fields you pass change). A 409 means nothing was written, printed verbatim with exit 1 and never retried: code deal_has_next_action (\"This deal already has a next action booked.\": re-opening or re-linking a quick-fix task would give the deal a second open one), or no code (the task changed while saving: read it again and retry)")
1451
1518
  .option("--title <title>", "task title")
1452
1519
  .option("--description <text>", "description")
1453
1520
  .option("--status <status>", "todo|in_progress|done")
@@ -3651,6 +3718,28 @@ replyCapture
3651
3718
  }));
3652
3719
  // ---- lifecycle (master lead status + intake deals) ----
3653
3720
  // ---- general settings writes (09-15) ----
3721
+ /** Split an error message into its prefix and its trailing details object (the grammar's final token): try each `{` from the
3722
+ * left until the rest parses as a JSON object. */
3723
+ function splitErrorDetails(message) {
3724
+ for (let i = message.indexOf("{"); i !== -1; i = message.indexOf("{", i + 1)) {
3725
+ try {
3726
+ const v = JSON.parse(message.slice(i));
3727
+ if (v && typeof v === "object" && !Array.isArray(v))
3728
+ return { prefix: message.slice(0, i).trimEnd(), details: v };
3729
+ }
3730
+ catch { /* next brace */ }
3731
+ }
3732
+ return { prefix: message, details: {} };
3733
+ }
3734
+ /** A settings conflict re-raised with a hint and the CURRENT settings: the original details object and the extra state are merged
3735
+ * into ONE final JSON object `{...details, hint, current}`, so nothing follows it. `current` is kept whole up to 20000 characters;
3736
+ * past that it is left out and `currentOmitted` says so (re-read with `settings get`). */
3737
+ function settingsConflictError(err, hint, current) {
3738
+ const { prefix, details } = splitErrorDetails(err.message);
3739
+ const whole = JSON.stringify(current ?? null);
3740
+ const extra = whole.length <= 20000 ? { hint, current: current ?? null } : { hint, currentOmitted: `${whole.length} characters; re-read the settings` };
3741
+ return new Error(`${prefix} ${JSON.stringify({ ...details, ...extra })}`);
3742
+ }
3654
3743
  // PATCH /api/v1/settings refuses a general-settings change without the settingsRevision the editor loaded
3655
3744
  // (400 expected_settings_revision_required) and answers 409 when another save landed first. Every general
3656
3745
  // write below reads the revision first and sends it; a 409 is re-raised with the current settings so the
@@ -3670,7 +3759,7 @@ async function patchGeneralSettings(client, body) {
3670
3759
  catch (err) {
3671
3760
  if (err instanceof Error && /^Conduyt API 409/.test(err.message)) {
3672
3761
  const current = await read();
3673
- throw new Error(`${err.message} — the account settings changed since revision ${expectedSettingsRevision ?? "?"} was read; re-run the command (current: ${JSON.stringify(current?.data ?? null).slice(0, 1500)})`);
3762
+ throw settingsConflictError(err, `the account settings changed since revision ${expectedSettingsRevision ?? "?"} was read; re-run the command`, current?.data);
3674
3763
  }
3675
3764
  throw err;
3676
3765
  }
@@ -3694,7 +3783,7 @@ async function patchGeneralSettingsMerged(client, build) {
3694
3783
  throw err;
3695
3784
  if (attempt === 1) {
3696
3785
  const current = await read();
3697
- throw new Error(`${err.message} — the account settings changed twice while this was being saved (last revision read ${revision ?? "?"}); re-run the command (current: ${JSON.stringify(current?.data ?? null).slice(0, 1500)})`);
3786
+ throw settingsConflictError(err, `the account settings changed twice while this was being saved (last revision read ${revision ?? "?"}); re-run the command`, current?.data);
3698
3787
  }
3699
3788
  }
3700
3789
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conduyt",
3
- "version": "1.65.0",
3
+ "version": "1.66.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",