@erdoai/cli 0.91.0 → 0.98.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 +402 -57
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -312,6 +312,19 @@ var ErdoClient = class {
312
312
  createManagerKey() {
313
313
  return this.request("POST", "/v1/manager-key");
314
314
  }
315
+ // Daily paid-media and lead performance for one organization, or for every
316
+ // client org this account manages when organization_slug is omitted.
317
+ managerPerformance(params) {
318
+ const qs = new URLSearchParams();
319
+ if (params.organization_slug) qs.set("organization_slug", params.organization_slug);
320
+ if (params.from) qs.set("from", params.from);
321
+ if (params.to) qs.set("to", params.to);
322
+ const q = qs.toString();
323
+ return this.request(
324
+ "GET",
325
+ `/v1/managed-organization-performance${q ? `?${q}` : ""}`
326
+ );
327
+ }
315
328
  // Brings an EXISTING org under your manager org. Two consents redeem here: a
316
329
  // one-time token minted by the target org's owner (or, for an ownerless managed
317
330
  // org, its current manager) — a manager credential on its own can never adopt an
@@ -464,6 +477,8 @@ var ErdoClient = class {
464
477
  const q = new URLSearchParams();
465
478
  if (params?.domain) q.set("domain", params.domain);
466
479
  if (params?.limit) q.set("limit", String(params.limit));
480
+ if (params?.leadRef) q.set("lead_ref", params.leadRef);
481
+ if (params?.dataset) q.set("dataset", params.dataset);
467
482
  const qs = q.toString();
468
483
  return this.request(
469
484
  "GET",
@@ -604,6 +619,16 @@ var ErdoClient = class {
604
619
  { enabled }
605
620
  );
606
621
  }
622
+ // A separate switch from the text one, on the same number: the two are
623
+ // separate permissions, so one endpoint per channel rather than a channel
624
+ // argument that could move the wrong one.
625
+ setVoiceAgentWhatsAppReplies(slug, enabled) {
626
+ return this.request(
627
+ "POST",
628
+ `/v1/voice/agents/${encodeURIComponent(slug)}/whatsapp-replies`,
629
+ { enabled }
630
+ );
631
+ }
607
632
  // Run a read-only HogQL query against the org's page-analytics events. Rows are
608
633
  // positional per columns; enabled:false means page analytics is off for the org
609
634
  // (not zero traffic). A rejected query surfaces PostHog's message as the error.
@@ -884,6 +909,9 @@ var ErdoClient = class {
884
909
  for (const s of params?.statuses ?? []) q.append("statuses", s);
885
910
  if (params?.engine_actions_only) q.set("engine_actions_only", "true");
886
911
  if (params?.item_id) q.set("item_id", params.item_id);
912
+ if (params?.organization_slug) q.set("organization_slug", params.organization_slug);
913
+ if (params?.subject_kind) q.set("subject_kind", params.subject_kind);
914
+ if (params?.subject_id) q.set("subject_id", params.subject_id);
887
915
  if (params?.limit) q.set("limit", String(params.limit));
888
916
  if (params?.offset) q.set("offset", String(params.offset));
889
917
  const qs = q.toString();
@@ -1982,6 +2010,29 @@ function timedOutMessage(threadID) {
1982
2010
  function print(value) {
1983
2011
  console.log(JSON.stringify(value, null, 2));
1984
2012
  }
2013
+ function formatNumber(n) {
2014
+ if (!Number.isFinite(n)) return String(n);
2015
+ if (Number.isInteger(n)) return n.toLocaleString("en-US");
2016
+ if (Math.abs(n) < 1e-6) {
2017
+ return n.toFixed(10).replace(/0+$/, "").replace(/\.$/, "");
2018
+ }
2019
+ return n.toLocaleString("en-US", { maximumFractionDigits: 6 });
2020
+ }
2021
+ function formatDate(iso) {
2022
+ if (!iso) return void 0;
2023
+ const d = new Date(iso);
2024
+ if (isNaN(d.getTime())) return iso;
2025
+ return `${d.toISOString().slice(0, 16).replace("T", " ")} UTC`;
2026
+ }
2027
+ function compactJSON(value) {
2028
+ if (value === void 0 || value === null) return void 0;
2029
+ try {
2030
+ const s = JSON.stringify(value);
2031
+ return s === "null" ? void 0 : s;
2032
+ } catch {
2033
+ return String(value);
2034
+ }
2035
+ }
1985
2036
  async function readAllStdin() {
1986
2037
  if (process.stdin.isTTY) {
1987
2038
  throw new Error("nothing is piped to standard input");
@@ -2030,7 +2081,11 @@ function printPageReview(review) {
2030
2081
  }
2031
2082
  }
2032
2083
  function printAlignedTable(columns, rows) {
2033
- const cell = (v) => v === null || v === void 0 ? "" : typeof v === "object" ? JSON.stringify(v) : String(v);
2084
+ const cell = (v) => {
2085
+ if (v === null || v === void 0) return "";
2086
+ if (typeof v === "object") return JSON.stringify(v);
2087
+ return String(v);
2088
+ };
2034
2089
  const widths = columns.map((c, i) => Math.max(c.length, ...rows.map((r) => cell(r[i]).length), 0));
2035
2090
  const line = (cells) => cells.map((c, i) => c.padEnd(widths[i])).join(" ").trimEnd();
2036
2091
  console.log(line(columns));
@@ -2045,7 +2100,10 @@ var collect = (v, acc) => {
2045
2100
  function summariseResults(results, cases = []) {
2046
2101
  const nameByID = new Map(cases.map((c) => [c.id, c.name]));
2047
2102
  for (const r of results) {
2048
- const status = r.agent_error ? "ERROR" : r.passed ? "PASS" : "FAIL";
2103
+ let status;
2104
+ if (r.agent_error) status = "ERROR";
2105
+ else if (r.passed) status = "PASS";
2106
+ else status = "FAIL";
2049
2107
  const name = nameByID.get(r.case_id) ?? r.case_id;
2050
2108
  console.log(` [${status}] ${r.score.toFixed(2)} ${name}`);
2051
2109
  if (r.agent_error) console.log(` agent error: ${r.agent_error}`);
@@ -2273,6 +2331,7 @@ function printBusinessProfile(profile) {
2273
2331
  ["industry", profile.industry],
2274
2332
  ["privacy policy", profile.privacy_policy_url],
2275
2333
  ["terms", profile.terms_and_conditions_url],
2334
+ ["example landing page", profile.example_landing_page_url],
2276
2335
  ["representative", `${profile.representative_first_name} ${profile.representative_last_name}`.trim()],
2277
2336
  ["title", profile.representative_title],
2278
2337
  ["job position", profile.representative_job_position],
@@ -2324,7 +2383,10 @@ businessProfileCmd.command("set").description("Save the organization's business
2324
2383
  ).option(
2325
2384
  "--tax-id-stdin",
2326
2385
  "read the tax id from standard input instead of prompting, for a script \u2014 e.g. `pass show ein | erdo org business-profile set --tax-id-stdin`"
2327
- ).option("--street <street>", "street address of the registered business").option("--city <city>", "city of the registered business").option("--region <region>", "state or province, e.g. FL").option("--postal-code <code>", "postal or ZIP code").option("--country <iso2>", "two-letter ISO country code, e.g. US").option("--website-url <url>", "the business's own website \u2014 carriers check that it describes it").option("--industry <industry>", "one of the industries listed by: erdo org business-profile get --json").option("--privacy-policy-url <url>", "the business's own privacy policy page (https) \u2014 a campaign without one is rejected").option("--terms-url <url>", "the business's own terms and conditions page (https) \u2014 required for the same reason").option("--first-name <name>", "given name of the person carriers may contact").option("--last-name <name>", "family name of that person").option("--title <title>", "their job title in the business's own words, e.g. Managing Partner").option(
2386
+ ).option("--street <street>", "street address of the registered business").option("--city <city>", "city of the registered business").option("--region <region>", "state or province, e.g. FL").option("--postal-code <code>", "postal or ZIP code").option("--country <iso2>", "two-letter ISO country code, e.g. US").option("--website-url <url>", "the business's own website \u2014 carriers check that it describes it").option("--industry <industry>", "one of the industries listed by: erdo org business-profile get --json").option("--privacy-policy-url <url>", "the business's own privacy policy page (https) \u2014 a campaign without one is rejected").option("--terms-url <url>", "the business's own terms and conditions page (https) \u2014 required for the same reason").option(
2387
+ "--example-landing-page-url <url>",
2388
+ "a public landing page (https) where people opt in to texts through the form's SMS consent checkbox and that shows your phone number \u2014 the opt-in the campaign reviewer opens"
2389
+ ).option("--first-name <name>", "given name of the person carriers may contact").option("--last-name <name>", "family name of that person").option("--title <title>", "their job title in the business's own words, e.g. Managing Partner").option(
2328
2390
  "--job-position <position>",
2329
2391
  "their role from the fixed list (ceo, cfo, director, general_counsel, gm, vp, other)"
2330
2392
  ).option("--email <email>", "their email address").option("--phone <number>", "their phone number in E.164, e.g. +13055550123").option("--json", "print the raw JSON result instead of a table").action(
@@ -2362,6 +2424,7 @@ businessProfileCmd.command("set").description("Save the organization's business
2362
2424
  industry: opts.industry ?? stored.industry,
2363
2425
  privacy_policy_url: opts.privacyPolicyUrl ?? stored.privacy_policy_url,
2364
2426
  terms_and_conditions_url: opts.termsUrl ?? stored.terms_and_conditions_url,
2427
+ example_landing_page_url: opts.exampleLandingPageUrl ?? stored.example_landing_page_url ?? "",
2365
2428
  representative_first_name: opts.firstName ?? stored.representative_first_name,
2366
2429
  representative_last_name: opts.lastName ?? stored.representative_last_name,
2367
2430
  representative_title: opts.title ?? stored.representative_title,
@@ -2522,9 +2585,11 @@ managed.command("ads-container <orgSlug>").description(
2522
2585
  timeZone: opts.timeZone,
2523
2586
  replaceCurrentAccount: opts.replaceCurrentAccount
2524
2587
  });
2525
- console.log(
2526
- c.already_provisioned ? `Google Ads container already existed for ${c.org_slug}` : c.adopted ? `Adopted Google Ads account ${c.customer_id} as the container for ${c.org_slug}` : `Created Google Ads container for ${c.org_slug}`
2527
- );
2588
+ let outcome;
2589
+ if (c.already_provisioned) outcome = `Google Ads container already existed for ${c.org_slug}`;
2590
+ else if (c.adopted) outcome = `Adopted Google Ads account ${c.customer_id} as the container for ${c.org_slug}`;
2591
+ else outcome = `Created Google Ads container for ${c.org_slug}`;
2592
+ console.log(outcome);
2528
2593
  printAdsContainer(c);
2529
2594
  const leftBehind = c.previous_campaign_count ?? 0;
2530
2595
  if (c.previous_customer_id && leftBehind > 0) {
@@ -2583,6 +2648,90 @@ This key operates every org you manage; target one with X-Organization-ID (or \`
2583
2648
  fail(e);
2584
2649
  }
2585
2650
  });
2651
+ managed.command("performance").description(
2652
+ "Daily spend, traffic and captured leads for the client orgs you manage \u2014 one organization with --organization, otherwise every client. A metric whose source dataset is missing or unreadable prints as '-' and is explained under coverage; it is never reported as zero."
2653
+ ).option("--organization <slug>", "read one organization instead of every client org").option("--from <YYYY-MM-DD>", "first day to include (inclusive); defaults to 89 days before --to").option("--to <YYYY-MM-DD>", "last day to include (inclusive); defaults to today (UTC)").option("--daily", "one row per organization per day instead of window totals").option("--json", "print the raw JSON result instead of a table").action(
2654
+ async (opts) => {
2655
+ try {
2656
+ const res = await new ErdoClient().managerPerformance({
2657
+ organization_slug: opts.organization,
2658
+ from: opts.from,
2659
+ to: opts.to
2660
+ });
2661
+ if (opts.json) {
2662
+ print(res);
2663
+ return;
2664
+ }
2665
+ if (res.organizations.length === 0) {
2666
+ console.log(res.message || "No organizations to read.");
2667
+ return;
2668
+ }
2669
+ const cell = (v, digits = 0) => v === null ? "-" : v.toFixed(digits);
2670
+ const total = (days, pick) => {
2671
+ let sum = 0;
2672
+ for (const day of days) {
2673
+ const v = pick(day);
2674
+ if (v === null) return null;
2675
+ sum += v;
2676
+ }
2677
+ return sum;
2678
+ };
2679
+ if (opts.daily) {
2680
+ const rows = [];
2681
+ for (const org2 of res.organizations) {
2682
+ for (const day of org2.days) {
2683
+ rows.push([
2684
+ org2.organization_slug,
2685
+ day.date,
2686
+ cell(day.spend, 2),
2687
+ cell(day.clicks),
2688
+ cell(day.visits),
2689
+ cell(day.form_starts),
2690
+ cell(day.leads_on_page),
2691
+ cell(day.leads_captured)
2692
+ ]);
2693
+ }
2694
+ }
2695
+ printAlignedTable(
2696
+ ["organization", "date", "spend", "clicks", "visits", "form starts", "page leads", "leads"],
2697
+ rows
2698
+ );
2699
+ } else {
2700
+ printAlignedTable(
2701
+ ["organization", "spend", "clicks", "visits", "form starts", "page leads", "leads", "cost/lead"],
2702
+ res.organizations.map((org2) => {
2703
+ const spend = total(org2.days, (d) => d.spend);
2704
+ const leads = total(org2.days, (d) => d.leads_captured);
2705
+ const costPerLead = spend === null || leads === null || leads === 0 ? null : spend / leads;
2706
+ return [
2707
+ org2.organization_slug,
2708
+ cell(spend, 2),
2709
+ cell(total(org2.days, (d) => d.clicks)),
2710
+ cell(total(org2.days, (d) => d.visits)),
2711
+ cell(total(org2.days, (d) => d.form_starts)),
2712
+ cell(total(org2.days, (d) => d.leads_on_page)),
2713
+ cell(leads, 0),
2714
+ cell(costPerLead, 2)
2715
+ ];
2716
+ })
2717
+ );
2718
+ }
2719
+ process.stderr.write(`
2720
+ ${res.from} to ${res.to} (${res.timezone} days)
2721
+ `);
2722
+ for (const org2 of res.organizations) {
2723
+ for (const gap of org2.coverage) {
2724
+ process.stderr.write(
2725
+ `${org2.organization_slug}: ${gap.metrics.join(", ")} unavailable \u2014 ${gap.reason}
2726
+ `
2727
+ );
2728
+ }
2729
+ }
2730
+ } catch (e) {
2731
+ fail(e);
2732
+ }
2733
+ }
2734
+ );
2586
2735
  var tokenCmd = program.command("token").description("Manage your API tokens (account-level credentials)");
2587
2736
  tokenCmd.command("create").description("Mint a new API token; the secret is printed ONCE").requiredOption("--name <name>", "a label for the token").option("--expires-days <n>", "days until expiry (default: 30)", (v) => parseInt(v, 10)).addOption(
2588
2737
  orgOption("the token's default org (defaults to your active org; must be one you belong to)")
@@ -2871,7 +3020,7 @@ function splitVariantSpec(spec) {
2871
3020
  const key = eq > 0 ? trimmed.slice(0, eq).trim() : "";
2872
3021
  const startsToken = trimmed === "control" || trimmed === "is_control" || key !== "" && VARIANT_SPEC_KEYS.has(key);
2873
3022
  if (!startsToken && parts.length > 0 && COMMA_SAFE_VARIANT_KEYS.has(lastKey)) {
2874
- parts[parts.length - 1] += "," + raw;
3023
+ parts[parts.length - 1] += `,${raw}`;
2875
3024
  continue;
2876
3025
  }
2877
3026
  parts.push(raw);
@@ -3381,9 +3530,13 @@ ${"Case".padEnd(34)}${res.models.map(modelCol).join("")}`);
3381
3530
  console.log(`
3382
3531
  ${"Summary (avg of all cases)".padEnd(34)}${res.models.map(modelCol).join("")}`);
3383
3532
  for (const stat of ["score", "pass", "cost", "secs"]) {
3384
- let line = `${" " + stat}`.padEnd(34);
3533
+ let line = ` ${stat}`.padEnd(34);
3385
3534
  for (const s of res.summary) {
3386
- const v = stat === "score" ? s.avg_score.toFixed(2) : stat === "pass" ? `${Math.round(s.pass_rate * 100)}%` : stat === "cost" ? `$${(s.avg_cost_millicents / 1e5).toFixed(3)}/case` : `${(s.avg_duration_ms / 1e3).toFixed(0)}s`;
3535
+ let v;
3536
+ if (stat === "score") v = s.avg_score.toFixed(2);
3537
+ else if (stat === "pass") v = `${Math.round(s.pass_rate * 100)}%`;
3538
+ else if (stat === "cost") v = `$${(s.avg_cost_millicents / 1e5).toFixed(3)}/case`;
3539
+ else v = `${(s.avg_duration_ms / 1e3).toFixed(0)}s`;
3387
3540
  line += v.padEnd(18);
3388
3541
  }
3389
3542
  console.log(line);
@@ -3731,9 +3884,11 @@ function printRunDetail(detail, wantResources, wantSteps) {
3731
3884
  group.set(key, entry);
3732
3885
  byKind.set(kind, group);
3733
3886
  }
3734
- const kinds = [...byKind.keys()].sort(
3735
- (a, b) => a === "skill" ? -1 : b === "skill" ? 1 : a.localeCompare(b)
3736
- );
3887
+ const kinds = [...byKind.keys()].sort((a, b) => {
3888
+ if (a === "skill") return -1;
3889
+ if (b === "skill") return 1;
3890
+ return a.localeCompare(b);
3891
+ });
3737
3892
  for (const kind of kinds) {
3738
3893
  console.log(` ${kind}`);
3739
3894
  for (const entry of byKind.get(kind).values()) {
@@ -3811,6 +3966,46 @@ function decisionLine(d) {
3811
3966
  }
3812
3967
  return parts.join(" ");
3813
3968
  }
3969
+ function renderApprovalCard(approval) {
3970
+ console.log("");
3971
+ console.log("Approval:");
3972
+ const headline = approval.action_display || approval.action_headline;
3973
+ if (headline) console.log(` ${headline}`);
3974
+ if (approval.action_context) console.log(` ${approval.action_context}`);
3975
+ for (const item of approval.items ?? []) {
3976
+ const params = item.params ? Object.entries(item.params).map(([k, v]) => `${k}=${v}`).join(", ") : "";
3977
+ console.log(` - ${item.what}${params ? ` (${params})` : ""}`);
3978
+ if (item.why) console.log(` why: ${item.why}`);
3979
+ }
3980
+ if (approval.omitted_items) console.log(` and ${approval.omitted_items} more`);
3981
+ if (approval.occurrence_count && approval.occurrence_count > 1) {
3982
+ console.log(` proposed ${approval.occurrence_count}x`);
3983
+ }
3984
+ }
3985
+ function renderReportEffect(e) {
3986
+ if (e.measurability !== "measurable") {
3987
+ const reason = e.unmeasurable_reason ? `: ${e.unmeasurable_reason}` : "";
3988
+ console.log(` ${e.metric || e.measurability} \u2014 ${e.measurability}${reason}`);
3989
+ return;
3990
+ }
3991
+ const predicateValue = e.target_value ?? e.min_delta;
3992
+ const predicate = [
3993
+ e.metric,
3994
+ e.success_operator,
3995
+ predicateValue !== void 0 && predicateValue !== null ? formatNumber(predicateValue) : void 0
3996
+ ].filter((p) => p !== void 0 && p !== null && p !== "").join(" ");
3997
+ const windows = [];
3998
+ if (e.baseline_window_days) windows.push(`baseline ${e.baseline_window_days}d`);
3999
+ if (e.outcome_window_days) windows.push(`outcome ${e.outcome_window_days}d`);
4000
+ const windowStr = windows.length ? ` (${windows.join(", ")})` : "";
4001
+ console.log(` ${predicate || e.metric || "effect"}${windowStr} \u2014 ${e.status}`);
4002
+ if (e.baseline_value !== void 0 && e.baseline_value !== null && e.outcome_value !== void 0 && e.outcome_value !== null) {
4003
+ const label = e.outcome_label ? ` (${e.outcome_label})` : "";
4004
+ console.log(` measured: ${formatNumber(e.baseline_value)} \u2192 ${formatNumber(e.outcome_value)}${label}`);
4005
+ } else if (e.outcome_label) {
4006
+ console.log(` outcome: ${e.outcome_label}`);
4007
+ }
4008
+ }
3814
4009
  decisionsCmd.command("list").description("List decisions, newest first").option("--workstream <slug>", "only decisions filed under this workstream/Strategy").option(
3815
4010
  "--source <source>",
3816
4011
  "producer family: approval | escalation | engine_gate | allocator | experiment | workstream_commitment"
@@ -3870,6 +4065,7 @@ function renderDecision(detail) {
3870
4065
  console.log("");
3871
4066
  console.log(`Why: ${d.why}`);
3872
4067
  }
4068
+ if (d.alternatives_considered) console.log(`Alternatives considered: ${d.alternatives_considered}`);
3873
4069
  if (d.decider_rationale) console.log(`Decider said: ${d.decider_rationale}`);
3874
4070
  if (detail.actions.length) {
3875
4071
  console.log("");
@@ -3879,6 +4075,12 @@ function renderDecision(detail) {
3879
4075
  if (a.subject_label) console.log(` subject: ${a.subject_label}`);
3880
4076
  if (a.effective_at) console.log(` effective: ${a.effective_at}`);
3881
4077
  if (a.result_summary) console.log(` result: ${a.result_summary}`);
4078
+ if (a.annotations && Object.keys(a.annotations).length) {
4079
+ console.log(" before:");
4080
+ for (const [k, v] of Object.entries(a.annotations)) {
4081
+ console.log(` ${k}: ${compactJSON(v) ?? "null"}`);
4082
+ }
4083
+ }
3882
4084
  });
3883
4085
  }
3884
4086
  if (detail.effects.length) {
@@ -3896,13 +4098,28 @@ function renderDecision(detail) {
3896
4098
  if (e.unmeasurable_reason) console.log(` reason: ${e.unmeasurable_reason}`);
3897
4099
  });
3898
4100
  }
3899
- if (detail.lineage.supersedes_slug || detail.lineage.superseded_by_slugs?.length) {
4101
+ const lineage = detail.lineage;
4102
+ if (lineage.supersedes_slug || lineage.superseded_by_slugs?.length || lineage.workstream_slug || lineage.attention_item_slug) {
3900
4103
  console.log("");
3901
4104
  console.log("Lineage:");
3902
- if (detail.lineage.supersedes_slug) console.log(` replaced ${detail.lineage.supersedes_slug}`);
3903
- for (const slug of detail.lineage.superseded_by_slugs ?? []) {
4105
+ if (lineage.supersedes_slug) console.log(` replaced ${lineage.supersedes_slug}`);
4106
+ for (const slug of lineage.superseded_by_slugs ?? []) {
3904
4107
  console.log(` replaced by ${slug}`);
3905
4108
  }
4109
+ if (lineage.workstream_slug) {
4110
+ console.log(` workstream: ${lineage.workstream_title || lineage.workstream_slug} (${lineage.workstream_slug})`);
4111
+ }
4112
+ if (lineage.attention_item_slug) {
4113
+ console.log(
4114
+ ` raised by: ${lineage.attention_item_title || lineage.attention_item_slug} (${lineage.attention_item_slug})`
4115
+ );
4116
+ }
4117
+ }
4118
+ if (lineage.approval) renderApprovalCard(lineage.approval);
4119
+ else if (lineage.approval_unreadable) {
4120
+ console.log("");
4121
+ console.log("Approval:");
4122
+ console.log(" The approval card could not be loaded.");
3906
4123
  }
3907
4124
  }
3908
4125
  decisionsCmd.command("show <slug>").description(
@@ -4176,9 +4393,7 @@ approvalsCmd.command("settings [mode]").description(
4176
4393
  try {
4177
4394
  const client = new ErdoClient();
4178
4395
  let gates;
4179
- if (opts.gates !== void 0) {
4180
- gates = opts.gates.split(",").map((g) => g.trim()).filter(Boolean);
4181
- } else {
4396
+ if (opts.gates === void 0) {
4182
4397
  switch (mode) {
4183
4398
  case void 0:
4184
4399
  break;
@@ -4195,6 +4410,8 @@ approvalsCmd.command("settings [mode]").description(
4195
4410
  default:
4196
4411
  throw new Error(`unknown mode ${mode} \u2014 use safe | all | reset, or --gates spend,destructive`);
4197
4412
  }
4413
+ } else {
4414
+ gates = opts.gates.split(",").map((g) => g.trim()).filter(Boolean);
4198
4415
  }
4199
4416
  if (gates !== void 0) {
4200
4417
  await client.setApprovalSettings(gates);
@@ -4212,19 +4429,100 @@ approvalsCmd.command("settings [mode]").description(
4212
4429
  }
4213
4430
  });
4214
4431
  var attnCmd = program.command("attention").description("The attention feed \u2014 digests, choices, escalations awaiting a human");
4215
- attnCmd.command("list").description("List attention items").option("--status <status...>", "open | answered | dismissed | expired").option("--open", "shorthand for --status open").option("--engine-actions", "only engine-generated items").option("--item <idOrSlug>", "read one item: its slug or its uuid").option("-n, --limit <n>", "max items", (v) => parseInt(v, 10)).option("--offset <n>", "pagination offset", (v) => parseInt(v, 10)).action(
4432
+ function attentionItemLine(item) {
4433
+ return [item.slug, item.severity, item.kind, item.status, item.title].join(" ");
4434
+ }
4435
+ function renderAttentionItemReport(item, report) {
4436
+ console.log("");
4437
+ console.log("Safe default:");
4438
+ const expires = formatDate(item.expires_at);
4439
+ if (report.proposed_answer) {
4440
+ if (!expires) {
4441
+ console.log(` Erdo proposes: ${report.proposed_answer}`);
4442
+ console.log(" No deadline \u2014 nothing applies until somebody answers");
4443
+ } else if (report.safe_default_approves) {
4444
+ console.log(` If nobody answers: ${report.proposed_answer}`);
4445
+ console.log(` Applies ${expires}`);
4446
+ } else {
4447
+ console.log(` Erdo proposes: ${report.proposed_answer}`);
4448
+ console.log(` If nobody answers, Erdo will NOT do this \u2014 the item closes ${expires}`);
4449
+ }
4450
+ } else {
4451
+ console.log(expires ? ` Closes unanswered ${expires}` : " Does not expire");
4452
+ }
4453
+ if (report.has_pending_action) console.log(" Holds a pending engine action");
4454
+ const safeDefault = compactJSON(report.safe_default);
4455
+ if (safeDefault) console.log(` Default answer: ${safeDefault}`);
4456
+ if (report.workstream_slug || report.workstream_title) {
4457
+ console.log("");
4458
+ console.log("Workstream:");
4459
+ console.log(` ${report.workstream_title || report.workstream_slug} (${report.workstream_slug})`);
4460
+ }
4461
+ if (report.decision_slug) {
4462
+ console.log("");
4463
+ console.log("Decision:");
4464
+ if (report.decision) {
4465
+ const d = report.decision;
4466
+ console.log(` ${d.slug} \u2014 ${d.what}`);
4467
+ if (d.why) console.log(` why: ${d.why}`);
4468
+ if (d.alternatives_considered) console.log(` alternatives considered: ${d.alternatives_considered}`);
4469
+ console.log(` decided by: ${d.decider_kind}`);
4470
+ console.log(` status: ${d.status}`);
4471
+ if (d.outcome_label) console.log(` outcome: ${d.outcome_label}`);
4472
+ } else {
4473
+ console.log(` ${report.decision_slug} \u2014 exists, but in a project you cannot view`);
4474
+ }
4475
+ }
4476
+ if (report.approval) renderApprovalCard(report.approval);
4477
+ else if (report.approval_unreadable) {
4478
+ console.log("");
4479
+ console.log("Approval:");
4480
+ console.log(" The approval card could not be loaded.");
4481
+ }
4482
+ if (report.effects?.length) {
4483
+ console.log("");
4484
+ console.log("Predicted vs measured:");
4485
+ for (const e of report.effects) renderReportEffect(e);
4486
+ }
4487
+ if (report.related_items?.length) {
4488
+ console.log("");
4489
+ console.log("Earlier items:");
4490
+ for (const r of report.related_items) {
4491
+ const reasons = [r.same_workstream ? "same workstream" : void 0, r.same_title ? "same title" : void 0].filter(Boolean).join(", ");
4492
+ const created = formatDate(r.created_at) ?? r.created_at;
4493
+ console.log(
4494
+ ` [${r.severity}] ${r.status} \u2014 ${r.title} (${r.slug}) \u2014 ${created}${reasons ? ` \u2014 ${reasons}` : ""}`
4495
+ );
4496
+ }
4497
+ }
4498
+ }
4499
+ attnCmd.command("list").description("List attention items").option("--status <status...>", "open | answered | dismissed | expired").option("--open", "shorthand for --status open").option("--engine-actions", "only engine-generated items").option("--item <idOrSlug>", "read one item: its slug or its uuid").option("--org <slug>", "read another organization's feed \u2014 yours, or a client you manage").option(
4500
+ "--subject-kind <kind>",
4501
+ "only items about this kind of thing: google_ads_campaign | meta_ads_campaign | google_ads_ad_group | google_ads_ad | page | dataset | workstream"
4502
+ ).option("--subject-id <id>", "with --subject-kind, only items about this exact subject").option("-n, --limit <n>", "max items", (v) => parseInt(v, 10)).option("--offset <n>", "pagination offset", (v) => parseInt(v, 10)).option("--json", "output raw JSON").action(
4216
4503
  async (opts) => {
4217
4504
  try {
4218
4505
  const statuses = opts.status?.length ? opts.status : opts.open ? ["open"] : void 0;
4219
- print(
4220
- await new ErdoClient().listAttentionItems({
4221
- statuses,
4222
- engine_actions_only: opts.engineActions,
4223
- item_id: opts.item,
4224
- limit: opts.limit,
4225
- offset: opts.offset
4226
- })
4227
- );
4506
+ const res = await new ErdoClient().listAttentionItems({
4507
+ statuses,
4508
+ engine_actions_only: opts.engineActions,
4509
+ item_id: opts.item,
4510
+ organization_slug: opts.org,
4511
+ subject_kind: opts.subjectKind,
4512
+ subject_id: opts.subjectId,
4513
+ limit: opts.limit,
4514
+ offset: opts.offset
4515
+ });
4516
+ if (opts.json) {
4517
+ print(res);
4518
+ return;
4519
+ }
4520
+ if (!res.items.length) {
4521
+ console.log("No attention items match. Widen the filters.");
4522
+ return;
4523
+ }
4524
+ for (const item of res.items) console.log(attentionItemLine(item));
4525
+ if (res.report) renderAttentionItemReport(res.items[0], res.report);
4228
4526
  } catch (e) {
4229
4527
  fail(e);
4230
4528
  }
@@ -4235,7 +4533,7 @@ attnCmd.command("respond <id>").description(
4235
4533
  ).option("--answer <json>", `a JSON answer, e.g. '{"q1":["option-a"]}'`).option(
4236
4534
  "--flag-broken <json>",
4237
4535
  'flag choice variants as broken: JSON array [{"variant_key":"...","reason":"..."}]'
4238
- ).option("--ack", "mark read / acknowledge").option("--dismiss", "dismiss the item").action(
4536
+ ).option("--ack", "mark read / acknowledge").option("--dismiss", "dismiss the item").option("--org <slug>", "the organization holding the item \u2014 yours, or a client you manage").action(
4239
4537
  async (id, opts) => {
4240
4538
  try {
4241
4539
  if (opts.ack || opts.dismiss) {
@@ -4247,7 +4545,8 @@ attnCmd.command("respond <id>").description(
4247
4545
  }
4248
4546
  print(
4249
4547
  await new ErdoClient().respondAttentionItem(id, {
4250
- action: opts.ack ? "acknowledge" : "dismiss"
4548
+ action: opts.ack ? "acknowledge" : "dismiss",
4549
+ organization_slug: opts.org
4251
4550
  })
4252
4551
  );
4253
4552
  return;
@@ -4255,7 +4554,7 @@ attnCmd.command("respond <id>").description(
4255
4554
  if (opts.answer === void 0 && opts.flagBroken === void 0) {
4256
4555
  fail(new Error("provide one of --answer, --flag-broken, --ack, or --dismiss"));
4257
4556
  }
4258
- const input = { action: "answer" };
4557
+ const input = { action: "answer", organization_slug: opts.org };
4259
4558
  if (opts.answer !== void 0) {
4260
4559
  try {
4261
4560
  input.answer = JSON.parse(opts.answer);
@@ -4311,7 +4610,19 @@ pageFeedbackCmd.command("record").description(
4311
4610
  );
4312
4611
  }
4313
4612
  let items;
4314
- if (opts.items !== void 0) {
4613
+ if (opts.items === void 0) {
4614
+ if (!opts.experiment || !opts.variant || !opts.feedback) {
4615
+ throw new Error("provide --items, or all of --experiment, --variant, --feedback");
4616
+ }
4617
+ items = [
4618
+ {
4619
+ experiment_slug: opts.experiment,
4620
+ variant_key: opts.variant,
4621
+ feedback: opts.feedback,
4622
+ image_bucket_key: opts.imageKey
4623
+ }
4624
+ ];
4625
+ } else {
4315
4626
  let parsed;
4316
4627
  try {
4317
4628
  parsed = JSON.parse(readMaybeFile(opts.items));
@@ -4324,18 +4635,6 @@ pageFeedbackCmd.command("record").description(
4324
4635
  throw new Error("--items must be a JSON array of {experiment_slug, variant_key, feedback} objects");
4325
4636
  }
4326
4637
  items = parsed;
4327
- } else {
4328
- if (!opts.experiment || !opts.variant || !opts.feedback) {
4329
- throw new Error("provide --items, or all of --experiment, --variant, --feedback");
4330
- }
4331
- items = [
4332
- {
4333
- experiment_slug: opts.experiment,
4334
- variant_key: opts.variant,
4335
- feedback: opts.feedback,
4336
- image_bucket_key: opts.imageKey
4337
- }
4338
- ];
4339
4638
  }
4340
4639
  print(await new ErdoClient().recordPageFeedback(items));
4341
4640
  } catch (e) {
@@ -4581,7 +4880,7 @@ reviewsCmd.command("decide <id>").description(
4581
4880
  opts.apply ? "apply" : null,
4582
4881
  opts.resolve ? "resolve" : null,
4583
4882
  opts.reject ? "reject" : null,
4584
- opts.snooze !== void 0 ? "snooze" : null
4883
+ opts.snooze === void 0 ? null : "snooze"
4585
4884
  ].filter(Boolean);
4586
4885
  if (chosen.length !== 1) {
4587
4886
  fail(new Error("provide exactly one of --apply, --resolve, --reject, or --snooze"));
@@ -4601,6 +4900,11 @@ reviewsCmd.command("decide <id>").description(
4601
4900
  }
4602
4901
  );
4603
4902
  var pagesCmd = program.command("pages").description("Create and manage pages/artifacts");
4903
+ function publicFlag(opts) {
4904
+ if (opts.public) return true;
4905
+ if (opts.private) return false;
4906
+ return void 0;
4907
+ }
4604
4908
  function grantList(v) {
4605
4909
  if (v === void 0) return void 0;
4606
4910
  if (v.trim() === "[]") return [];
@@ -4676,7 +4980,9 @@ pagesCmd.command("update <id>").description(
4676
4980
  writable_dataset_slugs: grantList(opts.writableDatasets),
4677
4981
  kv_slugs: grantList(opts.kv),
4678
4982
  writable_kv_slugs: grantList(opts.writableKv),
4679
- public: opts.public ? true : opts.private ? false : void 0,
4983
+ // --public and --private are mutually exclusive flags; neither set
4984
+ // leaves the server's default standing.
4985
+ public: publicFlag(opts),
4680
4986
  request_review: !!opts.requestReview
4681
4987
  });
4682
4988
  console.log(`${res.id} ${res.public_url || res.url}`);
@@ -5041,11 +5347,13 @@ sendingDomainsCmd.command("remove <domain>").description(
5041
5347
  });
5042
5348
  emailCmd.command("received").description(
5043
5349
  "Read the mail that arrived at the org's sending domain, newest first \u2014 usually leads replying to outreach an agent sent. Omit --domain: an org has one sending domain and the read resolves it."
5044
- ).option("--domain <domain>", "the sending domain to read (only needed if the org has more than one)").option("--limit <n>", "maximum messages to return, newest first").option("--json", "print the raw JSON result instead of the message list").action(async (opts) => {
5350
+ ).option("--domain <domain>", "the sending domain to read (only needed if the org has more than one)").option("--limit <n>", "maximum messages to return, newest first").option("--lead-ref <ref>", "only one lead's email conversation with the desk").option("--dataset <slug>", "with --lead-ref, only that lead dataset").option("--json", "print the raw JSON result instead of the message list").action(async (opts) => {
5045
5351
  try {
5046
5352
  const res = await new ErdoClient().listReceivedEmails({
5047
5353
  domain: opts.domain,
5048
- limit: opts.limit ? Number(opts.limit) : void 0
5354
+ limit: opts.limit ? Number(opts.limit) : void 0,
5355
+ leadRef: opts.leadRef,
5356
+ dataset: opts.dataset
5049
5357
  });
5050
5358
  if (opts.json) {
5051
5359
  print(res);
@@ -5062,12 +5370,17 @@ emailCmd.command("received").description(
5062
5370
  `);
5063
5371
  for (const m of emails) {
5064
5372
  const who = m.from_name ? `${m.from_name} <${m.from_email}>` : m.from_email;
5065
- console.log(`${m.received_at} ${who} ${m.subject || "(no subject)"}`);
5373
+ const side = m.direction ? ` [${m.direction}${m.lead_email ? ` \xB7 lead ${m.lead_email}` : ""}]` : "";
5374
+ console.log(`${m.received_at} ${who} ${m.subject || "(no subject)"}${side}`);
5066
5375
  const preview = (m.text_preview || "").replace(/\s+/g, " ").trim();
5067
5376
  if (preview) console.log(` ${preview.length > 160 ? `${preview.slice(0, 160)}\u2026` : preview}`);
5068
- console.log(
5069
- m.forwarded_at ? ` forwarded ${m.forwarded_at}` : " not forwarded \u2014 this one is in Erdo only, so nobody on your side has necessarily seen it"
5070
- );
5377
+ if (m.relay_skip_reason) {
5378
+ console.log(` kept in Erdo, not relayed (${m.relay_skip_reason})`);
5379
+ } else {
5380
+ console.log(
5381
+ m.forwarded_at ? ` ${m.relayed_email_id ? "relayed" : "forwarded"} ${m.forwarded_at}` : " not forwarded \u2014 this one is in Erdo only, so nobody on your side has necessarily seen it"
5382
+ );
5383
+ }
5071
5384
  }
5072
5385
  } catch (e) {
5073
5386
  fail(e);
@@ -5265,6 +5578,8 @@ voiceCallsCmd.command("list").description("List the organization's phone calls,
5265
5578
  "status",
5266
5579
  "secs",
5267
5580
  "transcript",
5581
+ "placed by",
5582
+ "dial",
5268
5583
  "contact",
5269
5584
  "lead",
5270
5585
  "lead ref",
@@ -5279,6 +5594,10 @@ voiceCallsCmd.command("list").description("List the organization's phone calls,
5279
5594
  call.status,
5280
5595
  call.duration_seconds,
5281
5596
  call.has_transcript ? "yes" : "no",
5597
+ // "-" rather than blank: a call the AI agent placed has no person
5598
+ // behind it, which is a fact, not a missing value.
5599
+ call.placed_by_name || "-",
5600
+ call.dial_outcome || "-",
5282
5601
  contactLabel(call.contact),
5283
5602
  leadStatusLabel(call.lead_status),
5284
5603
  leadReferenceLabel(call.lead_reference),
@@ -5298,7 +5617,7 @@ voiceCallsCmd.command("list").description("List the organization's phone calls,
5298
5617
  }
5299
5618
  );
5300
5619
  voiceCallsCmd.command("get <callID>").description(
5301
- "Read one phone call in full: transcript, summary, per-turn LLM metrics, and the lead it produced (contact, lead_status, lead_saved_at, canonical_lead_id, lead_reference)"
5620
+ "Read one phone call in full: transcript, summary, per-turn LLM metrics, who placed it by hand (placed_by_name) and what became of the lead's leg (dial_outcome), and the lead it produced (contact, lead_status, lead_saved_at, canonical_lead_id, lead_reference)"
5302
5621
  ).action(async (callID) => {
5303
5622
  try {
5304
5623
  print(await new ErdoClient().getVoiceCall(callID));
@@ -5537,12 +5856,14 @@ voiceNumbersCmd.command("list").description("List the organization's numbers wit
5537
5856
  if (numbers.length === 0) {
5538
5857
  console.log("This organization holds no phone numbers.");
5539
5858
  } else {
5859
+ const callable = new Set(res.person_call_numbers ?? []);
5540
5860
  printAlignedTable(
5541
- ["number", "agent", "kind", "status", "waiting on", "reason"],
5861
+ ["number", "agent", "kind", "calls", "status", "waiting on", "reason"],
5542
5862
  numbers.map((n) => [
5543
5863
  n.address,
5544
5864
  n.agent_ref ?? "",
5545
5865
  n.sender_kind,
5866
+ callable.has(n.address) ? "yes" : "",
5546
5867
  n.status,
5547
5868
  n.next_step ?? "",
5548
5869
  n.reason ?? ""
@@ -5627,6 +5948,28 @@ voiceAgentsCmd.command("sms-replies <slug>").description("Turn a voice agent's a
5627
5948
  fail(e);
5628
5949
  }
5629
5950
  });
5951
+ voiceAgentsCmd.command("whatsapp-replies <slug>").description("Turn a voice agent's answering on WhatsApp on or off").option("--on", "the agent answers WhatsApp messages sent to its own number").option("--off", "messages still arrive and stay readable; the agent does not write back").action(async (slug, opts) => {
5952
+ if (Boolean(opts.on) === Boolean(opts.off)) {
5953
+ fail(new Error("say which way: pass --on or --off (exactly one)"));
5954
+ }
5955
+ try {
5956
+ const agent = await new ErdoClient().setVoiceAgentWhatsAppReplies(slug, Boolean(opts.on));
5957
+ console.log(
5958
+ `${agent.slug} ${agent.whatsapp_replies_enabled ? "on" : "off"} ${agent.phone_number ?? "-"} ${agent.name}`
5959
+ );
5960
+ if (!agent.phone_number) {
5961
+ process.stderr.write(
5962
+ "this agent has no phone number yet \u2014 the setting is stored and applies once it gets one\n"
5963
+ );
5964
+ } else {
5965
+ process.stderr.write(
5966
+ "the number also has to be set up as a WhatsApp sender before any reply can go out\n"
5967
+ );
5968
+ }
5969
+ } catch (e) {
5970
+ fail(e);
5971
+ }
5972
+ });
5630
5973
  var datasetsCmd = program.command("datasets").description("Datasets");
5631
5974
  datasetsCmd.command("list").description("List datasets").option(
5632
5975
  "--class <class>",
@@ -6070,8 +6413,10 @@ refreshCmd.command("set <slug>").description(
6070
6413
  const slugs = opts.contextDatasets.split(",").map((s) => s.trim()).filter(Boolean);
6071
6414
  if (slugs.length) body.context_dataset_slugs = slugs;
6072
6415
  }
6073
- if (typeof opts.onView === "boolean") body.refresh_on_view_enabled = opts.onView;
6074
- if (opts.staleAfter !== void 0) body.refresh_on_view_stale_after_seconds = opts.staleAfter;
6416
+ if (opts.onView !== void 0) body.refresh_on_view_enabled = opts.onView;
6417
+ if (opts.staleAfter !== void 0) {
6418
+ body.refresh_on_view_stale_after_seconds = opts.staleAfter;
6419
+ }
6075
6420
  if (opts.debounce !== void 0) body.refresh_on_view_debounce_seconds = opts.debounce;
6076
6421
  if (opts.liveWriteMode) body.live_write_mode = opts.liveWriteMode;
6077
6422
  if (opts.payloadSchema) {
@@ -6520,7 +6865,7 @@ List tables with: erdo integrations tables ${app} <schema>`);
6520
6865
  return;
6521
6866
  }
6522
6867
  for (const t of res.tables ?? []) {
6523
- const rows = t.estimated_row_count != null ? ` ~${t.estimated_row_count} rows` : "";
6868
+ const rows = t.estimated_row_count == null ? "" : ` ~${t.estimated_row_count} rows`;
6524
6869
  console.log(`${t.schema_name}.${t.table_name} ${t.columns.length} columns${rows}`);
6525
6870
  }
6526
6871
  } catch (e) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@erdoai/cli",
3
- "version": "0.91.0",
3
+ "version": "0.98.0",
4
4
  "description": "Erdo CLI — drive datasets, pages, and evals from the terminal or CI",
5
5
  "type": "module",
6
6
  "bin": {