@erdoai/cli 0.87.0 → 0.89.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.
Files changed (2) hide show
  1. package/dist/index.js +231 -0
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1094,6 +1094,33 @@ var ErdoClient = class {
1094
1094
  // that was never live (an upload merged into an existing table); file_type may be
1095
1095
  // absent when the stored file's type was never recorded. Read one back by passing
1096
1096
  // its id as revision_id to fetchDatasetContents.
1097
+ // ── leads ──────────────────────────────────────────────────────────────────
1098
+ // Permanent lead identity. A lead is addressed by its canonical_lead_id or by
1099
+ // the 22-character reference short enough for a link or an SMS; an id that has
1100
+ // since been merged away resolves to the lead it became, so an id read off an
1101
+ // old capture response keeps working.
1102
+ getLead(datasetSlug, lead) {
1103
+ return this.request(
1104
+ "GET",
1105
+ `/v1/datasets/${encodeURIComponent(datasetSlug)}/leads/${encodeURIComponent(lead)}`
1106
+ );
1107
+ }
1108
+ // The captures the platform refused to resolve: two leads sharing a contact
1109
+ // detail, or a submission whose email and phone point at different leads.
1110
+ listLeadMergeCandidates(datasetSlug, limit) {
1111
+ const qs = limit ? `?limit=${encodeURIComponent(String(limit))}` : "";
1112
+ return this.request("GET", `/v1/datasets/${encodeURIComponent(datasetSlug)}/lead-candidates${qs}`);
1113
+ }
1114
+ // Not reversible in practice: the absorbed lead's row values are combined into
1115
+ // the survivor's and its row is gone. It needs no idempotency key — the pair it
1116
+ // joins identifies it, so a retry answers "already_merged".
1117
+ mergeLeads(datasetSlug, survivor, input) {
1118
+ return this.request(
1119
+ "POST",
1120
+ `/v1/datasets/${encodeURIComponent(datasetSlug)}/leads/${encodeURIComponent(survivor)}/merge`,
1121
+ input
1122
+ );
1123
+ }
1097
1124
  listDatasetRevisions(slug) {
1098
1125
  return this.request("GET", `/v1/datasets/${encodeURIComponent(slug)}/revisions`);
1099
1126
  }
@@ -1126,6 +1153,23 @@ var ErdoClient = class {
1126
1153
  `/v1/datasets/${encodeURIComponent(slug)}/refresh/run`
1127
1154
  );
1128
1155
  }
1156
+ // --- lead identity ---
1157
+ // Preview or perform a dataset's permanent-lead-identity migration: give every
1158
+ // pipeline writer the producer contract, backfill the IDs, and publish the
1159
+ // identity config in one transaction.
1160
+ //
1161
+ // apply:false mutates nothing and is the way to read readiness — `ready`, the
1162
+ // `blockers` holding it up, and how many rows would be assigned an identity.
1163
+ // apply:true needs an operation_key: replaying the same key settles an apply
1164
+ // whose response was lost, while a different key against a migrated dataset is
1165
+ // refused rather than migrating twice.
1166
+ maintainLeadIdentity(slug, body) {
1167
+ return this.request(
1168
+ "POST",
1169
+ `/v1/datasets/${encodeURIComponent(slug)}/lead-identity/maintain`,
1170
+ body
1171
+ );
1172
+ }
1129
1173
  // --- bounded outreach ---
1130
1174
  putOutreachBatch(batch, body) {
1131
1175
  return this.request(
@@ -1750,6 +1794,16 @@ async function resolveSecretInput(options) {
1750
1794
  }
1751
1795
  return void 0;
1752
1796
  }
1797
+ async function promptLine(question) {
1798
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return "";
1799
+ const readline2 = await import("readline/promises");
1800
+ const rl = readline2.createInterface({ input: process.stdin, output: process.stdout });
1801
+ try {
1802
+ return await rl.question(question);
1803
+ } finally {
1804
+ rl.close();
1805
+ }
1806
+ }
1753
1807
 
1754
1808
  // src/integrations.ts
1755
1809
  function formatIntegrationListRow(i) {
@@ -5616,6 +5670,49 @@ datasetsCmd.command("set-purpose <slug> [purpose]").description(
5616
5670
  fail(e);
5617
5671
  }
5618
5672
  });
5673
+ var leadIdentityCmd = datasetsCmd.command("lead-identity").description(
5674
+ "Permanent lead identity \u2014 one lead is one row, however many ways they got in touch. Migrating a dataset gives every row a canonical_lead_id, points every pipeline writer at it, and makes the resolver the only thing that decides whether a submission is a new lead or an existing one."
5675
+ );
5676
+ leadIdentityCmd.command("maintain <slug>").description(
5677
+ "Preview or perform the migration for one dataset. Without --apply it mutates nothing and reports readiness: whether the dataset is ready, what blocks it, and how many rows would be assigned an identity. Run the preview first and read the blockers \u2014 apply refuses outright while any consumer is incompatible, unverifiable, queued or running, because the backfill rewrites the file every one of them reads."
5678
+ ).option(
5679
+ "--apply",
5680
+ "perform the migration rather than preview it; requires --operation-key"
5681
+ ).option(
5682
+ "--operation-key <key>",
5683
+ "the migration receipt. Replaying the same key settles an apply whose response was lost; a different key against an already-migrated dataset is refused rather than migrating it twice."
5684
+ ).option("--email-column <column>", "the dataset's email column (detected when omitted)").option("--phone-column <column>", "the dataset's phone column (detected when omitted)").option(
5685
+ "--conversion-id-column <column>",
5686
+ "optional conversion id column; must differ from both contact columns"
5687
+ ).option(
5688
+ "--default-calling-code <code>",
5689
+ "calling code a phone written in national form belongs to, so one number written two ways is one lead (default 1)"
5690
+ ).action(
5691
+ async (slug, opts) => {
5692
+ try {
5693
+ if (opts.apply && !opts.operationKey) {
5694
+ fail(
5695
+ new Error(
5696
+ "--apply requires --operation-key so an uncertain retry can settle rather than migrating twice"
5697
+ )
5698
+ );
5699
+ return;
5700
+ }
5701
+ print(
5702
+ await new ErdoClient().maintainLeadIdentity(slug, {
5703
+ apply: Boolean(opts.apply),
5704
+ operation_key: opts.operationKey,
5705
+ email_column: opts.emailColumn,
5706
+ phone_column: opts.phoneColumn,
5707
+ conversion_id_column: opts.conversionIdColumn,
5708
+ default_calling_code: opts.defaultCallingCode
5709
+ })
5710
+ );
5711
+ } catch (e) {
5712
+ fail(e);
5713
+ }
5714
+ }
5715
+ );
5619
5716
  datasetsCmd.command("query <slug> <question>").description(
5620
5717
  "Ask a natural-language question of a dataset \u2014 Erdo writes and runs the SQL, and answers with that SQL alongside the values. It runs an agent, so it is slower and two identical questions can produce two different queries: for a deterministic or scripted read, write the SQL yourself with `datasets fetch --sql`."
5621
5718
  ).action(async (slug, question) => {
@@ -6073,6 +6170,140 @@ filterCmd.command("list <slug>").description("List the default filters on a data
6073
6170
  fail(e);
6074
6171
  }
6075
6172
  });
6173
+ var leadsCmd = program.command("leads").description("Read leads in a lead dataset, and merge two that are the same person");
6174
+ leadsCmd.command("get <dataset> <lead>").description(
6175
+ "Read one lead: its permanent canonical_lead_id, the email and phone evidence bound to it, its dataset row, the leads merged into it, and its capture history. <lead> is either the canonical_lead_id UUID or the 22-character lead reference, and an id that has since been merged away resolves to the lead it became \u2014 so an id read off an old capture response still works."
6176
+ ).option("--json", "print the raw JSON result instead of a summary").action(async (dataset, lead, opts) => {
6177
+ try {
6178
+ const res = await new ErdoClient().getLead(dataset, lead);
6179
+ if (opts.json) {
6180
+ print(res);
6181
+ return;
6182
+ }
6183
+ printLead(res.lead);
6184
+ } catch (e) {
6185
+ fail(e);
6186
+ }
6187
+ });
6188
+ leadsCmd.command("candidates <dataset>").description(
6189
+ "List the captures the platform refused to resolve \u2014 where two leads share a contact detail, or a submission's email and phone point at different leads. The platform records the candidates instead of guessing, and this is the queue a person drains with `leads merge`."
6190
+ ).option("-l, --limit <n>", "maximum captures to return (default 50, maximum 200)", (v) => parseInt(v, 10)).option("--json", "print the raw JSON result instead of a table").action(async (dataset, opts) => {
6191
+ try {
6192
+ const res = await new ErdoClient().listLeadMergeCandidates(dataset, opts.limit);
6193
+ if (opts.json) {
6194
+ print(res);
6195
+ return;
6196
+ }
6197
+ const candidates = res.candidates ?? [];
6198
+ if (!res.lead_identity_enabled) {
6199
+ console.log(
6200
+ `${res.dataset_slug} does not have permanent lead identity, so nothing has been checked for duplicates. Migrate the dataset first.`
6201
+ );
6202
+ return;
6203
+ }
6204
+ if (candidates.length === 0) {
6205
+ console.log(`${res.dataset_slug} has no unresolved lead captures.`);
6206
+ return;
6207
+ }
6208
+ const rows = [];
6209
+ for (const item of candidates) {
6210
+ for (const candidate of item.candidates ?? []) {
6211
+ rows.push([
6212
+ item.capture.created_at,
6213
+ item.capture.resolution,
6214
+ candidate.canonical_lead_id,
6215
+ (candidate.emails ?? []).join(" "),
6216
+ (candidate.phones ?? []).join(" "),
6217
+ candidate.merged_into ? `merged into ${candidate.merged_into}` : ""
6218
+ ]);
6219
+ }
6220
+ }
6221
+ printAlignedTable(["captured", "why", "lead", "emails", "phones", "settled"], rows);
6222
+ console.log(
6223
+ `
6224
+ Merge two of them with: erdo leads merge ${res.dataset_slug} <survivor> --absorb <lead>`
6225
+ );
6226
+ } catch (e) {
6227
+ fail(e);
6228
+ }
6229
+ });
6230
+ leadsCmd.command("merge <dataset> <survivor>").description(
6231
+ "Merge two leads into one. The absorbed lead stops being its own lead: its contact evidence moves onto the survivor, and the two dataset rows become one row \u2014 a value the absorbed row carries fills a blank in the survivor's row, and never replaces one the survivor already holds unless you name the column with --overwrite-column. This cannot be undone: the absorbed row's values are combined away and only the pointer survives, so an id already handed out keeps resolving. Read both leads with `leads get` before running it."
6232
+ ).requiredOption("--absorb <lead>", "the lead that stops being its own lead (UUID or lead reference)").option(
6233
+ "--overwrite-column <name>",
6234
+ "a column where the absorbed lead's value wins over one the survivor already holds; repeatable",
6235
+ (value, previous) => [...previous, value],
6236
+ []
6237
+ ).option("-y, --yes", "skip the confirmation prompt").option("--json", "print the raw JSON result instead of a summary").action(
6238
+ async (dataset, survivor, opts) => {
6239
+ try {
6240
+ if (!opts.yes) {
6241
+ const answer = await promptLine(
6242
+ `Merge ${opts.absorb} into ${survivor} in ${dataset}? The absorbed lead's row is combined away and this cannot be undone. Type "merge" to continue: `
6243
+ );
6244
+ if (answer.trim() !== "merge") {
6245
+ console.error("Not merged.");
6246
+ process.exit(1);
6247
+ }
6248
+ }
6249
+ const res = await new ErdoClient().mergeLeads(dataset, survivor, {
6250
+ absorb_lead_id: opts.absorb,
6251
+ overwrite_columns: opts.overwriteColumn.length ? opts.overwriteColumn : void 0
6252
+ });
6253
+ if (opts.json) {
6254
+ print(res);
6255
+ return;
6256
+ }
6257
+ if (res.state === "already_merged") {
6258
+ console.log(`Already one lead: ${res.absorbed_lead_id} resolves to ${res.lead.canonical_lead_id}.`);
6259
+ } else {
6260
+ console.log(
6261
+ `Merged ${res.absorbed_lead_id} into ${res.lead.canonical_lead_id} \u2014 ${res.aliases_moved} contact ${res.aliases_moved === 1 ? "alias" : "aliases"} moved, ${res.rows_combined} ${res.rows_combined === 1 ? "row" : "rows"} combined.`
6262
+ );
6263
+ }
6264
+ printLead(res.lead);
6265
+ } catch (e) {
6266
+ fail(e);
6267
+ }
6268
+ }
6269
+ );
6270
+ function printLead(lead) {
6271
+ console.log(`lead ${lead.canonical_lead_id}`);
6272
+ console.log(`reference ${lead.reference}`);
6273
+ console.log(`dataset ${lead.dataset_slug}`);
6274
+ if (lead.requested_lead_id && lead.requested_lead_id !== lead.canonical_lead_id) {
6275
+ console.log(`asked for ${lead.requested_lead_id} (merged into this lead)`);
6276
+ }
6277
+ if (lead.merged_into) {
6278
+ console.log(`merged into ${lead.merged_into}${lead.merged_at ? ` on ${lead.merged_at}` : ""}`);
6279
+ }
6280
+ if (lead.absorbed_lead_ids?.length) {
6281
+ console.log(`absorbed ${lead.absorbed_lead_ids.join(", ")}`);
6282
+ }
6283
+ if (lead.aliases?.length) {
6284
+ console.log("\ncontact evidence");
6285
+ printAlignedTable(
6286
+ ["type", "value", "from"],
6287
+ lead.aliases.map((a) => [a.type, a.value, a.provenance])
6288
+ );
6289
+ }
6290
+ if (lead.captures?.length) {
6291
+ console.log("\ncaptures");
6292
+ printAlignedTable(
6293
+ ["when", "producer", "resolution", "conversion"],
6294
+ lead.captures.map((c) => [c.created_at, c.producer, c.resolution, c.conversion_status])
6295
+ );
6296
+ }
6297
+ if (lead.row) {
6298
+ console.log("\nrow");
6299
+ printAlignedTable(
6300
+ ["column", "value"],
6301
+ Object.keys(lead.row).sort().map((column) => [column, lead.row?.[column] ?? ""])
6302
+ );
6303
+ } else {
6304
+ console.log("\nThis lead holds no dataset row \u2014 its values were combined into the lead it was merged with.");
6305
+ }
6306
+ }
6076
6307
  var integrationsCmd = program.command("integrations").description("Connect and inspect integrations");
6077
6308
  integrationsCmd.command("list").description("List connected integrations \u2014 app, status, auth type, name, then the connection's id (what --from-connection takes)").action(async () => {
6078
6309
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@erdoai/cli",
3
- "version": "0.87.0",
3
+ "version": "0.89.0",
4
4
  "description": "Erdo CLI — drive datasets, pages, and evals from the terminal or CI",
5
5
  "type": "module",
6
6
  "bin": {