@oxygen-agent/cli 1.987.20 → 1.1003.12

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 (106) hide show
  1. package/README.md +1 -1
  2. package/dist/admin-primary-providers-render.d.ts +0 -2
  3. package/dist/admin-primary-providers-render.js +1 -1
  4. package/dist/browser-login.js +1 -4
  5. package/dist/command-manifest.d.ts +3 -2
  6. package/dist/command-manifest.js +10 -0
  7. package/dist/credentials.d.ts +1 -1
  8. package/dist/functions-commands.js +6 -2
  9. package/dist/help.d.ts +8 -0
  10. package/dist/help.js +46 -0
  11. package/dist/index.js +1569 -123
  12. package/dist/knowledge-mirror.d.ts +2 -2
  13. package/dist/runtime.d.ts +0 -15
  14. package/dist/runtime.js +1 -1
  15. package/dist/session.d.ts +4 -3
  16. package/dist/skills.d.ts +8 -7
  17. package/dist/skills.js +24 -10
  18. package/dist/transcript.d.ts +2 -1
  19. package/dist/util.d.ts +1 -1
  20. package/dist/util.js +1 -3
  21. package/node_modules/@oxygen/formula/dist/coerce.d.ts +10 -0
  22. package/node_modules/@oxygen/formula/dist/coerce.js +10 -0
  23. package/node_modules/@oxygen/formula/dist/formula-functions.js +65 -0
  24. package/node_modules/@oxygen/formula/dist/hash.d.ts +19 -0
  25. package/node_modules/@oxygen/formula/dist/hash.js +199 -0
  26. package/node_modules/@oxygen/formula/dist/value-cleaners.d.ts +6 -1
  27. package/node_modules/@oxygen/formula/dist/value-cleaners.js +10 -26
  28. package/node_modules/@oxygen/shared/dist/array-utils.d.ts +5 -0
  29. package/node_modules/@oxygen/shared/dist/array-utils.js +11 -0
  30. package/node_modules/@oxygen/shared/dist/billing.d.ts +78 -22
  31. package/node_modules/@oxygen/shared/dist/billing.js +150 -40
  32. package/node_modules/@oxygen/shared/dist/capability-discovery.d.ts +17 -0
  33. package/node_modules/@oxygen/shared/dist/capability-discovery.js +89 -16
  34. package/node_modules/@oxygen/shared/dist/column-autofill.d.ts +52 -0
  35. package/node_modules/@oxygen/shared/dist/column-autofill.js +80 -0
  36. package/node_modules/@oxygen/shared/dist/column-output-fields.js +2 -6
  37. package/node_modules/@oxygen/shared/dist/company-enrichment-fields.d.ts +108 -0
  38. package/node_modules/@oxygen/shared/dist/company-enrichment-fields.js +545 -0
  39. package/node_modules/@oxygen/shared/dist/copilot-skills.generated.d.ts +2 -2
  40. package/node_modules/@oxygen/shared/dist/copilot-skills.generated.js +2 -2
  41. package/node_modules/@oxygen/shared/dist/deploy-env.d.ts +74 -0
  42. package/node_modules/@oxygen/shared/dist/deploy-env.js +82 -0
  43. package/node_modules/@oxygen/shared/dist/dnc-rules.d.ts +130 -0
  44. package/node_modules/@oxygen/shared/dist/dnc-rules.js +221 -0
  45. package/node_modules/@oxygen/shared/dist/enrichment-intents.d.ts +103 -0
  46. package/node_modules/@oxygen/shared/dist/enrichment-intents.js +819 -0
  47. package/node_modules/@oxygen/shared/dist/error-message.d.ts +1 -0
  48. package/node_modules/@oxygen/shared/dist/error-message.js +3 -0
  49. package/node_modules/@oxygen/shared/dist/error-redaction.js +1 -3
  50. package/node_modules/@oxygen/shared/dist/external-write-policy.d.ts +33 -0
  51. package/node_modules/@oxygen/shared/dist/external-write-policy.js +68 -0
  52. package/node_modules/@oxygen/shared/dist/format-percent.d.ts +8 -0
  53. package/node_modules/@oxygen/shared/dist/format-percent.js +13 -0
  54. package/node_modules/@oxygen/shared/dist/freemail-domains.d.ts +81 -0
  55. package/node_modules/@oxygen/shared/dist/freemail-domains.js +157 -0
  56. package/node_modules/@oxygen/shared/dist/future-signup-lifecycle-projection.d.ts +1 -0
  57. package/node_modules/@oxygen/shared/dist/future-signup-lifecycle-projection.js +1 -1
  58. package/node_modules/@oxygen/shared/dist/index.d.ts +13 -0
  59. package/node_modules/@oxygen/shared/dist/index.js +13 -0
  60. package/node_modules/@oxygen/shared/dist/json-path.js +1 -3
  61. package/node_modules/@oxygen/shared/dist/knowledge-bases.js +1 -3
  62. package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.d.ts +2 -2
  63. package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.js +2 -2
  64. package/node_modules/@oxygen/shared/dist/langfuse.js +9 -0
  65. package/node_modules/@oxygen/shared/dist/linkedin-countries.d.ts +32 -0
  66. package/node_modules/@oxygen/shared/dist/linkedin-countries.js +359 -0
  67. package/node_modules/@oxygen/shared/dist/log-sink-selector.d.ts +39 -0
  68. package/node_modules/@oxygen/shared/dist/log-sink-selector.js +56 -0
  69. package/node_modules/@oxygen/shared/dist/log.d.ts +1 -0
  70. package/node_modules/@oxygen/shared/dist/log.js +6 -1
  71. package/node_modules/@oxygen/shared/dist/object-storage.d.ts +17 -0
  72. package/node_modules/@oxygen/shared/dist/object-storage.js +21 -0
  73. package/node_modules/@oxygen/shared/dist/otlp-log-sink.d.ts +54 -0
  74. package/node_modules/@oxygen/shared/dist/otlp-log-sink.js +213 -0
  75. package/node_modules/@oxygen/shared/dist/plan-capabilities.js +1 -0
  76. package/node_modules/@oxygen/shared/dist/plan-limits.d.ts +23 -22
  77. package/node_modules/@oxygen/shared/dist/plan-limits.js +45 -18
  78. package/node_modules/@oxygen/shared/dist/pricing-sheet.d.ts +48 -41
  79. package/node_modules/@oxygen/shared/dist/pricing-sheet.js +36 -25
  80. package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.d.ts +22 -22
  81. package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.js +40 -34
  82. package/node_modules/@oxygen/shared/dist/product-analytics-environment.js +9 -0
  83. package/node_modules/@oxygen/shared/dist/product-analytics-events.d.ts +15 -0
  84. package/node_modules/@oxygen/shared/dist/product-analytics-events.js +15 -0
  85. package/node_modules/@oxygen/shared/dist/rate-window.d.ts +5 -0
  86. package/node_modules/@oxygen/shared/dist/rate-window.js +8 -0
  87. package/node_modules/@oxygen/shared/dist/research-output-contract.js +1 -3
  88. package/node_modules/@oxygen/shared/dist/search-vocab.js +4 -5
  89. package/node_modules/@oxygen/shared/dist/select-options.js +6 -1
  90. package/node_modules/@oxygen/shared/dist/sequence-failures.js +1 -5
  91. package/node_modules/@oxygen/shared/dist/sequences.d.ts +23 -0
  92. package/node_modules/@oxygen/shared/dist/sequences.js +110 -4
  93. package/node_modules/@oxygen/shared/dist/spend-safety.d.ts +22 -10
  94. package/node_modules/@oxygen/shared/dist/spend-safety.js +15 -21
  95. package/node_modules/@oxygen/shared/dist/sql-rows.d.ts +1 -0
  96. package/node_modules/@oxygen/shared/dist/sql-rows.js +3 -0
  97. package/node_modules/@oxygen/shared/dist/telemetry.js +9 -1
  98. package/node_modules/@oxygen/shared/dist/type-guards.d.ts +22 -0
  99. package/node_modules/@oxygen/shared/dist/type-guards.js +35 -0
  100. package/node_modules/@oxygen/shared/dist/value-readers.d.ts +21 -0
  101. package/node_modules/@oxygen/shared/dist/value-readers.js +59 -0
  102. package/node_modules/@oxygen/shared/dist/version.js +1 -1
  103. package/node_modules/@oxygen/shared/package.json +50 -0
  104. package/node_modules/@oxygen/workflows/dist/graph/expression.js +2 -5
  105. package/node_modules/@oxygen/workflows/dist/graph/params.js +1 -1
  106. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -12,9 +12,10 @@ import { registerKnowledgeRepositoryCommands } from "./knowledge-repository-comm
12
12
  import { registerVisualCommands } from "./visual-commands.js";
13
13
  import { renderPrimaryProviderBoard } from "./admin-primary-providers-render.js";
14
14
  import { registerFunctionsCommands } from "./functions-commands.js";
15
- import { applyOxygenHelp, enableCommandSuggestions, unknownCommandHint } from "./help.js";
15
+ import { applyOxygenHelp, enableCommandSuggestions, unknownCommandHint, unknownOptionHint } from "./help.js";
16
16
  import { buildCommandManifest, getCommandManifestEntry, searchCommandManifest, suggestCommandNames, } from "./command-manifest.js";
17
- import { AGENCY_DIRECTORY_REGIONS, AGENCY_DIRECTORY_SERVICES, COLLAB_GATE_KINDS, COLLAB_GATE_PROSE, COLLAB_SUBJECT_KINDS, COLLAB_SUBJECT_KINDS_PROSE, COLLAB_SUBJECT_LABELS, COLLAB_SUBJECT_PROSE, GATE_KIND_SUBJECTS, describeWorkflowStatusChange, formatCellForDisplay, formatCopilotPlanDuration, formatCopilotPlanSeconds, formatPublicBudgetScopes, SUBJECT_PATH_FORMS_PROSE, formatSubjectPath, exitCodeForOxygenError, parseSubjectPath, parseSubjectRef, parseWorkflowStatusChange, isVersionGreater, isVersionLess, KNOWLEDGE_BOOTSTRAP_MAX_CREDITS, MAX_CLI_JSON_BODY_BYTES, MAX_MCP_TOOL_NAME_LENGTH, normalizeCopilotPlanStepStatus, OXYGEN_CAPABILITY_ROUTES, OXYGEN_VERSION, OxygenError, getCapabilityRouteMatch, inferUserCapabilityRoute, parseKnowledgePageMarkdown, PLAN_LIMITS, serializeCapabilityRoute, sleep, success, TABLE_IMPORT_ROW_LIMIT, TAG_KINDS_PROSE, toFailure, workflowMcpToolName, } from "@oxygen/shared";
17
+ import { AGENCY_DIRECTORY_REGIONS, AGENCY_DIRECTORY_SERVICES, COLLAB_GATE_KINDS, COLLAB_GATE_PROSE, COLLAB_SUBJECT_KINDS, COLLAB_SUBJECT_KINDS_PROSE, COLLAB_SUBJECT_LABELS, COLLAB_SUBJECT_PROSE, GATE_KIND_SUBJECTS, describeWorkflowStatusChange, formatCellForDisplay, formatCopilotPlanDuration, formatCopilotPlanSeconds, formatPublicBudgetScopes, SUBJECT_PATH_FORMS_PROSE, formatSubjectPath, exitCodeForOxygenError, parseSubjectPath, parseSubjectRef, parseWorkflowStatusChange, chunk, isVersionGreater, isVersionLess, KNOWLEDGE_BOOTSTRAP_MAX_CREDITS, MAX_CLI_JSON_BODY_BYTES, MAX_MCP_TOOL_NAME_LENGTH, normalizeCopilotPlanStepStatus, OXYGEN_CAPABILITY_ROUTES, OXYGEN_VERSION, OxygenError, getCapabilityRouteMatch, inferUserCapabilityRoute, parseKnowledgePageMarkdown, PLAN_LIMITS, serializeCapabilityRoute, sleep, success, TABLE_IMPORT_ROW_LIMIT, TAG_KINDS_PROSE, toFailure, workflowMcpToolName, } from "@oxygen/shared";
18
+ import { PURCHASABLE_PLAN_KEYS } from "@oxygen/shared/billing";
18
19
  import { TAG_COLORS } from "@oxygen/shared/select-options";
19
20
  import { inferImportColumnLabels, inferRowsFileFormat, normalizeImportColumnKey, normalizeRowsForNewTable, normalizeRowsFormat, parseRowsFileBuffer, parseXlsxWorkbookBuffer, } from "@oxygen/shared/file-import";
20
21
  import { MAILBOX_IMPORT_FILE_MAX_BYTES as SHARED_MAILBOX_IMPORT_FILE_MAX_BYTES, MAILBOX_IMPORT_ROW_LIMIT as SHARED_MAILBOX_IMPORT_ROW_LIMIT, normalizeMailboxImportFile as normalizeSharedMailboxImportFile, normalizeMailboxImportVendor as normalizeSharedMailboxImportVendor, normalizeMailboxWorkbookRows, parseMailboxImportText, summarizeMailboxImportValidation as summarizeSharedMailboxImportValidation, } from "@oxygen/shared/mailbox-import";
@@ -173,13 +174,55 @@ function requireCollabDecision(options) {
173
174
  }
174
175
  return selected[0];
175
176
  }
177
+ /**
178
+ * A widget configuration from `--config` or `--config-file`.
179
+ *
180
+ * Both are accepted because the shape of a chart or a code widget is more than
181
+ * anyone wants to quote on one shell line, and `--config-file` is how an agent
182
+ * hands over a configuration it just generated. The SHAPE is validated server
183
+ * side by the shared widget registry, never here: a second validator is how the
184
+ * CLI and the web start disagreeing about what a valid widget is.
185
+ */
186
+ async function readDashboardWidgetConfig(options) {
187
+ const inline = readOption(options.config);
188
+ const path = readOption(options.configFile);
189
+ if (inline && path) {
190
+ throw new OxygenError("invalid_request", "Pass either --config or --config-file, not both.", { exitCode: 1 });
191
+ }
192
+ if (inline)
193
+ return parseJsonObject(inline);
194
+ if (path) {
195
+ const fs = await import("node:fs/promises");
196
+ return parseJsonObject(await fs.readFile(path, "utf8"));
197
+ }
198
+ return {};
199
+ }
176
200
  function buildFindBody(capability, options) {
177
201
  const body = { capability };
178
202
  const set = (key, value) => {
179
203
  if (value)
180
204
  body[key] = value;
181
205
  };
182
- if (capability === "company") {
206
+ if (capability === "person") {
207
+ set("linkedin_url", options.linkedinUrl);
208
+ set("linkedin_id", options.linkedinId);
209
+ set("sales_navigator_url", options.salesNavigatorUrl);
210
+ set("email", options.email);
211
+ set("full_name", options.fullName);
212
+ set("first_name", options.firstName);
213
+ set("last_name", options.lastName);
214
+ set("company_domain", options.companyDomain);
215
+ set("company_name", options.companyName);
216
+ if (options.includeRaw)
217
+ body.include_raw = true;
218
+ }
219
+ else if (capability === "company") {
220
+ // The catalog needs no identity and spends nothing; it is the answer to
221
+ // "which fields exist and what do they cost" before any field is named.
222
+ if (options.listFields) {
223
+ body.list_fields = true;
224
+ return body;
225
+ }
183
226
  set("domain", options.domain);
184
227
  set("name", options.name);
185
228
  set("linkedin_url", options.linkedinUrl);
@@ -188,6 +231,11 @@ function buildFindBody(capability, options) {
188
231
  if (fields.length > 0)
189
232
  body.fields = fields;
190
233
  }
234
+ if (options.checkTechnologies) {
235
+ const technologies = options.checkTechnologies.split(",").map((entry) => entry.trim()).filter(Boolean);
236
+ if (technologies.length > 0)
237
+ body.check_technologies = technologies;
238
+ }
191
239
  }
192
240
  else {
193
241
  set("linkedin_url", options.linkedinUrl);
@@ -843,6 +891,29 @@ function writeEngagersReceipt(data) {
843
891
  // reported as broken: the preview comes back empty and reads as a failed live
844
892
  // call. Mirror the server's preview banner on stderr, the same stdout/stderr
845
893
  // split the credits receipt uses, so the machine-read envelope stays clean.
894
+ /**
895
+ * Say WHY a preview found nothing, before the preview itself.
896
+ *
897
+ * A dry run against a tool the workspace has no connection for comes back as a
898
+ * clean, successful-looking envelope whose refusal lives several levels down in
899
+ * `access.status`. Read top-down, that is indistinguishable from "this provider
900
+ * has nothing for you" — which is how a blind operator concluded a whole
901
+ * provider could never be reached. When the route states `would_execute: false`
902
+ * with a `blocked_by` list, put it on stderr in one line, next to the fix.
903
+ * Absent those fields nothing is printed, so every other tool run is unchanged.
904
+ */
905
+ function writeToolRunBlockedNotice(data) {
906
+ const payload = asPayloadRecord(data);
907
+ if (!payload || payload.would_execute !== false)
908
+ return;
909
+ const blockedBy = Array.isArray(payload.blocked_by)
910
+ ? payload.blocked_by.filter((entry) => typeof entry === "string")
911
+ : [];
912
+ if (blockedBy.length === 0)
913
+ return;
914
+ const nextAction = typeof payload.next_action === "string" ? payload.next_action : null;
915
+ process.stderr.write(`Blocked: ${blockedBy.join(", ")}${nextAction ? ` — ${nextAction}` : ""}\n`);
916
+ }
846
917
  function writeDryRunNotice(data) {
847
918
  if (!data || typeof data !== "object" || Array.isArray(data))
848
919
  return;
@@ -1055,14 +1126,14 @@ function writeObservabilityCapsNotice(data) {
1055
1126
  // answer, so say it on stderr — same treatment the observability console's
1056
1127
  // per-source cap already gets. Machine-read stdout carries `capped`, `returned`
1057
1128
  // and `limit` either way.
1058
- function writeListCapNotice(data, noun) {
1129
+ function writeListCapNotice(data, noun, hint) {
1059
1130
  if (!data || typeof data !== "object" || Array.isArray(data))
1060
1131
  return;
1061
1132
  const record = data;
1062
1133
  if (record.capped !== true)
1063
1134
  return;
1064
1135
  const returned = typeof record.returned === "number" ? record.returned : null;
1065
- process.stderr.write(`note: showing ${returned === null ? "a window of" : returned} ${noun}; more exist — raise --limit (max 1000) or pass --all.\n`);
1136
+ process.stderr.write(`note: showing ${returned === null ? "a window of" : returned} ${noun}; more exist — raise --limit (max 1000) or pass --all${hint ? `; ${hint}` : ""}.\n`);
1066
1137
  }
1067
1138
  // The map is bounded twice over — a scan window per kind, then a character
1068
1139
  // budget across all of them — and both bounds are invisible in the rendered
@@ -2748,6 +2819,36 @@ function buildCrmTimelinePath(object, rowId, options) {
2748
2819
  const base = `/api/cli/crm/objects/${encodeURIComponent(object)}/records/${encodeURIComponent(rowId)}/timeline`;
2749
2820
  return query ? `${base}?${query}` : base;
2750
2821
  }
2822
+ function buildCrmRelatedPath(object, rowId, options) {
2823
+ const params = new URLSearchParams();
2824
+ if (options.previewLimit)
2825
+ params.set("preview_limit", options.previewLimit);
2826
+ const query = params.toString();
2827
+ const base = `/api/cli/crm/objects/${encodeURIComponent(object)}/records/${encodeURIComponent(rowId)}/related`;
2828
+ return query ? `${base}?${query}` : base;
2829
+ }
2830
+ function buildCrmNotesPath(object, rowId, options) {
2831
+ const params = new URLSearchParams();
2832
+ if (options.limit)
2833
+ params.set("limit", options.limit);
2834
+ if (options.cursor)
2835
+ params.set("cursor", options.cursor);
2836
+ const query = params.toString();
2837
+ const base = `/api/cli/crm/objects/${encodeURIComponent(object)}/records/${encodeURIComponent(rowId)}/notes`;
2838
+ return query ? `${base}?${query}` : base;
2839
+ }
2840
+ function buildCrmTasksPath(object, rowId, options) {
2841
+ const params = new URLSearchParams();
2842
+ if (options.status)
2843
+ params.set("status", options.status);
2844
+ if (options.limit)
2845
+ params.set("limit", options.limit);
2846
+ if (options.cursor)
2847
+ params.set("cursor", options.cursor);
2848
+ const query = params.toString();
2849
+ const base = `/api/cli/crm/objects/${encodeURIComponent(object)}/records/${encodeURIComponent(rowId)}/tasks`;
2850
+ return query ? `${base}?${query}` : base;
2851
+ }
2751
2852
  function buildCrmPipelinePath(options) {
2752
2853
  const params = new URLSearchParams();
2753
2854
  if (readOption(options.pipeline))
@@ -4790,10 +4891,10 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
4790
4891
  })));
4791
4892
  program
4792
4893
  .command("dashboards")
4793
- .description("Default GTM dashboards: the stitched Command Center funnel across the sequencer, unibox, and CRM.")
4894
+ .description("Your saved dashboards, plus the built-in GTM funnel. `summary` reads the built-in Command Center funnel across the sequencer, unibox and CRM; everything else creates and edits dashboards you save yourself and open again later, from charts, saved table views, notes, embeds and live sources.")
4794
4895
  .addCommand(new Command("summary")
4795
4896
  .description("Show the GTM funnel summary: touches → replies → positive → meetings → deals → won, with this-period revenue, channel split, and needs-action triage counts.")
4796
- .option("--range <range>", "Preset range: 7d, 14d, 28d, or 30d. Defaults to 14d.")
4897
+ .option("--range <range>", "Preset range: 7d, 14d, 28d, 30d, 90d, 180d, 365d, or all. Defaults to 14d.")
4797
4898
  .option("--from <date>", "Custom start date (YYYY-MM-DD).")
4798
4899
  .option("--to <date>", "Custom end date (YYYY-MM-DD).")
4799
4900
  .option("--cold-deal-days <n>", "Days of no activity before an open deal counts as cold. Defaults to 14.")
@@ -4816,7 +4917,187 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
4816
4917
  const qs = params.toString();
4817
4918
  return requestOxygen(`/api/cli/dashboards/summary${qs ? `?${qs}` : ""}`);
4818
4919
  });
4819
- }));
4920
+ }))
4921
+ .addCommand(new Command("list")
4922
+ .description("List the dashboards saved in this workspace.")
4923
+ .option("--json", "Print a JSON envelope.")
4924
+ .action(async (options) => {
4925
+ await handleAsyncAction("dashboards list", options, () => requestOxygen("/api/cli/dashboards"));
4926
+ }))
4927
+ .addCommand(new Command("create")
4928
+ .description("Create a saved dashboard. It starts with one empty tab; add widgets to it with `dashboards widgets add`.")
4929
+ .argument("<name>", "Display name, e.g. \"Monday numbers\".")
4930
+ .option("--slug <slug>", "URL handle. Defaults to a slug derived from the name.")
4931
+ .option("--description <text>", "What this dashboard answers.")
4932
+ .option("--icon <icon>", "Icon name shown beside it in the CRM sidebar.")
4933
+ .option("--default", "Open this one when the dashboards page is opened with no dashboard named.")
4934
+ .option("--json", "Print a JSON envelope.")
4935
+ .action(async (name, options) => {
4936
+ await handleAsyncAction("dashboards create", options, () => {
4937
+ const body = { name };
4938
+ const slug = readOption(options.slug);
4939
+ const description = readOption(options.description);
4940
+ const icon = readOption(options.icon);
4941
+ if (slug)
4942
+ body.slug = slug;
4943
+ if (description)
4944
+ body.description = description;
4945
+ if (icon)
4946
+ body.icon = icon;
4947
+ if (options.default)
4948
+ body.is_default = true;
4949
+ return requestOxygen("/api/cli/dashboards", { method: "POST", body });
4950
+ });
4951
+ }))
4952
+ .addCommand(new Command("get")
4953
+ .description("Read one dashboard with its tabs and widgets.")
4954
+ .argument("<dashboard>", "Dashboard slug or id.")
4955
+ .option("--json", "Print a JSON envelope.")
4956
+ .action(async (dashboard, options) => {
4957
+ await handleAsyncAction("dashboards get", options, () => requestOxygen(`/api/cli/dashboards/${encodeURIComponent(dashboard)}`));
4958
+ }))
4959
+ .addCommand(new Command("update")
4960
+ .description("Rename a dashboard, change its description or icon, or make it the default.")
4961
+ .argument("<dashboard>", "Dashboard slug or id.")
4962
+ .option("--name <name>", "New display name.")
4963
+ .option("--description <text>", "New description. Pass an empty string to clear it.")
4964
+ .option("--icon <icon>", "New icon name.")
4965
+ .option("--default", "Make this the default dashboard.")
4966
+ .option("--json", "Print a JSON envelope.")
4967
+ .action(async (dashboard, options) => {
4968
+ await handleAsyncAction("dashboards update", options, () => {
4969
+ const body = {};
4970
+ if (options.name !== undefined)
4971
+ body.name = options.name;
4972
+ if (options.description !== undefined)
4973
+ body.description = options.description;
4974
+ if (options.icon !== undefined)
4975
+ body.icon = options.icon;
4976
+ if (options.default)
4977
+ body.is_default = true;
4978
+ return requestOxygen(`/api/cli/dashboards/${encodeURIComponent(dashboard)}`, {
4979
+ method: "PATCH",
4980
+ body,
4981
+ });
4982
+ });
4983
+ }))
4984
+ .addCommand(new Command("delete")
4985
+ .description("Archive a dashboard. Its slug becomes free to reuse; the definition is kept, not destroyed.")
4986
+ .argument("<dashboard>", "Dashboard slug or id.")
4987
+ .option("--json", "Print a JSON envelope.")
4988
+ .action(async (dashboard, options) => {
4989
+ await handleAsyncAction("dashboards delete", options, () => requestOxygen(`/api/cli/dashboards/${encodeURIComponent(dashboard)}`, {
4990
+ method: "DELETE",
4991
+ }));
4992
+ }))
4993
+ .addCommand(new Command("tabs")
4994
+ .description("Tabs inside a dashboard.")
4995
+ .addCommand(new Command("add")
4996
+ .description("Add a tab to a dashboard.")
4997
+ .argument("<dashboard>", "Dashboard slug or id.")
4998
+ .argument("<title>", "Tab title.")
4999
+ .option("--json", "Print a JSON envelope.")
5000
+ .action(async (dashboard, title, options) => {
5001
+ await handleAsyncAction("dashboards tab create", options, () => requestOxygen(`/api/cli/dashboards/${encodeURIComponent(dashboard)}/tabs`, {
5002
+ method: "POST",
5003
+ body: { title },
5004
+ }));
5005
+ }))
5006
+ .addCommand(new Command("remove")
5007
+ .description("Remove a tab and every widget on it. A dashboard always keeps at least one tab.")
5008
+ .argument("<dashboard>", "Dashboard slug or id.")
5009
+ .argument("<tab_id>", "Tab id.")
5010
+ .option("--json", "Print a JSON envelope.")
5011
+ .action(async (dashboard, tabId, options) => {
5012
+ await handleAsyncAction("dashboards tab delete", options, () => requestOxygen(`/api/cli/dashboards/${encodeURIComponent(dashboard)}/tabs?tab=${encodeURIComponent(tabId)}`, { method: "DELETE" }));
5013
+ })))
5014
+ .addCommand(new Command("widgets")
5015
+ .description("Widgets on a dashboard: charts, saved views, embeds, notes, custom code, and live sources. `kinds` shows every configuration shape; `data` reads back the numbers a widget actually draws.")
5016
+ .addCommand(new Command("kinds")
5017
+ .description("List the widget kinds, what each one shows, whether it can cost credits, and an example configuration.")
5018
+ .option("--json", "Print a JSON envelope.")
5019
+ .action(async (options) => {
5020
+ await handleAsyncAction("dashboards widget kinds", options, () => requestOxygen("/api/cli/dashboards/widget-kinds"));
5021
+ }))
5022
+ .addCommand(new Command("add")
5023
+ .description("Add a widget. Kinds: chart, view, iframe, rich_text, code, live_source. The configuration shape depends on the kind — run `oxygen dashboards widgets kinds` to see each one with an example.")
5024
+ .argument("<dashboard>", "Dashboard slug or id.")
5025
+ .argument("<kind>", "chart | view | iframe | rich_text | code | live_source")
5026
+ .argument("<title>", "Widget title.")
5027
+ .option("--tab <tab_id>", "Tab to place it on. Defaults to the dashboard's first tab.")
5028
+ .option("--config <json>", "Widget configuration as JSON.")
5029
+ .option("--config-file <path>", "Read the configuration JSON from a file.")
5030
+ .option("--position <json>", "Grid placement as JSON, e.g. '{\"x\":0,\"y\":0,\"w\":6,\"h\":4}'.")
5031
+ .option("--json", "Print a JSON envelope.")
5032
+ .action(async (dashboard, kind, title, options) => {
5033
+ await handleAsyncAction("dashboards widget add", options, async () => {
5034
+ const body = { kind, title };
5035
+ const tab = readOption(options.tab);
5036
+ if (tab)
5037
+ body.tab = tab;
5038
+ body.configuration = await readDashboardWidgetConfig(options);
5039
+ const position = readOption(options.position);
5040
+ if (position)
5041
+ body.position = parseJsonObject(position);
5042
+ return requestOxygen(`/api/cli/dashboards/${encodeURIComponent(dashboard)}/widgets`, { method: "POST", body });
5043
+ });
5044
+ }))
5045
+ .addCommand(new Command("update")
5046
+ .description("Change a widget's title, configuration or placement, or pause/resume a live source.")
5047
+ .argument("<dashboard>", "Dashboard slug or id.")
5048
+ .argument("<widget_id>", "Widget id.")
5049
+ .option("--title <title>", "New title.")
5050
+ .option("--config <json>", "Replacement configuration as JSON. Replaces the whole configuration, never merged.")
5051
+ .option("--config-file <path>", "Read the replacement configuration JSON from a file.")
5052
+ .option("--position <json>", "New grid placement as JSON.")
5053
+ .option("--pause", "Stop a live-source widget refreshing. It keeps showing its last result.")
5054
+ .option("--resume", "Let a paused live-source widget refresh again.")
5055
+ .option("--json", "Print a JSON envelope.")
5056
+ .action(async (dashboard, widgetId, options) => {
5057
+ await handleAsyncAction("dashboards widget update", options, async () => {
5058
+ if (options.pause && options.resume) {
5059
+ throw new OxygenError("invalid_request", "Choose one of --pause or --resume, not both.", { exitCode: 1 });
5060
+ }
5061
+ const body = { widget: widgetId };
5062
+ if (options.title !== undefined)
5063
+ body.title = options.title;
5064
+ if (readOption(options.config) || readOption(options.configFile)) {
5065
+ body.configuration = await readDashboardWidgetConfig(options);
5066
+ }
5067
+ const position = readOption(options.position);
5068
+ if (position)
5069
+ body.position = parseJsonObject(position);
5070
+ if (options.pause)
5071
+ body.is_paused = true;
5072
+ if (options.resume)
5073
+ body.is_paused = false;
5074
+ return requestOxygen(`/api/cli/dashboards/${encodeURIComponent(dashboard)}/widgets`, { method: "PATCH", body });
5075
+ });
5076
+ }))
5077
+ .addCommand(new Command("remove")
5078
+ .description("Remove a widget from a dashboard.")
5079
+ .argument("<dashboard>", "Dashboard slug or id.")
5080
+ .argument("<widget_id>", "Widget id.")
5081
+ .option("--json", "Print a JSON envelope.")
5082
+ .action(async (dashboard, widgetId, options) => {
5083
+ await handleAsyncAction("dashboards widget remove", options, () => requestOxygen(`/api/cli/dashboards/${encodeURIComponent(dashboard)}/widgets?widget=${encodeURIComponent(widgetId)}`, { method: "DELETE" }));
5084
+ }))
5085
+ .addCommand(new Command("refresh")
5086
+ .description("Refresh a live-source widget now. This is the only dashboard command that can cost credits, bounded by the ceiling saved on the widget. Opening a dashboard never spends.")
5087
+ .argument("<dashboard>", "Dashboard slug or id.")
5088
+ .argument("<widget_id>", "Widget id.")
5089
+ .option("--json", "Print a JSON envelope.")
5090
+ .action(async (dashboard, widgetId, options) => {
5091
+ await handleAsyncAction("dashboards widget refresh", options, () => requestOxygen(`/api/cli/dashboards/${encodeURIComponent(dashboard)}/widgets/${encodeURIComponent(widgetId)}/refresh`, { method: "POST" }));
5092
+ }))
5093
+ .addCommand(new Command("data")
5094
+ .description("Read the data behind one widget — the same numbers the web page draws.")
5095
+ .argument("<dashboard>", "Dashboard slug or id.")
5096
+ .argument("<widget_id>", "Widget id.")
5097
+ .option("--json", "Print a JSON envelope.")
5098
+ .action(async (dashboard, widgetId, options) => {
5099
+ await handleAsyncAction("dashboards widget data", options, () => requestOxygen(`/api/cli/dashboards/${encodeURIComponent(dashboard)}/widgets/${encodeURIComponent(widgetId)}/data`));
5100
+ })));
4820
5101
  program
4821
5102
  .command("crm")
4822
5103
  .description("Agent-native CRM object setup, records, automatic enrichment, and metadata commands.")
@@ -4881,14 +5162,14 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
4881
5162
  .description("List configured CRM objects, or create/extend custom objects.")
4882
5163
  .option("--json", "Print a JSON envelope.")
4883
5164
  .action(async (options) => {
4884
- await handleAsyncAction("crm objects", options, () => requestOxygen("/api/cli/crm/objects"));
5165
+ await handleReadActionWithLens("crm objects", options, () => requestOxygen("/api/cli/crm/objects"), formatCrmObjects);
4885
5166
  })
4886
5167
  .addCommand(new Command("create")
4887
5168
  .description("Create or repair a custom CRM object. Defaults to dry-run.")
4888
5169
  .requiredOption("--slug <slug>", "Object slug (snake_case), such as projects or tasks.")
4889
5170
  .requiredOption("--display-name <name>", "Human-readable object name, such as Projects.")
4890
5171
  .requiredOption("--columns-json <json>", 'JSON array. Every column requires key,label,data_type,semantic_type; camelCase aliases are accepted. Example: [{"key":"name","label":"Name","data_type":"text","semantic_type":"text","is_record_label":true}].')
4891
- .option("--identities-json <json>", "JSON array. Every identity requires column_key and normalization; camelCase columnKey is accepted.")
5172
+ .option("--identities-json <json>", "JSON array. Every identity requires column_key and normalization; camelCase columnKey is accepted. normalization is one of email_v1, domain_v1, linkedin_url_v1, exact_text_v1, uuid_v1 — the versioned CRM names, NOT the short exact/email/domain spellings --lookup-normalize takes. At most one identity may set is_primary.")
4892
5173
  .option("--label-column <key>", "Column key to use as the record label. Defaults to the isRecordLabel column or the first column.")
4893
5174
  .option("--singular-name <name>", "Singular display name, such as Project.")
4894
5175
  .option("--plural-name <name>", "Plural display name, such as Projects.")
@@ -4925,10 +5206,10 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
4925
5206
  }));
4926
5207
  }))
4927
5208
  .addCommand(new Command("add-attr")
4928
- .description("Add one attribute to an existing custom CRM object, optionally as an identity. Defaults to dry-run.")
4929
- .argument("<object>", "Custom CRM object slug, such as projects or tasks. Standard objects are managed by `crm setup`.")
5209
+ .description("Add one field to an existing CRM object, optionally as an identity. Works on custom objects and on companies/people/deals — a field you add is yours and survives `crm setup`. Defaults to dry-run.")
5210
+ .argument("<object>", "CRM object slug, such as companies, people, deals, or a custom object like projects.")
4930
5211
  .requiredOption("--column-json <json>", "JSON column. Required: key,label,data_type,semantic_type; camelCase aliases are accepted.")
4931
- .option("--as-identity-json <json>", "JSON identity definition {columnKey,normalization,isPrimary?} promoting the new column.")
5212
+ .option("--as-identity-json <json>", "JSON identity definition {columnKey,normalization,isPrimary?} promoting the new column. normalization is one of email_v1, domain_v1, linkedin_url_v1, exact_text_v1, uuid_v1.")
4932
5213
  .option("--dry-run", "Preview the attribute plan without altering the table.")
4933
5214
  .option("--live", "Apply the attribute. Default is dry-run.")
4934
5215
  .option("--json", "Print a JSON envelope.")
@@ -4958,7 +5239,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
4958
5239
  .description("Create or update one CRM record by object identity. Defaults to dry-run. A live assert fires the object's standing auto-run (automatic enrichment) for the written record, exactly like `tables insert|upsert|import` — so new records enrich hands-free within the per-batch credit cap. Inspect or change what runs with `oxygen crm enrichment list`.")
4959
5240
  .argument("<object>", "CRM object slug, such as companies or people.")
4960
5241
  .requiredOption("--identity <key=value>", "Identity key/value, for example domain=acme.com or email=ceo@acme.com.")
4961
- .option("--values-json <json>", "JSON object of CRM attribute values keyed by column key.")
5242
+ .option("--values-json <json>", "JSON object of CRM attribute values keyed by column key. RELATIONSHIP columns (a deal's Associated Company and Associated People, a person's Company) are not set here and are silently ignored if you try: link by the target's identity with `company_domain` (people and deals) or `primary_contact_email` (deals), or attach an existing record with `oxygen crm relationships upsert`.")
4962
5243
  .option("--dry-run", "Preview the assert without writing a row.")
4963
5244
  .option("--live", "Apply the assert. Default is dry-run.")
4964
5245
  .option("--json", "Print a JSON envelope.")
@@ -4975,6 +5256,220 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
4975
5256
  .option("--json", "Print a JSON envelope.")
4976
5257
  .action(async (object, rowId, options) => {
4977
5258
  await handleAsyncAction("crm get", options, () => requestOxygen(`/api/cli/crm/objects/${encodeURIComponent(object)}/records/${encodeURIComponent(rowId)}`));
5259
+ }))
5260
+ .addCommand(new Command("connections")
5261
+ .description("Show the conversations and sequence enrollments connected to one CRM record, resolved through its people and identities \u2014 never by fuzzy name or domain.")
5262
+ .argument("<object>", "CRM object slug, such as companies or people.")
5263
+ .argument("<record>", "CRM record row id, or an identity value such as acme.com or sarah@acme.com.")
5264
+ .option("--conversation-limit <n>", "Maximum conversations to return.")
5265
+ .option("--sequence-limit <n>", "Maximum sequence enrollments to return.")
5266
+ .option("--json", "Print a JSON envelope.")
5267
+ .action(async (object, rowId, options) => {
5268
+ await handleAsyncAction("crm connections", options, () => {
5269
+ const params = new URLSearchParams();
5270
+ if (options.conversationLimit)
5271
+ params.set("conversation_limit", options.conversationLimit);
5272
+ if (options.sequenceLimit)
5273
+ params.set("sequence_limit", options.sequenceLimit);
5274
+ const query = params.toString();
5275
+ const base = `/api/cli/crm/objects/${encodeURIComponent(object)}/records/${encodeURIComponent(rowId)}/connections`;
5276
+ return requestOxygen(query ? `${base}?${query}` : base);
5277
+ });
5278
+ }))
5279
+ .addCommand(new Command("files")
5280
+ .description("Attach, list, download and remove files on a CRM record. The bytes live in the workspace file store, which owns dedupe, the per-file ceiling and the retained-bytes quota.")
5281
+ .addCommand(new Command("list")
5282
+ .description("List one record's attachments, newest first.")
5283
+ .argument("<object>", "CRM object slug, such as companies or people.")
5284
+ .argument("<record>", "CRM record row id, or an identity value such as acme.com or sarah@acme.com.")
5285
+ .option("--json", "Print a JSON envelope.")
5286
+ .action(async (object, rowId, options) => {
5287
+ await handleAsyncAction("crm files list", options, () => requestOxygen(`/api/cli/crm/objects/${encodeURIComponent(object)}/records/${encodeURIComponent(rowId)}/files`));
5288
+ }))
5289
+ .addCommand(new Command("attach")
5290
+ .description("Upload a local file and attach it to a CRM record. Attaching the same file twice is a no-op, not a duplicate.")
5291
+ .argument("<object>", "CRM object slug, such as companies or people.")
5292
+ .argument("<record>", "CRM record row id, or an identity value such as acme.com or sarah@acme.com.")
5293
+ .argument("<path>", "Local file path.")
5294
+ .option("--label <label>", "What to call it on this record. The stored file keeps its own filename.")
5295
+ .option("--json", "Print a JSON envelope.")
5296
+ .action(async (object, rowId, filePath, options) => {
5297
+ await handleAsyncAction("crm files attach", options, async () => {
5298
+ const { readFile } = await import("node:fs/promises");
5299
+ const { basename } = await import("node:path");
5300
+ const bytes = await readFile(filePath);
5301
+ return requestOxygen(`/api/cli/crm/objects/${encodeURIComponent(object)}/records/${encodeURIComponent(rowId)}/files`, {
5302
+ method: "POST",
5303
+ body: {
5304
+ name: basename(filePath),
5305
+ content_base64: bytes.toString("base64"),
5306
+ ...(readOption(options.label) ? { label: readOption(options.label) } : {}),
5307
+ },
5308
+ });
5309
+ });
5310
+ }))
5311
+ .addCommand(new Command("detach")
5312
+ .description("Remove a file from a record. Removes the LINK only \u2014 the stored file is kept, so anywhere else it is attached still has it.")
5313
+ .argument("<attachment_id>", "Attachment id from `crm files list`. Not the stored file id.")
5314
+ .option("--json", "Print a JSON envelope.")
5315
+ .action(async (attachmentId, options) => {
5316
+ await handleAsyncAction("crm files detach", options, () => requestOxygen(`/api/cli/crm/files/${encodeURIComponent(attachmentId)}`, {
5317
+ method: "DELETE",
5318
+ }));
5319
+ })))
5320
+ .addCommand(new Command("tasks")
5321
+ .description("Read and write a CRM record's tasks \u2014 the human to-do attached to a record. Distinct from `oxygen voice tasks`, which is the phone queue a rep dials from.")
5322
+ .addCommand(new Command("list")
5323
+ .description("List one record's tasks. Open work first, then by deadline; an undated task sorts last inside its group rather than first.")
5324
+ .argument("<object>", "CRM object slug, such as companies or people.")
5325
+ .argument("<record>", "CRM record row id, or an identity value such as acme.com or sarah@acme.com.")
5326
+ .option("--status <status>", "Filter to open, completed or cancelled.")
5327
+ .option("--limit <n>", "Maximum tasks to return. Defaults to 50.")
5328
+ .option("--cursor <cursor>", "Pagination cursor from a previous page.")
5329
+ .option("--json", "Print a JSON envelope.")
5330
+ .action(async (object, rowId, options) => {
5331
+ await handleAsyncAction("crm tasks list", options, () => requestOxygen(buildCrmTasksPath(object, rowId, options)));
5332
+ }))
5333
+ .addCommand(new Command("add")
5334
+ .description("Create a task on a CRM record. Free and internal, so it writes immediately rather than previewing first.")
5335
+ .argument("<object>", "CRM object slug, such as companies or people.")
5336
+ .argument("<record>", "CRM record row id, or an identity value such as acme.com or sarah@acme.com.")
5337
+ .argument("<title>", "What needs doing.")
5338
+ .option("--body <body>", "Longer description.")
5339
+ .option("--priority <priority>", "urgent, high, medium or low.")
5340
+ .option("--due <iso>", "ISO-8601 due timestamp. An unparseable value is rejected, never silently dropped.")
5341
+ .option("--assignee <user_id>", "Workspace member to assign it to. Unassigned means the team, not nobody.")
5342
+ .option("--json", "Print a JSON envelope.")
5343
+ .action(async (object, rowId, title, options) => {
5344
+ await handleAsyncAction("crm tasks add", options, () => requestOxygen(`/api/cli/crm/objects/${encodeURIComponent(object)}/records/${encodeURIComponent(rowId)}/tasks`, {
5345
+ method: "POST",
5346
+ body: {
5347
+ title,
5348
+ ...(readOption(options.body) ? { body: readOption(options.body) } : {}),
5349
+ ...(readOption(options.priority) ? { priority: readOption(options.priority) } : {}),
5350
+ ...(readOption(options.due) ? { due_at: readOption(options.due) } : {}),
5351
+ ...(readOption(options.assignee) ? { assignee_actor_id: readOption(options.assignee) } : {}),
5352
+ },
5353
+ }));
5354
+ }))
5355
+ .addCommand(new Command("update")
5356
+ .description("Change a task's title, body, status, priority, deadline or assignee. Addressed by task id because one task can be filed on several records.")
5357
+ .argument("<task_id>", "Task id.")
5358
+ .option("--title <title>", "Replacement title.")
5359
+ .option("--body <body>", "Replacement description. Pass an empty string to clear it.")
5360
+ .option("--status <status>", "open, completed or cancelled.")
5361
+ .option("--priority <priority>", "urgent, high, medium, low, or an empty string to clear it.")
5362
+ .option("--due <iso>", "ISO-8601 due timestamp, or an empty string to clear the deadline.")
5363
+ .option("--assignee <user_id>", "Workspace member, or an empty string to unassign.")
5364
+ .option("--json", "Print a JSON envelope.")
5365
+ .action(async (taskId, options) => {
5366
+ await handleAsyncAction("crm tasks update", options, () => {
5367
+ const body = readOption(options.body);
5368
+ const priority = readOption(options.priority);
5369
+ const due = readOption(options.due);
5370
+ const assignee = readOption(options.assignee);
5371
+ return requestOxygen(`/api/cli/crm/tasks/${encodeURIComponent(taskId)}`, {
5372
+ method: "PATCH",
5373
+ body: {
5374
+ ...(readOption(options.title) ? { title: readOption(options.title) } : {}),
5375
+ ...(readOption(options.status) ? { status: readOption(options.status) } : {}),
5376
+ ...(body !== undefined ? { body: body === "" ? null : body } : {}),
5377
+ ...(priority !== undefined ? { priority: priority === "" ? null : priority } : {}),
5378
+ ...(due !== undefined ? { due_at: due === "" ? null : due } : {}),
5379
+ ...(assignee !== undefined
5380
+ ? { assignee_actor_id: assignee === "" ? null : assignee }
5381
+ : {}),
5382
+ },
5383
+ });
5384
+ });
5385
+ }))
5386
+ .addCommand(new Command("complete")
5387
+ .description("Mark a task done. Completing an already-completed task keeps the original completion time and adds no second timeline entry.")
5388
+ .argument("<task_id>", "Task id.")
5389
+ .option("--json", "Print a JSON envelope.")
5390
+ .action(async (taskId, options) => {
5391
+ await handleAsyncAction("crm tasks complete", options, () => requestOxygen(`/api/cli/crm/tasks/${encodeURIComponent(taskId)}`, {
5392
+ method: "PATCH",
5393
+ body: { status: "completed" },
5394
+ }));
5395
+ }))
5396
+ .addCommand(new Command("reopen")
5397
+ .description("Reopen a completed task, clearing its completion stamp.")
5398
+ .argument("<task_id>", "Task id.")
5399
+ .option("--json", "Print a JSON envelope.")
5400
+ .action(async (taskId, options) => {
5401
+ await handleAsyncAction("crm tasks reopen", options, () => requestOxygen(`/api/cli/crm/tasks/${encodeURIComponent(taskId)}`, {
5402
+ method: "PATCH",
5403
+ body: { status: "open" },
5404
+ }));
5405
+ })))
5406
+ .addCommand(new Command("notes")
5407
+ .description("Read and write a CRM record's notes. A note is editable, pinnable and authored \u2014 and still lands on the record's timeline.")
5408
+ .addCommand(new Command("list")
5409
+ .description("List one record's notes, pinned first then newest. Address the record by row id OR by any of the object's identities.")
5410
+ .argument("<object>", "CRM object slug, such as companies or people.")
5411
+ .argument("<record>", "CRM record row id, or an identity value such as acme.com or sarah@acme.com.")
5412
+ .option("--limit <n>", "Maximum notes to return. Defaults to 25.")
5413
+ .option("--cursor <cursor>", "Pagination cursor from a previous page.")
5414
+ .option("--json", "Print a JSON envelope.")
5415
+ .action(async (object, rowId, options) => {
5416
+ await handleAsyncAction("crm notes list", options, () => requestOxygen(buildCrmNotesPath(object, rowId, options)));
5417
+ }))
5418
+ .addCommand(new Command("add")
5419
+ .description("Write a note on a CRM record. Free, internal, and editable afterwards, so it writes immediately rather than previewing first.")
5420
+ .argument("<object>", "CRM object slug, such as companies or people.")
5421
+ .argument("<record>", "CRM record row id, or an identity value such as acme.com or sarah@acme.com.")
5422
+ .argument("<body>", "Note text. Markdown.")
5423
+ .option("--title <title>", "Optional note title.")
5424
+ .option("--pin", "Pin the note to the top of the record's notes.")
5425
+ .option("--json", "Print a JSON envelope.")
5426
+ .action(async (object, rowId, body, options) => {
5427
+ await handleAsyncAction("crm notes add", options, () => requestOxygen(`/api/cli/crm/objects/${encodeURIComponent(object)}/records/${encodeURIComponent(rowId)}/notes`, {
5428
+ method: "POST",
5429
+ body: {
5430
+ body,
5431
+ ...(readOption(options.title) ? { title: readOption(options.title) } : {}),
5432
+ ...(options.pin ? { pinned: true } : {}),
5433
+ },
5434
+ }));
5435
+ }))
5436
+ .addCommand(new Command("update")
5437
+ .description("Edit a note's body, title or pin. Addressed by note id because one note can be filed on several records.")
5438
+ .argument("<note_id>", "Note id.")
5439
+ .option("--body <body>", "Replacement note text.")
5440
+ .option("--title <title>", "Replacement title. Pass an empty string to clear it.")
5441
+ .option("--pin", "Pin the note.")
5442
+ .option("--unpin", "Unpin the note.")
5443
+ .option("--json", "Print a JSON envelope.")
5444
+ .action(async (noteId, options) => {
5445
+ await handleAsyncAction("crm notes update", options, () => {
5446
+ const title = readOption(options.title);
5447
+ return requestOxygen(`/api/cli/crm/notes/${encodeURIComponent(noteId)}`, {
5448
+ method: "PATCH",
5449
+ body: {
5450
+ ...(readOption(options.body) ? { body: readOption(options.body) } : {}),
5451
+ ...(title !== undefined ? { title } : {}),
5452
+ ...(options.pin ? { pinned: true } : {}),
5453
+ ...(options.unpin ? { pinned: false } : {}),
5454
+ },
5455
+ });
5456
+ });
5457
+ }))
5458
+ .addCommand(new Command("delete")
5459
+ .description("Delete a note. Soft: the note stops showing its text while its place in the timeline stays, so the record still shows that a note was written and removed.")
5460
+ .argument("<note_id>", "Note id.")
5461
+ .option("--json", "Print a JSON envelope.")
5462
+ .action(async (noteId, options) => {
5463
+ await handleAsyncAction("crm notes delete", options, () => requestOxygen(`/api/cli/crm/notes/${encodeURIComponent(noteId)}`, { method: "DELETE" }));
5464
+ })))
5465
+ .addCommand(new Command("related")
5466
+ .description("Show the records linked to one CRM record, grouped by relationship with a true count per group. Address the record by row id OR by any of the object's identities \u2014 a company domain, a person's email or LinkedIn URL.")
5467
+ .argument("<object>", "CRM object slug, such as companies or people.")
5468
+ .argument("<record>", "CRM record row id, or an identity value such as acme.com or sarah@acme.com.")
5469
+ .option("--preview-limit <n>", "Linked records to preview per group. Defaults to 6, max 50. The count is always the real total.")
5470
+ .option("--json", "Print a JSON envelope.")
5471
+ .action(async (object, rowId, options) => {
5472
+ await handleAsyncAction("crm related", options, () => requestOxygen(buildCrmRelatedPath(object, rowId, options)));
4978
5473
  }))
4979
5474
  .addCommand(new Command("tag")
4980
5475
  .description("Add/remove workspace tags on one CRM record (Tags primitive — the same vocabulary as sequences, tables, conversations; see `oxygen tags list`). Delta semantics: --add unions, --remove subtracts, remove wins. Standard objects carry the tags attribute after `oxygen crm setup`.")
@@ -5481,6 +5976,20 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5481
5976
  const tablesCommand = program
5482
5977
  .command("tables")
5483
5978
  .description("Tenant workspace table commands.")
5979
+ .addCommand(new Command("sources")
5980
+ .description("List every way a table can start — imports, company/people/signal sources, webhooks, functions — with the provider count and per-row credit estimate behind each. Free; nothing is called or written. The same catalog the web New table picker shows.")
5981
+ .option("--group <group>", "Only one group: Imports, Companies, People, Signals, or Tables.")
5982
+ .option("--json", "Print a JSON envelope.")
5983
+ .action(async (options) => {
5984
+ await handleAsyncAction("tables sources", options, () => {
5985
+ const params = new URLSearchParams();
5986
+ const group = readOption(options.group);
5987
+ if (group)
5988
+ params.set("group", group);
5989
+ const qs = params.toString() ? `?${params.toString()}` : "";
5990
+ return requestOxygen(`/api/cli/tables/sources${qs}`);
5991
+ });
5992
+ }))
5484
5993
  .addCommand(new Command("create")
5485
5994
  .description("Create a real Postgres-backed workspace table. Free — 0 Oxygen credits. To create a table and import a file in one step, use `tables import --create <name>`.")
5486
5995
  .argument("<name>", "Display name for the table.")
@@ -5603,7 +6112,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5603
6112
  .addCommand(new Command("import")
5604
6113
  .description(`Import JSON, JSONL, CSV, or XLSX rows into a workspace table. The table write is free — 0 Oxygen credits. Files over ${LARGE_IMPORT_BACKGROUND_ROW_THRESHOLD} rows load durably in the background: the command returns a queued envelope and rows keep landing after it exits, so wait with the printed 'table-ingestions wait <id>' before reading row counts. ${IMPORT_FILE_LIMIT_HELP}`)
5605
6114
  .argument("[table]", "Table id or slug. Omit when using --create.")
5606
- .requiredOption("--file <path>", "Input file path.")
6115
+ .option("--file <path>", "Input file path. Pass this or --url.")
6116
+ .option("--url <https-link>", "Public link to fetch instead of a local file: a CSV, TSV, JSON, JSONL or XLSX file, or a Google Sheet shared with anyone-with-the-link or published to the web. Fetched server-side, up to 10 MB, 0 credits.")
6117
+ .option("--preview", "With --url: fetch and show the columns, a row sample and the row count without creating or writing anything.")
5607
6118
  .option("--format <format>", "json, jsonl, csv, or xlsx. Defaults from file extension.")
5608
6119
  .option("--sheet <name>", "Worksheet name for XLSX imports. Defaults to the first sheet.")
5609
6120
  .option("--create <name>", "Create a new table with columns inferred from the file before importing.")
@@ -5675,13 +6186,15 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5675
6186
  params.set("all", "true");
5676
6187
  const qs = params.toString() ? `?${params.toString()}` : "";
5677
6188
  const data = await requestOxygen(`/api/cli/tables${qs}`);
6189
+ // A capped list is where a blind agent filtered 200 rows locally to find
6190
+ // one table by name (acceptance run, 2026-09-18); name the native lookup.
5678
6191
  if (!options.json)
5679
- writeListCapNotice(data, "table(s)");
6192
+ writeListCapNotice(data, "table(s)", "find one by name with `workspace map --kind table --query <text>`");
5680
6193
  return data;
5681
6194
  });
5682
6195
  }))
5683
6196
  .addCommand(new Command("query")
5684
- .description("Query a workspace table by id or slug.")
6197
+ .description("Query a workspace table by id or slug. Output is a JSON envelope; for a typed, human-readable rendering of the same rows use `tables export <table> --format table`.")
5685
6198
  .argument("<table>", "Table id or slug.")
5686
6199
  .option("--limit <n>", "Maximum rows to return. Defaults to 100; hard cap is 1000.")
5687
6200
  .option("--cursor <cursor>", "Pagination cursor returned by a previous query. Walks forward one page at a time; mutually exclusive with --offset.")
@@ -5689,6 +6202,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5689
6202
  .option("--fields <columns>", "Comma-separated column keys or ids to include.")
5690
6203
  .option("--filter-json <json>", "Legacy filter object or array, e.g. '{\"column\":\"mobile_phone_e164\",\"op\":\"is_null\"}'. Mutually exclusive with --filter-tree-json/--sort-json.")
5691
6204
  .option("--filter-tree-json <json>", "Airtable-style filter group, e.g. '{\"type\":\"group\",\"conjunction\":\"and\",\"children\":[{\"type\":\"leaf\",\"columnKey\":\"stage\",\"operator\":\"is\",\"value\":\"won\"}]}'. Add \"path\":\"a.b\" to a leaf to match a key inside a JSON cell, e.g. the rows one Function run touched: {\"columnKey\":\"<function_column>\",\"path\":\"action_run_id\",\"operator\":\"is\",\"value\":\"<run_id>\"}. Mutually exclusive with --filter-json.")
6205
+ .option("--search <text>", "Find rows by one piece of text, matched against every text, number, date and checkbox column at once — the grid's magnifier. Combines with --filter-tree-json (both must hold). Use --filter-tree-json when you know which column to match.")
5692
6206
  .option("--formula-values <mode>", "Formula filters are refused by default because displayed formulas are evaluated live. Refresh the formula's stored cells with `columns run <table> <column> --force` (0 credits), then pass 'materialized'; filtering uses that stored snapshot and returns a freshness note.")
5693
6207
  .option("--sort-json <json>", "Ordered sort rules, e.g. '[{\"columnKey\":\"_created_at\",\"direction\":\"desc\"}]'. Earlier rules dominate. Mutually exclusive with --filter-json.")
5694
6208
  .option("--no-system-fields", "Omit _row_id, _created_at, and _updated_at from returned rows (included by default).")
@@ -5720,6 +6234,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5720
6234
  ...(readOption(options.fields) ? { fields: readCsvOption(options.fields) } : {}),
5721
6235
  ...(filters ? { filters } : {}),
5722
6236
  ...(filterTree ? { filterTree } : {}),
6237
+ ...(readOption(options.search) ? { search: readOption(options.search) } : {}),
5723
6238
  ...(formulaValues ? { formula_values: formulaValues } : {}),
5724
6239
  ...(sorts ? { sorts } : {}),
5725
6240
  ...(options.systemFields === false ? { include_system_fields: false } : {}),
@@ -5753,7 +6268,8 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5753
6268
  });
5754
6269
  }))
5755
6270
  .addCommand(new Command("describe")
5756
- .description("Describe a workspace table and its columns. Add --stats for row count and per-column fill rates (0 credits).")
6271
+ .alias("get")
6272
+ .description("Describe a workspace table and its columns. Add --stats for row count and per-column fill rates (0 credits). Next: `tools search --for-table <table>` lists what these columns can be enriched with and what each costs per row (0 credits).")
5757
6273
  .argument("<table>", "Table id or slug.")
5758
6274
  .option("--include-archived", "Include archived columns.")
5759
6275
  .option("--stats", "Include summary stats: exact row count and per-column fill rates. Exact at or under 100 rows; above that fill rates come from a 100-row sample (summary.statsSampled, summary.statsSampledRowCount) while rowCount stays exact. For whole-table fill rates on a bigger list use `tables preview <table> --summary-only` \u2014 exact up to 20,000 rows, 2,000-row sample above. 0 credits.")
@@ -6336,6 +6852,14 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6336
6852
  autoRunStatus: options.autoRunStatus,
6337
6853
  limit: options.limit,
6338
6854
  }))))));
6855
+ // One authoritative statement of the config shape, on the flag that stores
6856
+ // it. The previous copy — "(filters, sorts, columns, group). Usually written
6857
+ // by the app." — named the LEGACY key and disowned the flag; a blind-eval
6858
+ // agent (2026-09-20) followed it, wrote {"group":{"columnKey":...}}, and got a
6859
+ // board that grouped by accident. --group-by is the plain path; this is the
6860
+ // full one, and it says which key is the grouping key.
6861
+ const TABLE_VIEW_CONFIG_HELP = 'JSON view config, typed v2 shape: {"version":2,"groupBy":"<select column key>","filterTree":{...},"sorts":[{"columnKey":"...","direction":"asc|desc"}],"visibleColumns":["..."],"cardFields":["..."]}. '
6862
+ + 'groupBy is the kanban group-by column (--group-by sets it for you); a complete board config is \'{"version":2,"groupBy":"lifecycle_stage"}\'. Malformed v2 is rejected with invalid_view_config.';
6339
6863
  tablesCommand.addCommand(new Command("views")
6340
6864
  .description("Create and manage saved table views (grid / kanban configurations).")
6341
6865
  .addCommand(new Command("list")
@@ -6353,8 +6877,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6353
6877
  .description("Create a saved view.")
6354
6878
  .argument("<table>", "Table id or slug.")
6355
6879
  .requiredOption("--name <name>", "View name.")
6356
- .option("--view-type <type>", "table or kanban. Defaults to table.")
6357
- .option("--config <json>", "Advanced: raw JSON view config (filters, sorts, columns, group). Usually written by the app.")
6880
+ .option("--view-type <type>", "table (grid) or kanban (board split into lanes by a select/status column). Defaults to table; --group-by implies kanban. A kanban created without --group-by is not refused: the board renders on the table's first groupable column and every read reports that fallback as effective_group_by plus a warning, so pin the column with --group-by.")
6881
+ .option("--group-by <column>", "Kanban: the editable select/status column key the board splits on, e.g. --group-by lifecycle_stage. Unknown or non-groupable keys are refused with the groupable candidates. The response's effective_group_by is the column the board actually uses.")
6882
+ .option("--config <json>", TABLE_VIEW_CONFIG_HELP)
6358
6883
  .option("--default", "Make this the table's default view.")
6359
6884
  .option("--json", "Print a JSON envelope.")
6360
6885
  .action((table, options) => handleAsyncAction("tables views create", options, () => requestOxygen("/api/cli/tables/views", {
@@ -6363,6 +6888,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6363
6888
  table,
6364
6889
  name: readOption(options.name),
6365
6890
  ...(readOption(options.viewType) ? { view_type: readOption(options.viewType) } : {}),
6891
+ ...(readOption(options.groupBy) ? { group_by: readOption(options.groupBy) } : {}),
6366
6892
  ...(readOption(options.config)
6367
6893
  ? { config: parseJsonValue(readOption(options.config) ?? "", "--config") }
6368
6894
  : {}),
@@ -6374,8 +6900,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6374
6900
  .argument("<table>", "Table id or slug.")
6375
6901
  .argument("<view>", "View id.")
6376
6902
  .option("--name <name>", "Rename the view.")
6377
- .option("--view-type <type>", "table or kanban.")
6378
- .option("--config <json>", "Advanced: raw JSON view config. Replaces the stored config.")
6903
+ .option("--view-type <type>", "table (grid) or kanban (board). A kanban whose config names no group column renders on the first groupable column; reads report that fallback as effective_group_by plus a warning.")
6904
+ .option("--group-by <column>", "Kanban: change the select/status column the board splits on; folds into the stored config and keeps the rest of it. Refused for a grid view unless --view-type kanban is passed with it.")
6905
+ .option("--config <json>", `${TABLE_VIEW_CONFIG_HELP} Replaces the stored config.`)
6379
6906
  .option("--default", "Make this the table's default view.")
6380
6907
  .option("--position <n>", "0-based order among the table's views.")
6381
6908
  .option("--json", "Print a JSON envelope.")
@@ -6386,6 +6913,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6386
6913
  view,
6387
6914
  ...(readOption(options.name) ? { name: readOption(options.name) } : {}),
6388
6915
  ...(readOption(options.viewType) ? { view_type: readOption(options.viewType) } : {}),
6916
+ ...(readOption(options.groupBy) ? { group_by: readOption(options.groupBy) } : {}),
6389
6917
  ...(readOption(options.config)
6390
6918
  ? { config: parseJsonValue(readOption(options.config) ?? "", "--config") }
6391
6919
  : {}),
@@ -6539,18 +7067,26 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6539
7067
  .option("--json", "Print a JSON envelope.")
6540
7068
  .action((feed, options) => handleAsyncAction("feeds get", options, () => requestOxygen(feedsGetPath(feed, options)))))
6541
7069
  .addCommand(new Command("bind")
6542
- .description("Bind a pull feed to an existing table: freeze the provider request, then optionally arm a cadence so the table refills itself. Arming a cadence on a paid provider is a STANDING spend grant — it requires --every, --max-credits, and --approved together. Without --every the feed is stored disarmed and nothing runs until you set a cadence.")
6543
- .argument("<table>", "Table id or slug that receives the rows.")
6544
- .requiredOption("--kind <kind>", "Feed kind: company_search or signal_search.")
6545
- .requiredOption("--tool-id <tool>", "Provider tool id the cycle calls, e.g. blitzapi.job_search.")
6546
- .requiredOption("--request-json <json-or-file>", "Provider request JSON for the tool, inline or a path to a JSON file. Take it from the plan route's `request`.")
7070
+ .description("Bind a feed to a table: a scheduled provider harvest (pull), with --schema-json a typed-or-rejected push feed, or with --source rb2b the web-intent feed landing RB2B website-visit reveals as rows. A pull feed freezes the provider request into an existing table, then optionally arms a cadence so the table refills itself; arming a cadence on a paid provider is a STANDING spend grant — it requires --every, --max-credits, and --approved together, and without --every the feed is stored disarmed until you set one. A push feed is free: it returns a webhook URL and a secret shown once, refuses a delivery that does not match the schema with HTTP 422 (recorded in `oxygen feeds deliveries`, nothing written), and can create its table from the schema with --create <name>. `--source rb2b` is the web intent feed: OXYGEN already receives your RB2B website-visit reveals and records each one as a website_visit Signal, and this feed ALSO lands them as rows you can enrich, score, and sequence. It has no address, no schema, and no cadence to configure — the reveal shape is fixed — so binding is free and each reveal costs 0 credits on arrival. If RB2B is not connected to this workspace yet, rows only start once it is; there is no self-serve connect step yet.")
7071
+ .argument("[table]", "Table id or slug that receives the rows. Required for a pull feed; a push or web-intent feed takes it or --create <name>.")
7072
+ .option("--kind <kind>", "Pull feed, required. Feed kind: company_search or signal_search.")
7073
+ .option("--tool-id <tool>", "Pull feed, required. Provider tool id the cycle calls, e.g. blitzapi.job_search.")
7074
+ .option("--request-json <json-or-file>", "Pull feed, required. Provider request JSON for the tool, inline or a path to a JSON file. Take it from the plan route's `request`.")
7075
+ .option("--schema-json <json-or-file>", "Bind a PUSH feed instead: the JSON Schema (an object with type, properties, required, additionalProperties) every delivered row must match, inline or a path to a JSON file.")
7076
+ .option("--source <source>", `Bind a WEB INTENT push feed instead: ${FEED_BIND_WEB_INTENT_SOURCES.join(", ")} (rb2b = the website-visit reveals OXYGEN already receives for this workspace, also kept as website_visit Signals). Fixed row shape, no schema, no cadence, 0 credits per reveal. Takes a table or --create <name>, plus --name and --upsert-key.`)
7077
+ .option("--create <name>", "Push or web-intent feed only. Create a new table with this name — its columns derived from --schema-json, or the fixed reveal columns for --source rb2b — instead of binding an existing table.")
7078
+ .option("--items-path <path>", "Push feed only. Dot path to an array of row objects in the delivered payload.")
7079
+ .option("--event-id-path <path>", "Push feed only. Dot path to the event id. Defaults to event_id, eventId, id, or a payload hash.")
7080
+ .option("--event-type-path <path>", "Push feed only. Dot path to the event type. Defaults to type or event.")
7081
+ .option("--occurred-at-path <path>", "Push feed only. Dot path to the event timestamp.")
7082
+ .option("--auth-mode <mode>", "Push feed only. secret (default: senders include the returned secret in x-oxygen-table-webhook-secret) or none.")
6547
7083
  .option("--tool-ids <csv>", "Comma-separated cascade of tool ids to try in order. Defaults to just --tool-id.")
6548
7084
  .option("--rows-path <path>", "Dot path to the row array in the provider response.")
6549
7085
  .option("--row-mapping-json <json-or-file>", "Row mapping JSON: table column key -> provider field path.")
6550
7086
  .option("--cursor-path <path>", "Dot path to the next-page cursor in the provider response.")
6551
7087
  .option("--cursor-request-key <key>", "Request key that receives the next cursor.")
6552
7088
  .option("--max-pages <n>", "Maximum provider pages per cycle. Defaults to 1.")
6553
- .option("--upsert-key <key>", "Column key rows are upserted on, so a cycle updates instead of duplicating.")
7089
+ .option("--upsert-key <key>", "Column key rows are upserted on, so a cycle or a repeated delivery updates instead of duplicating.")
6554
7090
  .option("--name <name>", "Display name for the feed.")
6555
7091
  .option("--every <sugar>", CADENCE_FLAG_DESCRIPTION)
6556
7092
  .option("--max-credits <n>", "Credit ceiling PER sync cycle. Required with --approved to arm a cadence on a paid provider.")
@@ -6562,10 +7098,50 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6562
7098
  .option("--source-prompt <text-or-file>", "The prompt this feed was planned from, or a path to a prompt file.")
6563
7099
  .option("--approved", "Approve the recurring paid pull. Required with --every and --max-credits on a paid provider.")
6564
7100
  .option("--json", "Print a JSON envelope.")
6565
- .action((table, options) => handleAsyncAction("feeds bind", options, () => requestOxygen("/api/cli/tables/feeds", {
6566
- method: "POST",
6567
- body: readFeedBindBody(table, options),
6568
- }))))
7101
+ .addHelpText("after", `
7102
+ Examples:
7103
+ oxygen feeds bind signal-events --kind signal_search --tool-id blitzapi.job_search --request-json ./request.json --every daily@9 --max-credits 25 --approved
7104
+ oxygen feeds bind --create "Inbound leads" --schema-json '{"type":"object","properties":{"email":{"type":"string"},"score":{"type":"number"}},"required":["email"],"additionalProperties":false}' --upsert-key email
7105
+ oxygen feeds bind --source rb2b --create "Website visitors"
7106
+ `)
7107
+ .action((table, options, command) => {
7108
+ const webIntent = options.source !== undefined;
7109
+ const push = options.schemaJson !== undefined;
7110
+ // Pull usage errors keep commander's exact wording and path (stderr, exit 1),
7111
+ // raised before any request exactly as the former requiredOption/<table> did.
7112
+ if (!push && !webIntent)
7113
+ assertPullFeedBindArguments(table, options, command);
7114
+ return handleAsyncAction("feeds bind", options, async () => {
7115
+ if (webIntent) {
7116
+ const data = await requestOxygen("/api/cli/tables/feeds", {
7117
+ method: "POST",
7118
+ body: readFeedWebIntentBindBody(table, options),
7119
+ });
7120
+ // Nothing is withheld from stdout here (a web-intent feed has no
7121
+ // secret and no address); the receipt exists so a human reads the
7122
+ // table, the 0-credit cost, and what has to be true for rows to land.
7123
+ if (!options.json)
7124
+ writeFeedWebIntentBindReceipt(data);
7125
+ return data;
7126
+ }
7127
+ if (!push) {
7128
+ return requestOxygen("/api/cli/tables/feeds", {
7129
+ method: "POST",
7130
+ body: readFeedBindBody(table, options),
7131
+ });
7132
+ }
7133
+ const data = await requestOxygen("/api/cli/tables/feeds", {
7134
+ method: "POST",
7135
+ body: readFeedPushBindBody(table, options),
7136
+ });
7137
+ // Ahead of the payload, and only for a human: the secret is shown once,
7138
+ // so it must not scroll away inside the JSON. A machine caller reads it
7139
+ // off the envelope.
7140
+ if (!options.json)
7141
+ writeFeedPushBindReceipt(data);
7142
+ return data;
7143
+ });
7144
+ }))
6569
7145
  .addCommand(new Command("pause")
6570
7146
  .description("Pause a feed: the cron stays armed but every cycle it fires is refused, so nothing is fetched and nothing is billed until you resume. Free.")
6571
7147
  .argument("<feed>", "Feed id or endpoint id.")
@@ -7976,13 +8552,15 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7976
8552
  }));
7977
8553
  program
7978
8554
  .command("columns")
7979
- .description("Workspace table column commands. Ready-made templates (pricing pages, job posts, event pages, 10-Ks, website facts, market questions, text extraction, plus free 0-credit cleanups — company names, job titles, email type, text to list; person questions over an enriched LinkedIn profile and person appearances; company research questions — founders, parent company, funding, cloud provider, offers demos, industry, NAICS, HQ and footprint, LinkedIn/Crunchbase/careers page lookups): `columns catalog`, then `columns add <table> --prompt-key <key> --input <name>=<column>`. Column choice and provider routing guide: the oxygen-gtm skill's enriching-and-researching.md (`oxygen skills install`). With --json parse stdout only: the envelope is stdout, and the spend and cost notes (`estimated N credits for a live run`) are stderr, so merging the streams breaks the JSON.")
8555
+ .description("Workspace table column commands. Price first: `tools search --for-table <table>` lists every enrichment this table's columns support with its credit cost per row (0 credits). Ready-made templates (pricing pages, job posts, event pages, 10-Ks, website facts, market questions, text extraction, plus free 0-credit cleanups — company names, job titles, email type, text to list; person questions over an enriched LinkedIn profile and person appearances; company research questions — founders, parent company, funding, cloud provider, offers demos, industry, NAICS, HQ and footprint, LinkedIn/Crunchbase/careers page lookups): `columns catalog`, then `columns add <table> --prompt-key <key> --input <name>=<column>`. Column choice and provider routing guide: the oxygen-gtm skill's enriching-and-researching.md (`oxygen skills install`). With --json parse stdout only: the envelope is stdout, and the spend and cost notes (`estimated N credits for a live run`) are stderr, so merging the streams breaks the JSON.")
7980
8556
  .addCommand(new Command("add")
7981
8557
  .description("Add a nullable column to a workspace table. Writes the definition only — this never runs the column and never spends credits; use `columns run` for that, with --dry-run first to see the cost. For a page, posting or filing the row already links to, or a company fact Oxygen already knows how to research (founders, parent company, funding, cloud provider, offers demos, industry, NAICS, HQ, a LinkedIn or Crunchbase page), pick a template from `columns catalog` (--prompt-key) instead of writing a prompt; or read a URL column with --kind research --research-url. The guide is the oxygen-gtm skill's enriching-and-researching.md. A formula column needs no run at all: it evaluates on read from its current inputs, so `tables query` shows its values immediately.")
7982
8558
  .argument("<table>", "Table id or slug.")
7983
- .option("--preset <preset>", "Add a pre-built column bundle instead of one column: `person_enrich` (one LinkedIn profile lookup, then headline, bio, location and followers for free), `company_enrich` (domain, LinkedIn page, headcount, industry and full profile, cascading across providers until one answers), or `domain_check` (1 credit/row for a live/redirect/parked/not_found/dead verdict on every domain, cached 30 days, plus a free label column to filter on). For a single cleaned or classified column, use a template from `columns catalog` instead — that is one column, not a bundle. Oxygen finds the identity column itself — override with --input. Creating the columns is free; run them afterwards, --dry-run first.")
8559
+ .option("--preset <preset>", "Add a pre-built column bundle instead of one column, which is what --prompt-key adds: a preset writes SEVERAL columns at once and needs no --input on a table whose identity column it can find, while --prompt-key adds one template column and binds each input you name. Presets: `person_enrich` (one LinkedIn profile lookup, then first/last/full name, current company and its LinkedIn URL, job title, start date, education, school, headline, bio, location and followers as free formula columns; the person templates in `columns catalog --category people` all read its payload column), `company_enrich` (domain, LinkedIn page, headcount, industry and full profile, cascading across providers until one answers; binds to the person preset's current_company_linkedin_url automatically), `company_tech_stack` (the technologies a company runs, from its website and hiring, over the same cascade), `email_hashes` (SHA-256 and MD5 of each email for ad audiences, 0 credits), or `domain_check` (1 credit/row for a live/redirect/parked/not_found/dead verdict on every domain, cached 30 days, plus a free label column to filter on). For a single cleaned or classified column, use a template from `columns catalog` instead — that is one column, not a bundle. Oxygen finds the identity column itself — override with --input. Creating the columns is free; run them afterwards, --dry-run first.")
7984
8560
  .option("--input <slot=column...>", "Bind a preset or template input to an exact column, e.g. --input url=pricing_url with --prompt-key, or --input company_name=account --input domain=website with --preset. Repeatable. For a preset it is only needed when the automatic match is wrong or missing; for a template it names the column each declared input reads.", collectRepeatable, [])
7985
- .option("--capability <capability>", "Seed a ready-to-run enrichment column WITHOUT running it (0 credits): verify_email grades the address a row already holds \u2014 MillionVerifier first, catch-all domains escalate to BounceBan; work_email, mobile_phone and linkedin_url find a value the row is missing through the managed waterfall. Sets kind=enrichment and jsonb; label and key default from the capability. Preview cost with `enrich-column preview --capability <same>` and run later with `enrich-column run --approved --max-credits <n>`.")
8561
+ .option("--preset-fields <fields>", "For --preset company_enrich: comma-separated field keys to create instead of the default bundle (see `oxygen find company --list-fields` for the catalog and per-row cost), e.g. --preset-fields headcount,industry,funding_stage,latest_funding_round,competitors,technologies,job_openings. Free derived fields read the profile the bundle already fetches; each lane-priced field adds its own cost to the run. On a table that already has the bundle, the new fields are added to the existing company enrichment column rather than a second one.")
8562
+ .option("--check-technologies <names>", "For --preset company_enrich with the technology_check field: comma-separated technology names answered yes/no per row (e.g. hubspot,salesforce).")
8563
+ .option("--capability <capability>", "Seed a ready-to-run enrichment column WITHOUT running it (0 credits): verify_email grades the address a row already holds \u2014 MillionVerifier first, catch-all domains escalate to BounceBan; work_email, personal_email, mobile_phone and linkedin_url find a value the row is missing through the managed waterfall (personal_email is the non-work mailbox, graded by MillionVerifier before it is written). Sets kind=enrichment and jsonb; label and key default from the capability. Preview cost with `enrich-column preview --capability <same>` and run later with `enrich-column run --approved --max-credits <n>`.")
7986
8564
  .option("--label <label>", "Display label for the new column. Required unless --prompt-key or --capability supplies a default title.")
7987
8565
  .option("--key <key>", "Optional stable column key. Defaults to a normalized label.")
7988
8566
  .option("--data-type <type>", "Column data type: text, numeric, boolean, jsonb, or timestamptz.")
@@ -8063,6 +8641,12 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8063
8641
  presetBody.inputs = inputs;
8064
8642
  if (options.key)
8065
8643
  presetBody.payload_column = options.key;
8644
+ const presetFields = readCsvOption(options.presetFields);
8645
+ if (presetFields.length > 0)
8646
+ presetBody.fields = presetFields;
8647
+ const checkTechnologies = readCsvOption(options.checkTechnologies);
8648
+ if (checkTechnologies.length > 0)
8649
+ presetBody.check_technologies = checkTechnologies;
8066
8650
  return requestOxygen("/api/cli/tables/columns", {
8067
8651
  method: "POST",
8068
8652
  body: presetBody,
@@ -8211,7 +8795,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8211
8795
  });
8212
8796
  }))
8213
8797
  .addCommand(new Command("run")
8214
- .description("Run an executable AI, tool, formula, enrichment, bind, lookup, or local custom HTTP column for one row or a bounded batch. Paid server-side columns always run durably in the background. Bind create-mode (onNoMatch=create) needs --approved.")
8798
+ .description("Run an executable AI, tool, formula, enrichment, bind, lookup, or custom HTTP column for one row or a bounded batch. Paid server-side columns always run durably in the background. Bind create-mode (onNoMatch=create) needs --approved.")
8215
8799
  .argument("<table>", "Table id or slug.")
8216
8800
  .argument("<column>", "Column id or key.")
8217
8801
  .option("--row-id <row_id>", "Workspace row id to run. Get one from `oxygen tables query <table> --limit 1 --json` — the field is `_row_id`, not `id`.")
@@ -8221,12 +8805,12 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8221
8805
  .option("--formula-values <mode>", "With --filter-json on a formula column, first refresh that selector with `columns run <table> <column> --force` (0 credits), then pass 'materialized'. The run filters the stored snapshot and persists this freshness acknowledgement.")
8222
8806
  .option("--force", "Run even when the target cell already has a value. Formula columns compute when read, so `tables query` shows their values without a run; a run skips rows that already store a value (existing_value), and --force refreshes them.")
8223
8807
  .option("--connection-id <connection_id>", "Optional provider integration connection id.")
8224
- .option("--background", "Create a durable background run for a free deterministic column. Paid AI/tool/enrichment/custom-HTTP server runs are always backgrounded.")
8808
+ .option("--background", "Create a durable background run for a free deterministic column. Paid AI/tool/enrichment server runs are always backgrounded.")
8225
8809
  .option("--approved", "Confirm a paid durable run, or a bind create-mode run (onNoMatch=create), after inspecting a dry run or preview.")
8226
8810
  .option("--approved-effect-unknown", "With --row-id, allow rerunning a cell whose previous external write was dispatched but never confirmed. Verify the destination first: this can apply the same external effect twice.")
8227
8811
  .option("--max-credits <n>", "Maximum managed model/provider/web-grounding credits to reserve. BYOK covers the model call only; managed grounding still needs this ceiling.")
8228
8812
  .option("--max-concurrency <n>", "Maximum concurrent row items for a background run (1-250). Defaults to 250 for AI columns and 50 otherwise (160 for Firecrawl scrape columns).")
8229
- .option("--local", "Run a custom HTTP column in this CLI process so env-var secrets stay local.")
8813
+ .option("--local", "Run a custom HTTP column in this CLI process, where {env:...} secrets read your own machine's environment. Without it the column runs in the background on Oxygen and the same reference resolves from this workspace's registered custom integration.")
8230
8814
  .option("--local-concurrency <n>", "Maximum concurrent custom HTTP requests for --local. Defaults to 3.")
8231
8815
  .option("--dry-run", "Preview resolved model, credit estimate, run-condition posture, and — for an AI column — the prompt rendered with one real row's values, without spending any credits.")
8232
8816
  .option("--json", "Print a JSON envelope.")
@@ -8572,7 +9156,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8572
9156
  });
8573
9157
  }))
8574
9158
  .addCommand(new Command("catalog")
8575
- .description("List the column templates you can add with `columns add <table> --prompt-key <key>`: the extract templates (pricing-page, job-posting, event, 10-K, website-fact, market and text extractors) and the company research questions (founders, parent company, subsidiaries, funding rounds, market cap, cloud provider, is SaaS, offers demos, industry, NAICS, HQ / states / cities, competitors, LinkedIn / Crunchbase / Glassdoor / careers page lookups) and the person templates (--category people: skill set, grad school, location, job fit and current company read from the person_enrich payload for 15 credits; events, keynotes, podcasts and GitHub profile searched by name), each with the input it reads (bind it with --input <name>=<column_key>), the providers behind it, the answer fields, and the credits per row. Read-only and free.")
9159
+ .description("List the column templates you can add with `columns add <table> --prompt-key <key>`: the extract templates (pricing-page, job-posting, event, 10-K, website-fact, market and text extractors) and the company research questions (founders, parent company, subsidiaries, funding rounds, market cap, cloud provider, is SaaS, offers demos, industry, NAICS, HQ / states / cities, competitors, LinkedIn / Crunchbase / Glassdoor / careers page lookups) and the person templates (--category people: skill set, grad school, location, job fit and current company read from the person_enrich payload for 15 credits; events, keynotes, podcasts and GitHub profile searched by name), each with the input it reads (bind it with --input <name>=<column_key>), the providers behind it, the answer fields, and the credits per row. Read-only and free. Tool-backed columns that read a page or call a provider — e.g. Website contacts (emails and phones from each row's site) — are found with `oxygen tools search <words>`, not here. Provider waterfalls and enrichment bundles are a different list with their own per-row prices: `oxygen enrichment catalog`.")
8576
9160
  .option("--category <category>", "Only templates in one picker section: extract (page and text extraction), normalize, organize, summarize (free cleanups and classifications), people (person questions and appearances) or company (company research questions); comma-separate to combine (extract,people). The outreach prompts sit under copy, scoring, qa, research.")
8577
9161
  .option("--family <family>", "Only one family. Extract: pricing, job_posting, events, filings, site_facts, market, portfolio, text_extraction. Cleanups: company, person, email, lists, classification. Company: web_presence, corporate_structure, funding, footprint, business_model, tech, market. People: profile, appearances.")
8578
9162
  .option("--kind <kind>", "Only one column kind: research (reads the web or a page, answer with sources), search (a managed web search whose cell is a page URL or the result list, fixed price), ai (reads row text), or formula (free).")
@@ -9060,7 +9644,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9060
9644
  method: "POST",
9061
9645
  body: {
9062
9646
  table,
9063
- plan: parseJsonObject(readFileIfPresent(options.plan)),
9647
+ // Accepts the `search plan --json` envelope as-is, like the
9648
+ // companies/people/signals runs do: the inner `data` is the plan.
9649
+ plan: readSearchPlanJson(options.plan),
9064
9650
  request: parseJsonObject(options.requestJson),
9065
9651
  ...(readOption(options.route) ? { route_id: readOption(options.route) } : {}),
9066
9652
  ...(readOption(options.mode) ? { mode: readOption(options.mode) } : {}),
@@ -9137,19 +9723,24 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9137
9723
  .addCommand(new Command("search")
9138
9724
  .description("Plan, dry-run, or queue provider-backed company search.")
9139
9725
  .addCommand(new Command("filters")
9140
- .description("List Company Search filter fields and options without a provider call.")
9726
+ .description("List Company Search filter fields and options without a provider call, including every enum field's full accepted-value list. --source hiring lists the Job postings filters instead (job.* and company.*).")
9727
+ .option("--source <source>", "company_search (default) or hiring: live job postings for the roles you name, one row per posting with a link to it.")
9141
9728
  .option("--json", "Print a JSON envelope.")
9142
9729
  .action(async (options) => {
9143
- await handleAsyncAction("companies search filters", options, () => requestOxygen("/api/cli/companies/search/preview"));
9730
+ await handleAsyncAction("companies search filters", options, () => requestOxygen(`/api/cli/companies/search/preview${readCompanySourceQuery(options.source)}`));
9144
9731
  }))
9145
9732
  .addCommand(new Command("preview")
9146
- .description("Preview up to 50 companies free, using native filters from companies search filters.")
9733
+ .description("Preview up to 50 rows free, using native filters from companies search filters. With --source hiring the sample is the open roles matching job.title, one row per posting, and total_results is the exact number of matching postings.")
9147
9734
  .requiredOption("--filters-json <json-or-file>", 'Nested native filters as JSON or @file/path, e.g. {"employee_count":{"min":10}}; {} searches all.')
9735
+ .option("--source <source>", "company_search (default) or hiring. Filters for hiring nest under job and company, e.g. {\"job\":{\"title\":{\"include\":[\"Account Executive\"]}}}.")
9148
9736
  .option("--json", "Print a JSON envelope.")
9149
9737
  .action(async (options) => {
9150
9738
  await handleAsyncAction("companies search preview", options, () => requestOxygen("/api/cli/companies/search/preview", {
9151
9739
  method: "POST",
9152
- body: { filters: parseJsonObject(readFileIfPresent(options.filtersJson)) },
9740
+ body: {
9741
+ filters: parseJsonObject(readFileIfPresent(options.filtersJson)),
9742
+ ...(readCompanySearchSource(options.source) !== "company_search" ? { source: readCompanySearchSource(options.source) } : {}),
9743
+ },
9153
9744
  }));
9154
9745
  }))
9155
9746
  .addCommand(new Command("plan")
@@ -9181,8 +9772,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9181
9772
  }))
9182
9773
  .addCommand(new Command("run")
9183
9774
  .description("Return a dry-run request or queue a live company-search ingestion run.")
9184
- .option("--source <source>", "Use company_search with --source-filters-json instead of a prompt or plan.")
9185
- .option("--source-filters-json <json-or-file>", 'Same nested filters as the free preview, e.g. {"employee_count":{"min":10}}; use with --source company_search.')
9775
+ .option("--source <source>", "Use company_search or hiring with --source-filters-json instead of a prompt or plan. hiring sources the live job postings for the roles you name, one row per posting (up to 5,000 per search), each with its posting URL, date and hiring company.")
9776
+ .option("--source-filters-json <json-or-file>", 'Same nested filters as the free preview, e.g. {"employee_count":{"min":10}} or, with --source hiring, {"job":{"title":{"include":["Account Executive"]}}}.')
9777
+ .option("--table-name <name>", "Name the table this run creates. Ignored when --table names an existing table, which must already exist.")
9186
9778
  .argument("[prompt]", "Prompt text or @file (same as --prompt). The --prompt flag wins if both are given.")
9187
9779
  .option("--prompt <text-or-file>", "Company-search prompt, or a path to a prompt file.")
9188
9780
  .option("--plan-json <json-or-file>", "Plan JSON returned by companies search plan, or a path to a JSON file.")
@@ -9192,9 +9784,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9192
9784
  .option("--project <project>", "Folder (project) id or slug the created table lands in. Ignored with --table. Defaults to the workspace default folder.")
9193
9785
  .option("--upsert-key <column>", "Column key used for live upsert. Must match the plan upsert key, usually domain.")
9194
9786
  .option("--mode <mode>", "dry_run or live. Defaults to dry_run.")
9195
- .option("--max-pages <n>", "Maximum provider pages to ingest. Defaults to the route estimate.")
9787
+ .option("--max-pages <n>", "Maximum provider pages to ingest. Defaults to the route estimate, and is itself capped by --target-count: asking for more pages than that many rows needs cannot add rows.")
9196
9788
  .option("--max-credits <n>", "Required credit ceiling for live runs.")
9197
- .option("--target-count <n>", "Desired company count when planning from --prompt. Single-plan ceiling 50,000.")
9789
+ .option("--target-count <n>", "How many rows to source. Defaults to 50 — one page — so raise it to import more than the free sample, using the preview's total_results as the real ceiling. It bounds pagination: --max-pages can never exceed the pages this count needs, and a run that stops because of it reports stopped_by row_target.")
9198
9790
  .option("--source-intent <intent>", "Override detected intent when planning from --prompt.")
9199
9791
  .option("--filters-json <json-or-file>", "CompanySearchFilters JSON inline or a @file/path when planning from --prompt; wins over individual flags per top-level filter path.")
9200
9792
  .option("--industries <csv>", "Comma-separated industries to include when planning from --prompt.")
@@ -9222,7 +9814,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9222
9814
  .addCommand(new Command("preview")
9223
9815
  .description("Inspect missing company fields, provider routing, and credit estimates without provider calls.")
9224
9816
  .argument("<table>", "Table id or slug.")
9225
- .option("--missing-fields <fields>", "Comma-separated fields to fill: domain,linkedin_url,headcount,industry,funding,technologies,hiring_signals,company_profile.")
9817
+ .option("--missing-fields <fields>", "Comma-separated fields to fill; accepts every key `oxygen find company --list-fields` prints, not just the identity set. Derived fields (description, founded_year, hq_country, funding_stage, latest_funding_round, investors, job_openings, parent_company, subsidiaries) are read from a payload the run already fetches and add no provider lane of their own.")
9226
9818
  .option("--providers <providers>", "Comma-separated provider order pool. Defaults to scraper,blitzapi,crustdata,ai_ark,prospeo,leadmagic.")
9227
9819
  .option("--all", "Preview all rows.")
9228
9820
  .option("--limit <n>", "Preview a limited row scope.")
@@ -9262,6 +9854,22 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9262
9854
  .description("People and contact prospecting workflows.")
9263
9855
  .addCommand(new Command("search")
9264
9856
  .description("Plan, dry-run, or queue provider-backed people/contact search.")
9857
+ .addCommand(new Command("filters")
9858
+ .description("List Find people filter fields and options without a provider call: people.* (title, level, function, location, education) and company.* (industry, headcount, location, funding, up to 50 company LinkedIn URLs). Every enum field ships its full accepted-value list, so no value has to be guessed. Shape rule: only a field whose own key ends in .include or .exclude takes {include, exclude} — every other list field takes the strings directly. To search the companies in a table you already have, rather than pasting their URLs here, use people search plan|run --from-table <table> --linkedin-url-column <key>, which searches each company and writes a company relation back.")
9859
+ .option("--json", "Print a JSON envelope.")
9860
+ .action(async (options) => {
9861
+ await handleAsyncAction("people search filters", options, () => requestOxygen("/api/cli/people/search/preview"));
9862
+ }))
9863
+ .addCommand(new Command("preview")
9864
+ .description("Preview up to 50 people free, using native filters from people search filters. Then people search run --source people_search --source-filters-json <same filters> for a quoted, approved table fill. Scoping to companies you already keep in a table is people search run --from-table instead — one search per company, so it costs per company but covers each one.")
9865
+ .requiredOption("--filters-json <json-or-file>", 'Nested native filters as JSON or @file/path, e.g. {"people":{"job_title":{"include":["VP Sales"]}},"company":{"linkedin_url":["https://linkedin.com/company/acme"]}}; {} searches all.')
9866
+ .option("--json", "Print a JSON envelope.")
9867
+ .action(async (options) => {
9868
+ await handleAsyncAction("people search preview", options, () => requestOxygen("/api/cli/people/search/preview", {
9869
+ method: "POST",
9870
+ body: { filters: parseJsonObject(readFileIfPresent(options.filtersJson)) },
9871
+ }));
9872
+ }))
9265
9873
  .addCommand(new Command("plan")
9266
9874
  .description("Compile a people-search prompt and optional typed persona filters into ordered provider routes without provider calls.")
9267
9875
  .option("--prompt <text-or-file>", "People-search prompt, or a path to a prompt file. Required unless --from-table names the companies to source employees from.")
@@ -9302,6 +9910,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9302
9910
  }))
9303
9911
  .addCommand(new Command("run")
9304
9912
  .description("Return a dry-run request or queue a live people-search ingestion run. Upsert dedup is on linkedin_url.")
9913
+ .option("--source <source>", "Use people_search with --source-filters-json instead of a prompt or plan: the same filters as people search preview, no prompt needed.")
9914
+ .option("--source-filters-json <json-or-file>", 'Same nested filters as the free preview, e.g. {"people":{"job_title":{"include":["VP Sales"]}}}; use with --source people_search.')
9915
+ .option("--table-name <name>", "Name the table this run creates. Ignored when --table names an existing table, which must already exist.")
9305
9916
  .option("--prompt <text-or-file>", "People-search prompt, or a path to a prompt file.")
9306
9917
  .option("--plan-json <json-or-file>", "Plan JSON returned by people search plan, or a path to a JSON file.")
9307
9918
  .option("--route-id <id>", "Route id from the plan to execute.")
@@ -9458,7 +10069,11 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9458
10069
  .description("Plan and managed credit commands. Spend splits into FLEXIBLE (ad-hoc: enrichment, AI, automation — drawn from your free-to-spend balance) and FIXED recurring per-resource monthly commitments blocked out of it; see `billing commitments`. THREE CLOCKS, deliberately different: the CREDIT CYCLE that `billing allowance` reports against (your plan's monthly grant window); each resource's own COMMITMENT RENEWAL, anchored to the day you connected it, so `billing commitments --json` next_due_at rarely matches the cycle end; and your SUBSCRIPTION PERIOD in `billing balance` (annual plans span many credit cycles). A number from one clock will not reconcile against another. Failed-payment grace, suspension, and recovery: https://oxygen-agent.com/docs/safety/billing.")
9459
10070
  .addCommand(new Command("change")
9460
10071
  .description("Preview an upgrade or downgrade and return a Stripe confirmation link. Nothing changes until confirmed in Stripe.")
9461
- .requiredOption("--to <tier>", "Target plan: starter, pro, or team.")
10072
+ .requiredOption("--to <plan>",
10073
+ // Derived from the catalog, never retyped: this help text is the only
10074
+ // place a customer's agent learns which plans exist, and it named
10075
+ // three retired ones for as long as it was a literal.
10076
+ `Target plan: ${PURCHASABLE_PLAN_KEYS.join(", ")}.`)
9462
10077
  .option("--json", "Print a JSON envelope.")
9463
10078
  .action(async (options) => {
9464
10079
  await handleAsyncAction("billing change", options, () => requestOxygen("/api/cli/billing/change", {
@@ -9647,9 +10262,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9647
10262
  }));
9648
10263
  }))
9649
10264
  .addCommand(new Command("topup")
9650
- .description("Buy a custom amount of on-demand credits at $1.25 per 1,000. Run without an amount to inspect the allowed range; checkout happens in Stripe and purchased credits never expire.")
10265
+ .description("Buy a custom amount of on-demand credits at $1.25 per 100. Run without an amount to inspect the allowed range; checkout happens in Stripe and purchased credits never expire.")
9651
10266
  .argument("[pack]", "Legacy pack alias in USD: 10, 25, 100, or 250.")
9652
- .option("--credits <n>", "Credits to buy (8,000-1,000,000 in 1,000-credit increments).")
10267
+ .option("--credits <n>", "Credits to buy (800-100,000 in 100-credit increments).")
9653
10268
  .option("--json", "Print a JSON envelope.")
9654
10269
  .action(async (pack, options) => {
9655
10270
  const credits = readOption(options.credits);
@@ -10999,13 +11614,13 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
10999
11614
  }));
11000
11615
  program
11001
11616
  .command("tools")
11002
- .description("Tool catalog commands.")
11617
+ .description("The priced catalog: every provider operation and every OXYGEN column (enrichment bundles, waterfalls, templates, Functions) with its credit cost per row. Start with `tools search --for-table <table>` to see what a table's own columns can be enriched with and what each costs, before adding anything.")
11003
11618
  .addCommand(new Command("search")
11004
- .description("Search a bounded, compact provider-operation catalog; hydrate one result with tools get. A response listing partial_sources means an optional catalog source timed out and totals may be understated — rerun for the complete catalog.")
11619
+ .description("Search the priced catalog (0 credits): provider operations in `tools`, and OXYGEN's own columns in `native_columns` (enrichment bundles, waterfalls, templates, Functions), each with `price_label`, `estimated_credits_per_row`, `pricing_kind` and the exact `cli_add` command. With --for-table <table> it reads that table's columns and returns `suggestions`: the enrichments those columns already support, priced per row. Hydrate one provider operation with tools get. A response listing partial_sources means an optional catalog source timed out and totals may be understated — rerun for the complete catalog.")
11005
11620
  .argument("[query]", "Search text.")
11006
11621
  .option("--verbosity <verbosity>", "minimal, summary, or full. Defaults to minimal; hydrate one result with tools get.")
11007
11622
  .option("--terse", "Alias for --verbosity minimal.")
11008
- .option("--all", "Return the complete matching catalog. Explicit because the default is bounded to 10.")
11623
+ .option("--all", "Return the complete matching catalog — with no query that is every provider operation (7,000+ rows). Explicit because the default is bounded to 10; with --for-table the table-scoped answer is `suggestions`, which --all does not change.")
11009
11624
  .option("--only-runnable", "Only return tools runnable by the active organization.")
11010
11625
  .option("--workflow-eligible", "Only return tools a hosted workflow step may call, each annotated with the canonical `workflow_effect` its manifest step must declare. This is a narrower set than the table-column catalog — use it when authoring a workflow manifest so lint cannot reject a tool at apply time.")
11011
11626
  .option("--no-access-check", "Skip per-tool availability checks for a fast complete-catalog listing. Tools are returned without availability info; pair with --terse for discovery sweeps.")
@@ -11014,11 +11629,21 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
11014
11629
  .option("--provider <provider>", "Filter to one exact provider id, such as blitzapi.")
11015
11630
  .option("--limit <n>", "Maximum number of tools to return. Capped at 100.")
11016
11631
  .option("--providers", "Also return a per-vendor summary (provider id, display name, tool count) alongside the tools.")
11632
+ .option("--for-table <table>", "Table id or slug. Prices Oxygen's own column kinds, presets and waterfalls (native_columns) for that table's columns and lists deterministic suggestions first — what you can enrich here and what each costs per row, before adding anything. `suggestions` (cross-referenced into native_columns) is the table-scoped answer; `tools` stays the general provider catalog, so leave --all off unless you want every provider operation.")
11633
+ .option("--favorites", "Only the catalog entries you favourited for this workspace (see tools favorite).")
11634
+ .option("--table-runnable", "Only tools a table column can run.")
11017
11635
  .option("--json", "Print a JSON envelope.")
11018
11636
  .action(async (query, options) => {
11019
11637
  await handleAsyncAction("tools search", options, async () => {
11020
11638
  const params = new URLSearchParams();
11021
11639
  params.set("query", query ?? "");
11640
+ const forTable = readOption(options.forTable);
11641
+ if (forTable)
11642
+ params.set("for_table", forTable);
11643
+ if (options.favorites)
11644
+ params.set("favorites", "true");
11645
+ if (options.tableRunnable)
11646
+ params.set("table_runnable", "true");
11022
11647
  const verbosity = options.terse ? "minimal" : readOption(options.verbosity);
11023
11648
  if (verbosity)
11024
11649
  params.set("verbosity", verbosity);
@@ -11053,6 +11678,32 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
11053
11678
  .option("--json", "Print a JSON envelope.")
11054
11679
  .action(async (toolId, options) => {
11055
11680
  await handleAsyncAction("tools get", options, () => requestOxygen(`/api/cli/tools/${encodeURIComponent(toolId)}`));
11681
+ }))
11682
+ .addCommand(new Command("favorite")
11683
+ .description("Favourite one column-catalog entry for this workspace: a native column (find_email, preset:person_enrich, prompt:icp_fit_score_v1) or a vendor tool (tool:<tool_id>). Favourites lead every table's + Add column panel and `tools search --favorites`.")
11684
+ .argument("<entry_id>", "Catalog entry id, e.g. find_email, preset:person_enrich, prompt:<key>, tool:<tool_id>.")
11685
+ .option("--json", "Print a JSON envelope.")
11686
+ .action(async (entryId, options) => {
11687
+ await handleAsyncAction("tools favorite", options, () => requestOxygen("/api/cli/tools/favorites", {
11688
+ method: "POST",
11689
+ body: { entry_id: entryId, favorite: true },
11690
+ }));
11691
+ }))
11692
+ .addCommand(new Command("unfavorite")
11693
+ .description("Remove one column-catalog entry from your favourites for this workspace.")
11694
+ .argument("<entry_id>", "Catalog entry id, as listed by tools favorites.")
11695
+ .option("--json", "Print a JSON envelope.")
11696
+ .action(async (entryId, options) => {
11697
+ await handleAsyncAction("tools unfavorite", options, () => requestOxygen("/api/cli/tools/favorites", {
11698
+ method: "POST",
11699
+ body: { entry_id: entryId, favorite: false },
11700
+ }));
11701
+ }))
11702
+ .addCommand(new Command("favorites")
11703
+ .description("List your favourite column-catalog entries for this workspace, with each native entry's per-row price.")
11704
+ .option("--json", "Print a JSON envelope.")
11705
+ .action(async (options) => {
11706
+ await handleAsyncAction("tools favorites", options, () => requestOxygen("/api/cli/tools/favorites"));
11056
11707
  }))
11057
11708
  .addCommand(new Command("enums")
11058
11709
  .description("Provider enum catalogs for fields that accept normalized values.")
@@ -11117,28 +11768,32 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
11117
11768
  // 24.94", so an integer reader would refuse the very command the CLI
11118
11769
  // just told the operator to run.
11119
11770
  const maxCredits = readNonNegativeNumber(options.maxCredits);
11120
- await handleAsyncAction("tools run", options, () => requestOxygen("/api/cli/tools/run", {
11121
- method: "POST",
11122
- body: {
11123
- tool_id: toolId,
11124
- input: parseJsonObject(options.inputJson),
11125
- ...(readOption(options.mode) ? { mode: readOption(options.mode) } : {}),
11126
- ...(readOption(options.credentialMode) ? { credential_mode: readOption(options.credentialMode) } : {}),
11127
- ...(readOption(options.org) ? { org_id: readOption(options.org) } : {}),
11128
- ...(readOption(options.orgId) ? { org_id: readOption(options.orgId) } : {}),
11129
- ...(readOption(options.connectionId) ? { connection_id: readOption(options.connectionId) } : {}),
11130
- ...(readOption(options.fields) ? { fields: readCsvOption(options.fields) } : {}),
11131
- ...(readOption(options["return"]) ? { return: readOption(options["return"]) } : {}),
11132
- ...(readOption(options.returnMode) ? { return_mode: readOption(options.returnMode) } : {}),
11133
- ...(readOption(options.oxygenCursor) ? { oxygen_cursor: readOption(options.oxygenCursor) } : {}),
11134
- ...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
11135
- ...(options.approved ? { approved: true } : {}),
11136
- },
11137
- }));
11771
+ await handleAsyncAction("tools run", options, async () => {
11772
+ const data = await requestOxygen("/api/cli/tools/run", {
11773
+ method: "POST",
11774
+ body: {
11775
+ tool_id: toolId,
11776
+ input: parseJsonObject(options.inputJson),
11777
+ ...(readOption(options.mode) ? { mode: readOption(options.mode) } : {}),
11778
+ ...(readOption(options.credentialMode) ? { credential_mode: readOption(options.credentialMode) } : {}),
11779
+ ...(readOption(options.org) ? { org_id: readOption(options.org) } : {}),
11780
+ ...(readOption(options.orgId) ? { org_id: readOption(options.orgId) } : {}),
11781
+ ...(readOption(options.connectionId) ? { connection_id: readOption(options.connectionId) } : {}),
11782
+ ...(readOption(options.fields) ? { fields: readCsvOption(options.fields) } : {}),
11783
+ ...(readOption(options["return"]) ? { return: readOption(options["return"]) } : {}),
11784
+ ...(readOption(options.returnMode) ? { return_mode: readOption(options.returnMode) } : {}),
11785
+ ...(readOption(options.oxygenCursor) ? { oxygen_cursor: readOption(options.oxygenCursor) } : {}),
11786
+ ...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
11787
+ ...(options.approved ? { approved: true } : {}),
11788
+ },
11789
+ });
11790
+ writeToolRunBlockedNotice(data);
11791
+ return data;
11792
+ });
11138
11793
  }));
11139
11794
  program
11140
11795
  .command("enrich-column")
11141
- .description("High-level table enrichment helpers.")
11796
+ .description("High-level table enrichment helpers. To see every enrichment a table's columns support, with per-row prices, before choosing one, run `tools search --for-table <table>` (0 credits).")
11142
11797
  .addCommand(new Command("preview")
11143
11798
  .description("Preflight an enrichment column without provider calls or credit usage.")
11144
11799
  .argument("<table>", "Table id or slug.")
@@ -11151,7 +11806,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
11151
11806
  .option("--company-domain-column <column>", "Column key or id containing the company domain for work_email. Pair with full-name/first+last for name+company providers.")
11152
11807
  .option("--company-name-column <column>", "Column key or id containing the company name for work_email when no domain is available.")
11153
11808
  .option("--company-linkedin-url-column <column>", "Column key or id containing the company's LinkedIn URL for company identity fallback.")
11154
- .option("--capability <capability>", "What to run: mobile_phone, work_email, linkedin_url (find a missing value), or verify_email (grade an email the row already has). Defaults to mobile_phone.")
11809
+ .option("--capability <capability>", "What to run: mobile_phone, work_email, personal_email, linkedin_url (find a missing value), or verify_email (grade an email the row already has). Defaults to mobile_phone.")
11155
11810
  .option("--target-column <column>", "Target enrichment column key. Defaults to the capability payload column.")
11156
11811
  .option("--on-existing-manual-column <mode>", "How to handle an existing manual target: error, write_if_empty, or create_enrichment_column.")
11157
11812
  .option("--provider-order <providers>", "Comma-separated provider order. Overrides the default cost-aware waterfall profile.")
@@ -11186,7 +11841,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
11186
11841
  .option("--company-name-column <column>", "Column key or id containing the company name for work_email when no domain is available.")
11187
11842
  .option("--company-linkedin-url-column <column>", "Column key or id containing the company's LinkedIn URL for company identity fallback.")
11188
11843
  .requiredOption("--max-credits <credits>", "Required credit ceiling for the queued run.")
11189
- .option("--capability <capability>", "What to run: mobile_phone, work_email, linkedin_url (find a missing value), or verify_email (grade an email the row already has). Defaults to mobile_phone.")
11844
+ .option("--capability <capability>", "What to run: mobile_phone, work_email, personal_email, linkedin_url (find a missing value), or verify_email (grade an email the row already has). Defaults to mobile_phone.")
11190
11845
  .option("--target-column <column>", "Target enrichment column key. Defaults to the capability payload column.")
11191
11846
  .option("--on-existing-manual-column <mode>", "How to handle an existing manual target: error, write_if_empty, or create_enrichment_column.")
11192
11847
  .option("--provider-order <providers>", "Comma-separated provider order. Overrides the default cost-aware waterfall profile.")
@@ -11234,6 +11889,19 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
11234
11889
  .option("--json", "Print a JSON envelope.")
11235
11890
  .action(async (options) => {
11236
11891
  await handleAsyncAction("find email", options, () => requestOxygen("/api/cli/find/run", { method: "POST", body: buildFindBody("email", options) }));
11892
+ }))
11893
+ .addCommand(new Command("personal-email")
11894
+ .description("Find a person's personal (non-work) email — for ad-audience uploads, recruiting, or founder outreach — via a cost-ordered multi-provider waterfall that bills only on a hit and grades the address with MillionVerifier before returning it. Every default lane keys on a LinkedIn URL; ContactOut also accepts a known email or full name + company. dry_run previews the chain and its price for free.")
11895
+ .option("--linkedin-url <url>", "Person LinkedIn profile URL (strongest signal; every default lane accepts it).")
11896
+ .option("--email <email>", "Known email as an identity fallback (ContactOut lane).")
11897
+ .option("--full-name <name>", "Person full name (with a company domain or name; ContactOut lane).")
11898
+ .option("--company-domain <domain>", "Company apex domain, e.g. acme.com.")
11899
+ .option("--company-name <name>", "Company name.")
11900
+ .option("--mode <mode>", "dry_run (default) previews the plan and spends nothing; live spends credits and returns the answer.")
11901
+ .option("--max-credits <credits>", "Spend ceiling. Required for --mode live. Run the free dry_run first: it returns estimate.recommended_max_credits, the ceiling that lets the WHOLE resolved waterfall run, plus estimate.min_credits and a per-leg breakdown. A lower ceiling does NOT stop the run: each lane priced above what is left of it is refused on its own (spend_cap_too_low) and the waterfall advances, so a cheaper lane further down the chain can still run and bill.")
11902
+ .option("--json", "Print a JSON envelope.")
11903
+ .action(async (options) => {
11904
+ await handleAsyncAction("find personal-email", options, () => requestOxygen("/api/cli/find/run", { method: "POST", body: buildFindBody("personal_email", options) }));
11237
11905
  }))
11238
11906
  .addCommand(new Command("phone")
11239
11907
  .description("Find a person's mobile phone via a multi-provider waterfall. The provider chain is input-aware: a LinkedIn URL unlocks the full cost-ordered chain (Blitz first), email/name use the providers that accept them.")
@@ -11261,13 +11929,33 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
11261
11929
  .option("--json", "Print a JSON envelope.")
11262
11930
  .action(async (options) => {
11263
11931
  await handleAsyncAction("find linkedin", options, () => requestOxygen("/api/cli/find/run", { method: "POST", body: buildFindBody("linkedin", options) }));
11932
+ }))
11933
+ .addCommand(new Command("person")
11934
+ .description("Enrich one person from a LinkedIn URL, a LinkedIn public id, a Sales Navigator link, or an email / name + company: one managed profile lookup returns the profile block — first, last and full name, current company and its LinkedIn URL, job title, job start date, education and school, headline, bio, location and follower count — the same fields the person_enrich table preset extracts for free. An email or name is first resolved to the profile through the LinkedIn URL waterfall (priced as an identity prerequisite). A Sales Navigator link or member id resolves only on the workspace's own HarvestAPI key. dry_run previews the lookup and its price for free.")
11935
+ .option("--linkedin-url <url>", "Person LinkedIn profile URL (linkedin.com/in/<handle>).")
11936
+ .option("--linkedin-id <id>", "LinkedIn public identifier (the part after /in/), or an ACoAA/ACwAA member id (BYOK HarvestAPI only).")
11937
+ .option("--sales-navigator-url <url>", "Sales Navigator lead or people link; resolved on the workspace's own HarvestAPI key.")
11938
+ .option("--email <email>", "Known email; resolved to the profile through the LinkedIn URL waterfall first.")
11939
+ .option("--full-name <name>", "Person full name; with --company-domain or --company-name, resolved through the LinkedIn URL waterfall first.")
11940
+ .option("--first-name <name>", "Person first name.")
11941
+ .option("--last-name <name>", "Person last name.")
11942
+ .option("--company-domain <domain>", "Company apex domain, e.g. acme.com.")
11943
+ .option("--company-name <name>", "Company name.")
11944
+ .option("--include-raw", "Also return the provider's raw profile payload (profile_raw) for fields the profile block does not carry.")
11945
+ .option("--mode <mode>", "dry_run (default) previews the plan and spends nothing; live spends credits and returns the profile.")
11946
+ .option("--max-credits <credits>", "Spend ceiling. Required for --mode live. Run the free dry_run first: estimate.recommended_max_credits funds the profile lookup plus, for an email or name input, every lane of the LinkedIn URL waterfall that resolves it.")
11947
+ .option("--json", "Print a JSON envelope.")
11948
+ .action(async (options) => {
11949
+ await handleAsyncAction("find person", options, () => requestOxygen("/api/cli/find/run", { method: "POST", body: buildFindBody("person", options) }));
11264
11950
  }))
11265
11951
  .addCommand(new Command("company")
11266
- .description("Enrich a company (domain/linkedin/headcount/industry/funding/tech/hiring/profile) via a waterfall.")
11952
+ .description("Enrich a company via a waterfall: identity, firmographics, structured funding, competitors, corporate structure, acquisitions, news, job openings, tech stack, web-presence URLs. `--list-fields` prints the catalog with per-field cost.")
11267
11953
  .option("--domain <domain>", "Company apex domain.")
11268
11954
  .option("--name <name>", "Company name.")
11269
11955
  .option("--linkedin-url <url>", "Company LinkedIn URL.")
11270
- .option("--fields <fields>", "Comma-separated company fields. Defaults to domain,linkedin_url,headcount,industry.")
11956
+ .option("--fields <fields>", "Comma-separated field keys from --list-fields (e.g. headcount,industry,funding_stage,latest_funding_round,competitors,technologies,job_openings). Defaults to domain,linkedin_url,headcount,industry,description,founded_year,hq_country — the identity plus everything one profile lookup answers for free. Unknown keys are refused, never silently dropped.")
11957
+ .option("--list-fields", "Print the field catalog (category, description, provider count, per-row credits: free / fixed / ~estimated) and exit. Needs no identity; spends nothing.")
11958
+ .option("--check-technologies <names>", "Comma-separated technology names for the technology_check field, answered yes/no against the detected stack (e.g. hubspot,salesforce).")
11271
11959
  .option("--mode <mode>", "dry_run (default) previews the plan and spends nothing; live spends credits and returns the answer.")
11272
11960
  .option("--max-credits <credits>", "Spend ceiling. Required for --mode live; 0 runs only the zero-credit lanes (the priced lanes are skipped as credit_ceiling_reached). Run the free dry_run first: it returns estimate.recommended_max_credits, the ceiling that funds every lane in the plan, and estimate.min_credits, what the plan costs if each field is answered by its first lane.")
11273
11961
  .option("--json", "Print a JSON envelope.")
@@ -11298,7 +11986,32 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
11298
11986
  }));
11299
11987
  program
11300
11988
  .command("enrichment")
11301
- .description("High-level enrichment column definition helpers.")
11989
+ .description("The enrichment catalogue: name the DATA (work email, phone, headcount, funding, tech stack, decision makers), see its status, expected vs up-to credits per row, coverage and the exact add command — never pick a provider by hand.")
11990
+ .addCommand(new Command("catalog")
11991
+ .description("Name the DATA, not the provider: list every data type you can ask a table to fill (person profile, work email, phone, headcount, funding, tech stack, hiring, competitors, news, decision makers, signals ...) as `intents`, each with its status (default | selectable | no_default | coming_soon | rejected), the inputs it needs, the fields it fills, region coverage bands (vendor claims until measured), the provider order behind it as metadata, a per-row credit estimate (expected on measured-or-assumed hit rates, plus the up-to ceiling) and the exact add command on CLI, MCP and web. `entries` keeps the column presets, capabilities and recipes. Read this before `columns add --preset` or `--capability`; 0 credits. (Extract column templates have their own `columns catalog`.)")
11992
+ .option("--intent <id>", "One intent id, e.g. find_work_email, enrich_company:funding, people_search:account_contacts.")
11993
+ .option("--group <group>", "Filter intents to one group: person, contact, company, decision_maker, or signal.")
11994
+ .option("--region <region>", "Show the coverage band for one region and hide rows the vendors exclude there: US, UK, DACH, EU_REST, or APAC.")
11995
+ .option("--status <status>", "Filter intents to one status: default, selectable, no_default, coming_soon, or rejected.")
11996
+ .option("--category <category>", "Filter the column entries to one category: Person, Contact, Company, or Ads.")
11997
+ .option("--json", "Print a JSON envelope.")
11998
+ .action(async (options) => {
11999
+ await handleAsyncAction("enrichment catalog", options, async () => {
12000
+ const params = new URLSearchParams();
12001
+ for (const key of ["intent", "group", "region", "status"]) {
12002
+ const value = readOption(options[key]);
12003
+ if (value)
12004
+ params.set(key, value);
12005
+ }
12006
+ const query = params.toString();
12007
+ const envelope = await requestOxygen(`/api/cli/enrichment/catalog${query ? `?${query}` : ""}`, { method: "GET" });
12008
+ const wanted = readOption(options.category)?.toLowerCase();
12009
+ if (!wanted || !Array.isArray(envelope.entries))
12010
+ return envelope;
12011
+ const entries = envelope.entries.filter((entry) => String(entry.category ?? "").toLowerCase() === wanted);
12012
+ return { ...envelope, entries, category: wanted };
12013
+ });
12014
+ }))
11302
12015
  .addCommand(new Command("apply-default-cascade")
11303
12016
  .description("Patch an existing enrichment column to the server-side default provider cascade for its intent.")
11304
12017
  .argument("<table>", "Table id or slug.")
@@ -12822,7 +13535,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12822
13535
  });
12823
13536
  }))
12824
13537
  .addCommand(new Command("get")
12825
- .description("Get one conversation with its full message thread. <conversation> accepts a conversation id, Unipile chat id, or (email) Zapbox thread id, and is resolved on any channel by default (email first, then LinkedIn/WhatsApp).")
13538
+ .description("Get one conversation with its full message thread. <conversation> accepts a conversation id, Unipile chat id, or (email) Zapbox thread id, and is resolved on any channel by default (email first, then LinkedIn/WhatsApp). Opening a WhatsApp thread may fetch provider history and merge provider-confirmed phone/contact identities in the local mirror. This uses no Oxygen credits and sends no message; old conversation references remain usable. For stored-only inspection, use messages query.")
12826
13539
  .argument("<conversation>", "Conversation id, Unipile chat id, or Zapbox thread id.")
12827
13540
  .option("--channel <channel>", "Inbox channel: all (default, resolves the id on any channel), email, linkedin, or whatsapp.")
12828
13541
  .option("--message-limit <n>", "Maximum messages to return (1-500). Defaults to 100.")
@@ -13324,7 +14037,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13324
14037
  });
13325
14038
  }))));
13326
14039
  program.addCommand(new Command("messages")
13327
- .description("Cross-channel message corpus: every individual email + LinkedIn + WhatsApp message as one searchable stream (Postgres full-text search over bodies, keyset paginated by recency), plus reply-rate/campaign analytics. Message-level, unlike inbox (conversation-level triage).")
14040
+ .description("Cross-channel message corpus: every individual email + LinkedIn + WhatsApp message as one searchable stream (Postgres full-text search over bodies, keyset paginated by recency), plus INBOX-WIDE conversation analytics. NOT campaign performance: for per-channel campaign reply rates use `oxygen sequences analytics`. Message-level, unlike inbox (conversation-level triage).")
13328
14041
  .addCommand(new Command("query")
13329
14042
  .description("Query messages across channels newest first, or relevance-ranked when -q is set. --channel all merges email + LinkedIn + WhatsApp (narrow with --channels); a campaign filter (--sequence-id) restricts to email. Filter by direction, account, contact, status, and date range. Scope: the Messages store (Unibox conversations, every native email since v1.927.0 linked to its campaign; earlier sends without a provider thread id are not). The complete send ledger of a sequence is `sequences events --kind sent`; its counts are `sequences stats`.")
13330
14043
  .option("-q, --query <text>", "Full-text search over message bodies (relevance-ranked).")
@@ -13370,7 +14083,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13370
14083
  });
13371
14084
  }))
13372
14085
  .addCommand(new Command("stats")
13373
- .description("Unibox/Message-ledger analytics: outbound/inbound totals, heuristic reply rate by channel + campaign, status breakdowns, response-time percentiles, top counterpart domains, and winning openers. Messages with no sequence id remain outside byCampaign. For native Sequence sent/replied/bounced attribution by mailbox and sending domain, use `oxygen sequences analytics`. Scope with --sequence-id, --channel, and a date range. Defaults to the last 30 days; pass --all-time for full history.")
14086
+ .description("INBOX-WIDE message-ledger analytics over the WHOLE corpus, not your campaigns: outbound/inbound totals, a conversation-level reply rate (has-inbound over has-outbound) by channel, status breakdowns, response-time percentiles, top counterpart domains, and winning openers. It counts every conversation in the workspace, including mail no campaign sent, so its reply rate is NOT your campaign reply rate — for that, and for per-channel campaign performance, use `oxygen sequences analytics`. byCampaign is email-only because direct-message conversations carry no campaign link; an empty list there is inaccessible, not absence. Scope with --sequence-id, --channel, and a date range. Defaults to the last 30 days; pass --all-time for full history.")
13374
14087
  .option("--sequence-id <ids>", "Comma-separated campaign (sequence) ids to scope to.")
13375
14088
  .option("--channel <channel>", "Channel: all (default), email, linkedin, or whatsapp.")
13376
14089
  .option("--since <iso>", "Only messages sent at or after this ISO timestamp. Defaults to 30 days ago.")
@@ -13401,12 +14114,13 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13401
14114
  .addCommand(new Command("list")
13402
14115
  .description("List sequences with persisted launch status kept separate from current operational state. Fleet counts report marked-active versus operationally-live (active with nonterminal work), and rows explain flowing, paced, blocked, degraded, drained, or empty work without SQL. --stats joins each row's LIFETIME funnel.")
13403
14116
  .option("--status <status>", "Filter by status: draft, active, paused, or archived.")
13404
- .addOption(new Option("--operational-state <state>", "Filter by current work state; fleet counts remain unfiltered: empty, drained, blocked, degraded, flowing, or paced.").choices(["empty", "drained", "blocked", "degraded", "flowing", "paced"]))
14117
+ .addOption(new Option("--operational-state <state>", "Filter by current work state; fleet counts remain unfiltered: empty, drained, never_started (enrolled leads, never launched), blocked, degraded, flowing, or paced.").choices(["empty", "drained", "never_started", "blocked", "degraded", "flowing", "paced"]))
14118
+ .addOption(new Option("--channel <channel>", "Only sequences carrying this channel.").choices(["email", "linkedin", "whatsapp"]))
13405
14119
  .option("--tag <tag>", "Only sequences carrying this workspace tag (see `oxygen tags list`).")
13406
14120
  .option("--stats", "Attach lifetime stats per sequence (a stats pass per row — slower on large workspaces).")
13407
14121
  .option("--json", "Print a JSON envelope.")
13408
14122
  .action(async (options) => {
13409
- await handleSequenceReadAction("sequences list", options, () => {
14123
+ await handleReadActionWithLens("sequences list", options, () => {
13410
14124
  const params = new URLSearchParams();
13411
14125
  const status = readOption(options.status);
13412
14126
  if (status)
@@ -13414,6 +14128,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13414
14128
  const operationalState = readOption(options.operationalState);
13415
14129
  if (operationalState)
13416
14130
  params.set("operational_state", operationalState);
14131
+ const channel = readOption(options.channel);
14132
+ if (channel)
14133
+ params.set("channel", channel);
13417
14134
  const tag = readOption(options.tag);
13418
14135
  if (tag)
13419
14136
  params.set("tag", tag);
@@ -13644,7 +14361,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13644
14361
  .option("--sequence <id-or-slug>", "Limit analytics to one sequence.")
13645
14362
  .option("--json", "Print a JSON envelope.")
13646
14363
  .action(async (options) => {
13647
- await handleSequenceReadAction("sequences analytics", options, () => {
14364
+ await handleReadActionWithLens("sequences analytics", options, () => {
13648
14365
  const params = new URLSearchParams();
13649
14366
  const range = readOption(options.range);
13650
14367
  const from = readOption(options.from);
@@ -13671,9 +14388,11 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13671
14388
  .option("--whatsapp-cold-initiate", "WhatsApp: allow cold-initiating new chats (no prior conversation). Required to start a WhatsApp sequence live — WhatsApp via Unipile is unofficial WhatsApp Web, so cold-initiating is an explicit ban-risk opt-in.")
13672
14389
  .option("--phone-column-key <key>", "WhatsApp: row_values key holding each lead's phone number (else falls back to phone/phone_number/mobile).")
13673
14390
  .option("--senders <ids>", "Comma-separated LinkedIn or WhatsApp sender account ids (or connection / Unipile ids; `linkedin senders` / `whatsapp accounts` list them). Optional at create; enroll and start require at least one when the journey has LinkedIn or WhatsApp steps (attach later with `sequences update --senders`).")
13674
- .option("--table <id>", "Source table id whose rows supply {{column}} template values.")
14391
+ .option("--table <id>", "Source table id or slug whose rows supply {{column}} template values.")
14392
+ .option("--from-table <id>", "Alias of --table: the source table id or slug this sequence draws its leads and {{column}} values from.")
14393
+ .option("--enroll", "After creating the sequence, immediately enroll every row of the bound table (same as running `sequences enroll --from-table` next). Requires --table/--from-table. Enrolling spends no credits and sends nothing.")
13675
14394
  .option("--url-column <key>", "Column key holding each lead's LinkedIn URL/provider id.")
13676
- .option("--email-provider <provider>", "Email provider for the email track. Only 'instantly' is supported.")
14395
+ .option("--email-provider <provider>", "Email track provider for this NATIVE Oxygen sequence; only 'instantly' is supported here. To push rows into a campaign you already run in Smartlead, lemlist, HeyReach or Instantly, use `oxygen sequences push-external` instead.")
13677
14396
  .option("--email-connection <id>", "Instantly connection id for the email track. Defaults to the org's active Instantly connection.")
13678
14397
  .option("--email-definition-file <path>", "Path to a JSON file with the email content spec (subjects/bodies/delays/subsequences) compiled to an Instantly campaign on start.")
13679
14398
  .option("--max-credits <n>", "Credit cap for the LinkedIn track (also set when starting).")
@@ -13715,6 +14434,13 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13715
14434
  const maxCredits = readPositiveNumber(options.maxCredits);
13716
14435
  const maxLiveSends = readPositiveInt(options.maxLiveSends);
13717
14436
  const settings = readSequenceSettings(options);
14437
+ // `--from-table` is the phrase customers already use for enroll,
14438
+ // so it names the same thing here rather than being a second
14439
+ // concept; `--table` stays for every existing script.
14440
+ const sourceTable = readOption(options.fromTable) ?? readOption(options.table);
14441
+ if (options.enroll && !sourceTable) {
14442
+ throw new Error("--enroll needs a source table: pass --from-table <table> (or --table <table>).");
14443
+ }
13718
14444
  return requestOxygen("/api/cli/sequences", {
13719
14445
  method: "POST",
13720
14446
  body: {
@@ -13724,7 +14450,8 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13724
14450
  ...(channels.length > 0 ? { channels } : {}),
13725
14451
  ...(senders.length > 0 ? { senders } : {}),
13726
14452
  ...(tags.length > 0 ? { tags } : {}),
13727
- ...(readOption(options.table) ? { source_table_id: readOption(options.table) } : {}),
14453
+ ...(sourceTable ? { source_table_id: sourceTable } : {}),
14454
+ ...(options.enroll ? { enroll_from_table: true } : {}),
13728
14455
  ...(readOption(options.urlColumn) ? { linkedin_url_column_key: readOption(options.urlColumn) } : {}),
13729
14456
  ...(email ? { email } : {}),
13730
14457
  ...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
@@ -13866,14 +14593,15 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13866
14593
  });
13867
14594
  }))
13868
14595
  .addCommand(new Command("get")
13869
- .description("Get a sequence's definition, senders, status, and credit usage.")
14596
+ .description("Get a sequence's definition, senders, status, and credit usage. --readiness adds the launch check — whether it CAN go live, why not, and which mailboxes it would send from — without touching it.")
13870
14597
  .argument("<sequence>", "Sequence id or slug.")
14598
+ .option("--readiness", "Add launch_readiness: blockers (e.g. a missing send cap), warnings, and the resolved sending mailboxes. Read-only — sends nothing, changes nothing, spends nothing. Skips the LinkedIn rendered-copy scan, which stays in `sequences start` without --approved.")
13871
14599
  .option("--json", "Print a JSON envelope.")
13872
14600
  .action(async (sequence, options) => {
13873
- await handleAsyncAction("sequences get", options, () => requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}`));
14601
+ await handleAsyncAction("sequences get", options, () => requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}${options.readiness ? "?include=readiness" : ""}`));
13874
14602
  }))
13875
14603
  .addCommand(new Command("enroll")
13876
- .description("Enroll leads into a sequence. Enrolling spends no credits and sends nothing — dispatch only happens at `sequences start`. --from-table enrolls every not-yet-enrolled row of the sequence's bound source table (up to 500 per run; the response's from_table.has_more says whether to run again) — for a LinkedIn sequence the profile is read from the bound table's URL column, where a full /in/ URL and a bare public handle carrying a hyphen or digit (ada-lovelace) both work, so no pre-resolved provider ids are needed; each handle Oxygen canonicalized is echoed in linkedin_handles_canonicalized (value + url) to check before start, and every row that still cannot be resolved is named in unresolved_linkedin_lead_details (row, value, reason) rather than only counted in unresolved_linkedin_leads. --leads-file enrolls an explicit JSON list { leads: [...] }: for an email/WhatsApp sequence a lead identified only by an email (row_values.email) or phone (row_values.phone) is auto-filed as a row in the sequence's leads table (auto-creating and binding one if there is none), deduped by email/phone, so no table has to exist first (the table is returned as leads_table with a web_url); a LinkedIn lead needs a lead_provider_id, a lead_profile_url (a /in/ URL or a bare public handle with a hyphen or digit), or a table_row_id whose row carries one of those. A CRM-only lead needs a stable table_row_id or lead_provider_id in addition to mapped row_values; a HubSpot contact id used in crm_task associations identifies the destination record, not the Oxygen enrollment. When the sequence is bound to a source table, a lead's table_row_id auto-snapshots that row's columns (incl. AI/tool outputs) into row_values for {{column}} copy — explicit row_values win. Idempotent per table row (and per email/phone for auto-filed leads). The org do-not-contact list is always enforced; --exclude-contacted and --suppress-list add further opt-in skips (reported under skipped_by_reason). Leads already owned by a sender account (from an earlier real send) are routed back to that same account; a lead owned by a sender NOT on this sequence is skipped (bound_to_other_sender) unless --ignore-sender-bindings.")
14604
+ .description("Enroll leads into a sequence. Enrolling spends no credits and sends nothing — dispatch only happens at `sequences start`. --from-table enrolls every not-yet-enrolled row of the sequence's bound source table (up to 500 per run; the response's from_table.has_more says whether to run again) — for a LinkedIn sequence the profile is read from the bound table's URL column, where a full /in/ URL and a bare public handle carrying a hyphen or digit (ada-lovelace) both work, so no pre-resolved provider ids are needed; each handle Oxygen canonicalized is echoed in linkedin_handles_canonicalized (value + url) to check before start, and every row that still cannot be resolved is named in unresolved_linkedin_lead_details (row, value, reason) rather than only counted in unresolved_linkedin_leads. --leads-file enrolls an explicit JSON list { leads: [...] }: for an email/WhatsApp sequence a lead identified only by an email (row_values.email) or phone (row_values.phone) is auto-filed as a row in the sequence's leads table (auto-creating and binding one if there is none), deduped by email/phone, so no table has to exist first (the table is returned as leads_table with a web_url); a LinkedIn lead needs a lead_provider_id, a lead_profile_url (a /in/ URL or a bare public handle with a hyphen or digit), or a table_row_id whose row carries one of those. A CRM-only lead needs a stable table_row_id or lead_provider_id in addition to mapped row_values; a HubSpot contact id used in crm_task associations identifies the destination record, not the Oxygen enrollment. When the sequence is bound to a source table, a lead's table_row_id auto-snapshots that row's columns (incl. AI/tool outputs) into row_values for {{column}} copy — explicit row_values win. Idempotent per table row (and per email/phone for auto-filed leads). The org do-not-contact list is always enforced; --exclude-contacted and --suppress-list add further opt-in skips (reported under skipped_by_reason). Leads already owned by a sender account (from an earlier real send) are routed back to that same account; a lead owned by a sender NOT on this sequence is skipped (bound_to_other_sender) unless --ignore-sender-bindings. This enrolls into an OXYGEN Sequence; to put the same rows into a campaign you already run in Smartlead, Instantly, lemlist or HeyReach, use `sequences push-external`.")
13877
14605
  .argument("<sequence>", "Sequence id or slug.")
13878
14606
  .option("--leads-file <path>", "Path to a JSON file: { \"leads\": [{ row_values: { email }, lead_name }] } for cold email; { lead_provider_id, lead_name, table_row_id, row_values } for LinkedIn/existing rows; CRM-only leads require table_row_id or a stable lead_provider_id alongside mapped row_values. A CRM association object id is not the enrollment identity. Exactly one of --leads-file or --from-table.")
13879
14607
  .option("--from-table", "Enroll every not-yet-enrolled row of the sequence's bound source table (up to 500 per run; re-run to continue). Exactly one of --leads-file or --from-table.")
@@ -13914,6 +14642,50 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13914
14642
  },
13915
14643
  });
13916
14644
  });
14645
+ }))
14646
+ .addCommand(new Command("push-external")
14647
+ .description("Push table rows into a campaign you ALREADY run in an external sequencer (Smartlead, Instantly, lemlist, HeyReach), through your own connected API key. This is the External-layer sibling of `sequences enroll`: use it when the campaign lives in that tool and the rows live in Oxygen; for an Oxygen-native Sequence use `sequences create` + `sequences enroll` instead. Oxygen charges 0 credits (BYOK — your provider bills you), but it IS an external write: --mode dry_run (the default) previews the resolved campaign, the exact field mapping, three sample leads and the row counts, and --mode live --approved queues a durable per-row run. Every row gets its own cell holding that row's provider response as provenance, and a row already pushed to this campaign is skipped unless --force. The preview's live_executable reports whether Oxygen can queue the live push for that provider; when it cannot, the preview still gives you the resolved campaign, the mapping and the counts, and names the alternative.")
14648
+ .requiredOption("--provider <provider>", "External sequencer: smartlead, instantly, lemlist, or heyreach.")
14649
+ .requiredOption("--campaign <name-or-id>", "The campaign that already exists in that tool. A name is resolved against your connected account (exact id, then exact name, then a unique partial match).")
14650
+ .requiredOption("--from-table <table>", "Table id or slug whose rows are pushed.")
14651
+ .option("--rows <row_ids>", "Comma-separated row ids to push. Defaults to the whole table.")
14652
+ .option("--filter-json <json>", "Filter object or array selecting the rows, e.g. '{\"column\":\"email\",\"op\":\"is_not_null\"}'.")
14653
+ .option("--mode <mode>", "dry_run (default) or live.")
14654
+ .option("--approved", "Required for --mode live: confirms the external write after inspecting the dry run.")
14655
+ .option("--field-mapping-json <json>", "Override the detected column mapping, e.g. '{\"email\":\"work_email\",\"first_name\":\"fname\"}'. Keys: email, first_name, last_name, company, website, linkedin_url, phone, job_title.")
14656
+ .option("--sender-account <id>", "HeyReach only: the LinkedIn sender account id the leads are added against (list them with `oxygen tools run heyreach.linkedin_accounts_get_all --input-json '{}' --credential-mode user_api_key`).")
14657
+ .option("--connection-id <id>", "Specific integration connection id. Defaults to the workspace's active connection for that provider.")
14658
+ .option("--force", "Push rows again even when they already carry a successful push to this campaign.")
14659
+ .option("--json", "Print a JSON envelope.")
14660
+ .action(async (options) => {
14661
+ await handleSequenceWriteAction("sequences push-external", options, () => {
14662
+ const rows = readCsvOption(options.rows);
14663
+ const filterJson = readOption(options.filterJson);
14664
+ if (rows.length > 0 && filterJson) {
14665
+ throw new Error("Pass either --rows or --filter-json, not both.");
14666
+ }
14667
+ const selection = rows.length > 0
14668
+ ? { mode: "row_ids", row_ids: rows }
14669
+ : filterJson
14670
+ ? { mode: "all_matching", filters: JSON.parse(filterJson) }
14671
+ : null;
14672
+ const fieldMappingJson = readOption(options.fieldMappingJson);
14673
+ return requestOxygen("/api/cli/sequences/push-external", {
14674
+ method: "POST",
14675
+ body: {
14676
+ provider: readOption(options.provider),
14677
+ campaign: readOption(options.campaign),
14678
+ table: readOption(options.fromTable),
14679
+ mode: readOption(options.mode) ?? "dry_run",
14680
+ ...(selection ? { selection } : {}),
14681
+ ...(options.approved ? { approved: true } : {}),
14682
+ ...(options.force ? { force: true } : {}),
14683
+ ...(readOption(options.connectionId) ? { connection_id: readOption(options.connectionId) } : {}),
14684
+ ...(readOption(options.senderAccount) ? { sender_account_id: readOption(options.senderAccount) } : {}),
14685
+ ...(fieldMappingJson ? { field_mapping: parseJsonObject(fieldMappingJson) } : {}),
14686
+ },
14687
+ });
14688
+ }, formatSequencePushExternal);
13917
14689
  }))
13918
14690
  .addCommand(new Command("start")
13919
14691
  .description("Preview or start a sequence. Without --approved this is the launch check: it returns launch_readiness (blockers such as a missing send cap or the WhatsApp cold-initiate opt-in, plus warnings), scans pending LinkedIn copy, and returns exact rendered recipient samples, character counts/limits, copy blockers, per-kind scope, sender/mailbox capacity, CRM readiness, and safety caps. Live start requires --approved; --max-credits is optional. Email/WhatsApp/CRM-task journeys additionally require --max-live-sends because their external actions cost 0 Oxygen credits. Use --dry-run to simulate without any send, provider campaign, CRM task, or credits. The first live start also switches on the standing reply → CRM automation for the workspace.")
@@ -14135,7 +14907,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
14135
14907
  .argument("<sequence>", "Sequence id or slug.")
14136
14908
  .option("--json", "Print a JSON envelope.")
14137
14909
  .action(async (sequence, options) => {
14138
- await handleSequenceReadAction("sequences stats", options, () => requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/stats`), formatSequenceStatsHealth);
14910
+ await handleReadActionWithLens("sequences stats", options, () => requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/stats`), formatSequenceStatsHealth);
14139
14911
  }))
14140
14912
  .addCommand(new Command("esp")
14141
14913
  .description("ESP breakdown of this sequence's email sending scope: which provider each enrolled lead's mail routes through (google/microsoft, plus named gateways like Proofpoint/Mimecast and non-matchable hosts), folded against your sending mailbox pool. Shows how many leads the CURRENT --esp-matching mode would defer before you turn it on. Resolves domains from MX + SPF (0 credits, DNS only) and caches the result, warming the send path. Preview before flipping strict/prefer.")
@@ -14422,7 +15194,74 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
14422
15194
  });
14423
15195
  }))));
14424
15196
  program.addCommand(new Command("suppressions")
14425
- .description("Unified do-not-contact controls for people and typed identities. `import-person` bundles one contact's email, LinkedIn, phone, and explicit company domain; `import` preserves the legacy mixed-file contract; `import-identities` is the integration-level typed endpoint; `hubspot lists|sync` arms additive synchronization. Company DNC applies across Sequence channels and is never inferred from a person's email. Consumes 0 credits.")
15197
+ .description("Unified do-not-contact controls for people and typed identities. `policy` decides WHO GETS ADDED automatically -- which replies, bounces, unsubscribes and call outcomes put someone on the list, and whether each one blocks them forever or only stops the current campaign. `crm-backfill` files the people already on the list into your CRM. `import-person` bundles one contact's email, LinkedIn, phone, and explicit company domain; `import` preserves the legacy mixed-file contract; `import-identities` is the integration-level typed endpoint; `hubspot lists|sync` arms additive synchronization. Company DNC applies across Sequence channels and is never inferred from a person's email. Consumes 0 credits.")
15198
+ .addCommand(new Command("policy")
15199
+ .description("Do-not-contact RULES: who gets added to the list automatically. Seven events can add someone -- an email hard bounce, a one-click unsubscribe, opt-out wording in a reply, ANY inbound LinkedIn reply, ANY inbound WhatsApp reply, and the do-not-call / wrong-number call outcomes -- plus an optional rule per inbox reply classification. Each rule is off, stop_only (end this campaign but do not block future ones) or suppress (the default for all seven, and what every version before this did). Blocking itself is never configurable: once someone is on the list they are always skipped. Consumes 0 credits.")
15200
+ .addCommand(new Command("show")
15201
+ .description("Show every do-not-contact rule with its current setting, whether that is your change or the shipped default, and the values it accepts.")
15202
+ .option("--json", "Print a JSON envelope.")
15203
+ .action(async (options) => {
15204
+ await handleAsyncAction("suppressions policy", options, () => requestOxygen("/api/cli/suppressions/policy"));
15205
+ }))
15206
+ .addCommand(new Command("set")
15207
+ .description("Change one rule. Example: `oxygen suppressions policy set --rule linkedin_reply --effect stop_only` stops a LinkedIn replier's current campaign without blocking them from every future one.")
15208
+ .requiredOption("--rule <key>", "Rule key from `policy show` (e.g. linkedin_reply, whatsapp_reply, email_hard_bounce, reply_status:not_interested).")
15209
+ .requiredOption("--effect <effect>", "off (do nothing), stop_only (end this enrollment only) or suppress (also add to the do-not-contact list).")
15210
+ .option("--reason <reason>", "Reason stamped on the list entry when the rule suppresses. Defaults to the rule's own.")
15211
+ .option("--json", "Print a JSON envelope.")
15212
+ .action(async (options) => {
15213
+ await handleAsyncAction("suppressions policy set", options, () => {
15214
+ const rule = readOption(options.rule);
15215
+ if (!rule)
15216
+ throw new Error("--rule is required.");
15217
+ const effect = readOption(options.effect);
15218
+ if (!effect)
15219
+ throw new Error("--effect is required.");
15220
+ const reason = readOption(options.reason);
15221
+ return requestOxygen("/api/cli/suppressions/policy", {
15222
+ method: "POST",
15223
+ body: { rule, effect, ...(reason ? { reason } : {}) },
15224
+ });
15225
+ });
15226
+ }))
15227
+ .addCommand(new Command("reset")
15228
+ .description("Return one rule (--rule) or every rule (--all) to the shipped default.")
15229
+ .option("--rule <key>", "Rule key to reset.")
15230
+ .option("--all", "Reset every rule instead of one.")
15231
+ .option("--json", "Print a JSON envelope.")
15232
+ .action(async (options) => {
15233
+ await handleAsyncAction("suppressions policy set", options, () => {
15234
+ const rule = readOption(options.rule);
15235
+ if (options.all && rule)
15236
+ throw new Error("Pass either --rule or --all, not both.");
15237
+ if (!options.all && !rule)
15238
+ throw new Error("--rule is required (or pass --all).");
15239
+ return requestOxygen("/api/cli/suppressions/policy", {
15240
+ method: "POST",
15241
+ body: options.all ? { reset_all: true } : { rule, reset: true },
15242
+ });
15243
+ });
15244
+ })))
15245
+ .addCommand(new Command("crm-backfill")
15246
+ .description("Create a CRM person record for everyone already on the do-not-contact list, so you can see who they are. New suppressions do this automatically; this covers the ones added before. Previews by default and writes nothing until you pass --live. Consumes 0 credits: it never runs enrichment on the records it creates.")
15247
+ .option("--live", "Actually create the records. Without this the command only reports what it would create.")
15248
+ .option("--limit <n>", "People to process in this call (1-500; default 200).")
15249
+ .option("--offset <n>", "Resume cursor; pass the next_offset from the previous call.")
15250
+ .option("--json", "Print a JSON envelope.")
15251
+ .action(async (options) => {
15252
+ await handleAsyncAction("suppressions crm-backfill", options, () => {
15253
+ const limit = readOption(options.limit);
15254
+ const offset = readOption(options.offset);
15255
+ return requestOxygen("/api/cli/suppressions/crm-backfill", {
15256
+ method: "POST",
15257
+ body: {
15258
+ ...(options.live ? { live: true } : {}),
15259
+ ...(limit ? { limit: Number(limit) } : {}),
15260
+ ...(offset ? { offset: Number(offset) } : {}),
15261
+ },
15262
+ });
15263
+ });
15264
+ }))
14426
15265
  .addCommand(new Command("list")
14427
15266
  .description("List the org's do-not-contact suppressions, newest first. Filter by --reason or by --search (case-insensitive substring of the lead provider id).")
14428
15267
  .option("--reason <reason>", "Filter by reason: manual, replied, unsubscribed, bounced, do_not_contact, friends.")
@@ -15195,7 +16034,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
15195
16034
  await handleAsyncAction("mailboxes get", options, () => requestOxygen(`/api/cli/mailboxes/${encodeURIComponent(mailbox)}`));
15196
16035
  }))
15197
16036
  .addCommand(new Command("delete")
15198
- .description("Preview or approve removal of exact mailboxes from Oxygen at 0 credits. Deletion immediately removes them from sending but never deletes the underlying Google Workspace or Microsoft 365 accounts. It preserves conversation/message/delivery history and stops any separately listed 1,000-credit connected-mailbox commitment for future renewals; that line is currently built but not charged, and it is not the 1,000-credit OXYGEN Warm-up subscription. The current period is not refunded. Listed active/paused sequences keep their status but lose these senders and are not automatically paused. Managed mailboxes and live warmup/monitoring add-ons fail closed with their exact address-scoped teardown steps.")
16037
+ .description("Preview or approve removal of exact mailboxes from Oxygen at 0 credits. Deletion immediately removes them from sending but never deletes the underlying Google Workspace or Microsoft 365 accounts. It preserves conversation/message/delivery history and stops any separately listed 100-credit connected-mailbox commitment for future renewals; that line is currently built but not charged, and it is not the 100-credit OXYGEN Warm-up subscription. The current period is not refunded. Listed active/paused sequences keep their status but lose these senders and are not automatically paused. Managed mailboxes and live warmup/monitoring add-ons fail closed with their exact address-scoped teardown steps.")
15199
16038
  .requiredOption("--mailboxes <list>", "Comma-separated mailbox ids or addresses (maximum 500).")
15200
16039
  .option("--approved", "Execute the fresh preview. Requires --plan-hash and --confirmation.")
15201
16040
  .option("--plan-hash <hash>", "Fresh preview plan_hash.")
@@ -15534,7 +16373,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
15534
16373
  .addCommand(new Command("emailguard")
15535
16374
  .description("Assess every mailbox origin for EmailGuard and connect credential-capable Google Workspace mailboxes from managed, Zapmail, or compatible encrypted external imports. Manual/native Google rows report credential_required; Microsoft 365/Azure reports vendor_blocked. Preview never materializes a password; approved execution fetches it just in time and never prints it.")
15536
16375
  .addCommand(new Command("connect")
15537
- .description("Preview or connect selected mailboxes to EmailGuard. Managed mode is 1,000 credits/inbox-month ($1); BYOK is 0 Oxygen credits. Google Workspace requires a real app password, fetched only after approval. Microsoft 365/Azure is explicitly vendor-blocked because EmailGuard exposes neither Microsoft OAuth nor tenant consent; Oxygen never downgrades it to password auth. Starts with a 0-credit, no-provider-write preview. Account connection sends no placement probe.")
16376
+ .description("Preview or connect selected mailboxes to EmailGuard. Managed mode is 100 credits/inbox-month ($1); BYOK is 0 Oxygen credits. Google Workspace requires a real app password, fetched only after approval. Microsoft 365/Azure is explicitly vendor-blocked because EmailGuard exposes neither Microsoft OAuth nor tenant consent; Oxygen never downgrades it to password auth. Starts with a 0-credit, no-provider-write preview. Account connection sends no placement probe.")
15538
16377
  .option("--mailboxes <list>", "Comma-separated mailbox ids or addresses. Omit for the whole pool.")
15539
16378
  .option("--approved", "Perform the external EmailGuard account write.")
15540
16379
  .option("--plan <hash>", "Exact hash from the fresh preview (required with --approved).")
@@ -15651,10 +16490,10 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
15651
16490
  });
15652
16491
  }))
15653
16492
  .addCommand(new Command("warmup")
15654
- .description("OXYGEN Warm-up for the sending pool — the weeks of low-volume conversational mail that make a new inbox look trusted before you cold-send from it. It is managed and credit-billed at 1,000 credits per warming inbox per month ($1). Fresh managed InboxKit Google/Microsoft/Azure orders with the default-on warm-up add-on activate automatically after provisioning under the approved order quote; do not run a second `warmup enable`. Google reuses the InboxKit-held credential only during activation and never stores or returns it; Microsoft/Azure uses exact native export. Standalone preview → approval is for BYOK/imported mailboxes, a managed opt-out, or later separate enrollment. InboxKit auto-export stays off for Microsoft/Azure because Oxygen submits only the exact authorized UIDs, and any non-cancelled InboxKit warm-up blocks either handoff so one mailbox cannot warm twice. OXYGEN Warm-up never owns campaign dispatch: native OXYGEN Sequences still own campaigns and sends. Eligible non-InboxKit Microsoft mailboxes use the separate `mailboxes warmup microsoft` OAuth fallback. Inboxes already warming on the retired TrulyInbox rail keep reporting and are wound down there with `warmup disable`; they are never silently moved to the current managed rail.")
16493
+ .description("OXYGEN Warm-up for the sending pool — the weeks of low-volume conversational mail that make a new inbox look trusted before you cold-send from it. It is managed and credit-billed at 100 credits per warming inbox per month ($1). Fresh managed InboxKit Google/Microsoft/Azure orders with the default-on warm-up add-on activate automatically after provisioning under the approved order quote; do not run a second `warmup enable`. Google reuses the InboxKit-held credential only during activation and never stores or returns it; Microsoft/Azure uses exact native export. Standalone preview → approval is for BYOK/imported mailboxes, a managed opt-out, or later separate enrollment. InboxKit auto-export stays off for Microsoft/Azure because Oxygen submits only the exact authorized UIDs, and any non-cancelled InboxKit warm-up blocks either handoff so one mailbox cannot warm twice. OXYGEN Warm-up never owns campaign dispatch: native OXYGEN Sequences still own campaigns and sends. Eligible non-InboxKit Microsoft mailboxes use the separate `mailboxes warmup microsoft` OAuth fallback. Inboxes already warming on the retired TrulyInbox rail keep reporting and are wound down there with `warmup disable`; they are never silently moved to the current managed rail.")
15655
16494
  .addHelpText("after", "\nDocs: https://oxygen-agent.com/docs/providers/mailboxes\n")
15656
16495
  .addCommand(new Command("enable")
15657
- .description("STANDALONE ENROLLMENT: enroll BYOK/imported mailboxes, a managed order that opted out, or mailboxes being added to warmup later. Fresh managed InboxKit Google/Microsoft/Azure orders whose approved quote included warmup activate automatically after provisioning and do not need this command. Targets the whole pool unless --mailboxes is given; one synchronous preview/approval is capped at 220 mailboxes, so split larger pools into explicit batches. Without --approved this is a 0-credit, no-provider-write priced preview: nothing is handed off, enrolled, or billed. The current public price is 1,000 credits per warming mailbox-month; the preview returns the exact first-cycle ceiling. Preview one inbox with `oxygen mailboxes warmup enable --mailboxes sender@example.com --json`. Execute by echoing its --plan and --max-credits with --approved. Eligible managed Google targets use the InboxKit-held credential just in time without storing or returning it; Microsoft/Azure targets use native InboxKit Sequencer export with exact UIDs and auto-export off. Any non-cancelled InboxKit warmup must be cancelled before either handoff (pausing is not enough). Only eligible non-InboxKit Microsoft mailboxes need `mailboxes warmup microsoft`. OXYGEN Warm-up retains no campaign authority — OXYGEN Sequences own enrollment and dispatch. New enrollments only ever land on the managed OXYGEN rail; the retired TrulyInbox rail is refused here and only accepts teardown.")
16496
+ .description("STANDALONE ENROLLMENT: enroll BYOK/imported mailboxes, a managed order that opted out, or mailboxes being added to warmup later. Fresh managed InboxKit Google/Microsoft/Azure orders whose approved quote included warmup activate automatically after provisioning and do not need this command. Targets the whole pool unless --mailboxes is given; one synchronous preview/approval is capped at 220 mailboxes, so split larger pools into explicit batches. Without --approved this is a 0-credit, no-provider-write priced preview: nothing is handed off, enrolled, or billed. The current public price is 100 credits per warming mailbox-month; the preview returns the exact first-cycle ceiling. Preview one inbox with `oxygen mailboxes warmup enable --mailboxes sender@example.com --json`. Execute by echoing its --plan and --max-credits with --approved. Eligible managed Google targets use the InboxKit-held credential just in time without storing or returning it; Microsoft/Azure targets use native InboxKit Sequencer export with exact UIDs and auto-export off. Any non-cancelled InboxKit warmup must be cancelled before either handoff (pausing is not enough). Only eligible non-InboxKit Microsoft mailboxes need `mailboxes warmup microsoft`. OXYGEN Warm-up retains no campaign authority — OXYGEN Sequences own enrollment and dispatch. New enrollments only ever land on the managed OXYGEN rail; the retired TrulyInbox rail is refused here and only accepts teardown.")
15658
16497
  .addHelpText("after", "\nDocs: https://oxygen-agent.com/docs/providers/mailboxes\n")
15659
16498
  .option("--mailboxes <list>", "Comma-separated mailbox ids or addresses to warm. Omit to warm the whole pool.")
15660
16499
  .option("--approved", "Execute the PRICED enrollment. Without it the response is a priced preview and nothing is enrolled or billed.")
@@ -15884,7 +16723,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
15884
16723
  });
15885
16724
  }))
15886
16725
  .addCommand(new Command("disable")
15887
- .description("Disable warmup and unenroll each mailbox from the rail that actually enrolled it. On a managed rail — OXYGEN Warm-up today, TrulyInbox for older enrollments — this also cancels that mailbox's warmup subscription, so billing stops with the provider removal. This is how you wind an inbox off the retired TrulyInbox rail. NOT the way to fix a rotated app password — use `mailboxes warmup reconnect` for that, which costs 0 credits instead of a new 1,000-credit month. Targets the whole pool unless --mailboxes is given.")
16726
+ .description("Disable warmup and unenroll each mailbox from the rail that actually enrolled it. On a managed rail — OXYGEN Warm-up today, TrulyInbox for older enrollments — this also cancels that mailbox's warmup subscription, so billing stops with the provider removal. This is how you wind an inbox off the retired TrulyInbox rail. NOT the way to fix a rotated app password — use `mailboxes warmup reconnect` for that, which costs 0 credits instead of a new 100-credit month. Targets the whole pool unless --mailboxes is given.")
15888
16727
  .option("--mailboxes <list>", "Comma-separated mailbox ids or addresses. Omit for the whole pool.")
15889
16728
  .option("--json", "Print a JSON envelope.")
15890
16729
  .action(async (options) => {
@@ -17306,7 +18145,7 @@ Full trigger schema: oxygen workflows schema --subject trigger --json
17306
18145
  .option("--api-url <url>", "Oxygen app URL. Defaults to OXYGEN_API_URL or https://oxygen-agent.com.")
17307
18146
  .option("--agents <agents...>", "Space or comma separated agents. Defaults to codex, claude-code, and cursor.")
17308
18147
  .option("--skill <skill>", "Skill name or '*'. Defaults to '*'.")
17309
- .option("--project", "Install into the current project instead of global agent scope.")
18148
+ .option("--project", "Install into the current project instead of global agent scope. A flag with no value: run it from the project directory; a path argument is rejected.")
17310
18149
  .option("--copy", "Copy skill files instead of symlinking when supported by npx skills. Default on Windows, where symlinks need Developer Mode or admin.")
17311
18150
  .option("--json", "Print a JSON envelope.")
17312
18151
  .action(async (options) => {
@@ -18625,7 +19464,9 @@ function printColumnCatalog(result) {
18625
19464
  const entries = data && Array.isArray(data.entries) ? data.entries.filter(isRecord) : [];
18626
19465
  const bundles = data && Array.isArray(data.bundles) ? data.bundles.filter(isRecord) : [];
18627
19466
  if (entries.length === 0 && bundles.length === 0) {
18628
- process.stderr.write("No column templates match.\n");
19467
+ process.stderr.write("No column templates match. No preset bundle matches either.\n"
19468
+ + "A template is one column (`columns add <table> --prompt-key <key>`); a bundle is a preset that creates several at once (`columns add <table> --preset <id>`).\n"
19469
+ + "Shorten --search, drop it, or pick a section: `columns catalog --category people`.\n");
18629
19470
  return;
18630
19471
  }
18631
19472
  const byFamily = new Map();
@@ -18677,6 +19518,9 @@ function printColumnCatalog(result) {
18677
19518
  lines.push("");
18678
19519
  lines.push("Prices: a fixed figure is what every row costs; `~X credits/row (up to Y)` is X when the first grounding lane answers and Y reserved per row — `columns run --dry-run` quotes Y for your rows.");
18679
19520
  lines.push("Add one: oxygen columns add <table> --prompt-key <key> --input <input>=<column_key>, then `columns run <table> <key> --dry-run` for the exact cost.");
19521
+ // The blind acceptance judge watched an agent filter this list with python3
19522
+ // because nothing on screen said the command can narrow itself.
19523
+ lines.push("Narrow this list: --family pricing|job_posting|events|filings|site_facts|market|portfolio|text_extraction, --category extract, --search <word>, --kind research|ai|formula. Credits are the reserved ceiling per row (primary lane + fallback); a typical row spends the primary lane only.");
18680
19524
  process.stderr.write(`${lines.join("\n")}\n`);
18681
19525
  }
18682
19526
  function applyAiColumnConfig(definition, options) {
@@ -19415,6 +20259,7 @@ function readFeedBindBody(table, options) {
19415
20259
  feed_kind: options.kind,
19416
20260
  route: {
19417
20261
  tool_id: options.toolId,
20262
+ // assertPullFeedBindArguments guarantees --request-json before this reader runs.
19418
20263
  request: parseJsonObject(readFileIfPresent(options.requestJson)),
19419
20264
  ...(toolIds.length > 0 ? { tool_ids: toolIds } : {}),
19420
20265
  ...(readOption(options.rowsPath) ? { rows_path: readOption(options.rowsPath) } : {}),
@@ -19438,6 +20283,225 @@ function readFeedBindBody(table, options) {
19438
20283
  ...(options.approved ? { approved: true } : {}),
19439
20284
  };
19440
20285
  }
20286
+ // `feeds bind` is one verb over two transports, told apart by --schema-json. Each
20287
+ // list names the flags that only mean something on one side, in help order, so a
20288
+ // flag from the other transport is refused by name instead of silently dropped.
20289
+ const FEED_BIND_PULL_ONLY_FLAGS = [
20290
+ ["kind", "--kind"],
20291
+ ["toolId", "--tool-id"],
20292
+ ["toolIds", "--tool-ids"],
20293
+ ["requestJson", "--request-json"],
20294
+ ["rowsPath", "--rows-path"],
20295
+ ["rowMappingJson", "--row-mapping-json"],
20296
+ ["cursorPath", "--cursor-path"],
20297
+ ["cursorRequestKey", "--cursor-request-key"],
20298
+ ["maxPages", "--max-pages"],
20299
+ ["every", "--every"],
20300
+ ["maxCredits", "--max-credits"],
20301
+ ["maxRows", "--max-rows"],
20302
+ ["filtersJson", "--filters-json"],
20303
+ ["intentId", "--intent-id"],
20304
+ ["inputShape", "--input-shape"],
20305
+ ["routeId", "--route-id"],
20306
+ ["sourcePrompt", "--source-prompt"],
20307
+ ["approved", "--approved"],
20308
+ ];
20309
+ const FEED_BIND_PUSH_ONLY_FLAGS = [
20310
+ ["create", "--create"],
20311
+ ["itemsPath", "--items-path"],
20312
+ ["eventIdPath", "--event-id-path"],
20313
+ ["eventTypePath", "--event-type-path"],
20314
+ ["occurredAtPath", "--occurred-at-path"],
20315
+ ["authMode", "--auth-mode"],
20316
+ ];
20317
+ // The third mode: a WEB INTENT feed, named by source instead of configured.
20318
+ // OXYGEN's own receiver normalizes every reveal, so there is nothing to declare —
20319
+ // which is also why this list is the accepted values rather than a free string.
20320
+ const FEED_BIND_WEB_INTENT_SOURCES = ["rb2b"];
20321
+ // Everything a web-intent feed cannot carry, in help order. --create, --name and
20322
+ // --upsert-key are its only companions, so a flag belonging to either other mode
20323
+ // is refused here by name, one round trip before the server would refuse it.
20324
+ const FEED_BIND_WEB_INTENT_REFUSED_FLAGS = [
20325
+ ["schemaJson", "--schema-json"],
20326
+ ...FEED_BIND_PULL_ONLY_FLAGS,
20327
+ ...FEED_BIND_PUSH_ONLY_FLAGS.filter(([key]) => key !== "create"),
20328
+ ];
20329
+ // Pull mode keeps the contract commander enforced when --kind/--tool-id/
20330
+ // --request-json were requiredOptions and <table> was required: same messages, same
20331
+ // order (mandatory options before the argument), same stderr + exit 1 path. They
20332
+ // stopped being commander-required only because a push feed must not carry them.
20333
+ function assertPullFeedBindArguments(table, options, command) {
20334
+ for (const [key, flag] of FEED_BIND_PUSH_ONLY_FLAGS) {
20335
+ if (options[key] !== undefined) {
20336
+ command.error(`error: ${flag} belongs to a push feed; add --schema-json <json-or-file> to bind one, or drop ${flag} to bind a pull feed.`);
20337
+ }
20338
+ }
20339
+ const required = [
20340
+ ["kind", "--kind <kind>"],
20341
+ ["toolId", "--tool-id <tool>"],
20342
+ ["requestJson", "--request-json <json-or-file>"],
20343
+ ];
20344
+ for (const [key, flags] of required) {
20345
+ if (options[key] === undefined) {
20346
+ command.error(`error: required option '${flags}' not specified`, { code: "commander.missingMandatoryOptionValue" });
20347
+ }
20348
+ }
20349
+ if (table === undefined) {
20350
+ command.error("error: missing required argument 'table'", { code: "commander.missingArgument" });
20351
+ }
20352
+ }
20353
+ // The push bind body mirrors the route's declared-schema reader: transport push, the
20354
+ // schema as an OBJECT (the server validates it against the table's real columns),
20355
+ // and exactly one of an existing table or create_table. Pull fields are refused here
20356
+ // by flag name, before any request, because the server would 400 on them anyway.
20357
+ function readFeedPushBindBody(table, options) {
20358
+ for (const [key, flag] of FEED_BIND_PULL_ONLY_FLAGS) {
20359
+ if (options[key] !== undefined) {
20360
+ throw new OxygenError("invalid_table_feed", `${flag} belongs to a pull feed; a push feed is filled by whatever posts to its endpoint. Drop ${flag}, or drop --schema-json to bind a pull feed.`, { details: { flag }, exitCode: 1 });
20361
+ }
20362
+ }
20363
+ const schemaSource = readOption(options.schemaJson);
20364
+ if (!schemaSource) {
20365
+ throw new OxygenError("invalid_json", "--schema-json needs a JSON Schema object, inline or a path to a JSON file.", { exitCode: 1 });
20366
+ }
20367
+ const rowSchema = parseJsonObject(readFileIfPresent(schemaSource));
20368
+ const tableRef = readOption(table);
20369
+ const create = readOption(options.create);
20370
+ if (tableRef && create) {
20371
+ throw new OxygenError("invalid_request", "Pass a table or --create <name>, not both.", { exitCode: 1 });
20372
+ }
20373
+ if (!tableRef && !create) {
20374
+ throw new OxygenError("invalid_request", "Pass the table id or slug that receives the rows, or --create <name> to create the table from the schema.", { exitCode: 1 });
20375
+ }
20376
+ const authMode = readOption(options.authMode);
20377
+ if (authMode && authMode !== "secret" && authMode !== "none") {
20378
+ throw new OxygenError("invalid_request", "--auth-mode must be secret or none.", { details: { auth_mode: authMode }, exitCode: 1 });
20379
+ }
20380
+ return {
20381
+ transport: "push",
20382
+ row_schema: rowSchema,
20383
+ ...(tableRef ? { table: tableRef } : { create_table: { name: create } }),
20384
+ ...(readOption(options.name) ? { name: readOption(options.name) } : {}),
20385
+ ...(readOption(options.upsertKey) ? { upsert_key: readOption(options.upsertKey) } : {}),
20386
+ ...(readOption(options.itemsPath) ? { items_path: readOption(options.itemsPath) } : {}),
20387
+ ...(readOption(options.eventIdPath) ? { event_id_path: readOption(options.eventIdPath) } : {}),
20388
+ ...(readOption(options.eventTypePath) ? { event_type_path: readOption(options.eventTypePath) } : {}),
20389
+ ...(readOption(options.occurredAtPath) ? { occurred_at_path: readOption(options.occurredAtPath) } : {}),
20390
+ ...(authMode ? { auth_mode: authMode } : {}),
20391
+ };
20392
+ }
20393
+ // The web-intent bind body: transport push plus the source as the feed kind, and
20394
+ // exactly one of an existing table or create_table. Nothing else is accepted —
20395
+ // the reveal shape, the address, and the (zero) cost are all fixed by OXYGEN's own
20396
+ // receiver — so every other flag is refused here by name, before any request.
20397
+ function readFeedWebIntentBindBody(table, options) {
20398
+ const source = readOption(options.source);
20399
+ if (!source || !FEED_BIND_WEB_INTENT_SOURCES.includes(source)) {
20400
+ throw new OxygenError("invalid_request", `--source must be one of: ${FEED_BIND_WEB_INTENT_SOURCES.join(", ")}.`, { details: { source: source ?? null, accepted: [...FEED_BIND_WEB_INTENT_SOURCES] }, exitCode: 1 });
20401
+ }
20402
+ for (const [key, flag] of FEED_BIND_WEB_INTENT_REFUSED_FLAGS) {
20403
+ if (options[key] !== undefined) {
20404
+ throw new OxygenError("invalid_table_feed", `${flag} does not apply to a --source ${source} feed: its rows come from OXYGEN's own website-visit receiver with a fixed shape. Drop ${flag}, or drop --source to bind a pull or --schema-json feed.`, { details: { flag, source }, exitCode: 1 });
20405
+ }
20406
+ }
20407
+ const tableRef = readOption(table);
20408
+ const create = readOption(options.create);
20409
+ if (tableRef && create) {
20410
+ throw new OxygenError("invalid_request", "Pass a table or --create <name>, not both.", { exitCode: 1 });
20411
+ }
20412
+ if (!tableRef && !create) {
20413
+ throw new OxygenError("invalid_request", "Pass the table id or slug that receives the reveals, or --create <name> to create the table with the fixed reveal columns.", { exitCode: 1 });
20414
+ }
20415
+ return {
20416
+ transport: "push",
20417
+ feed_kind: source,
20418
+ ...(tableRef ? { table: tableRef } : { create_table: { name: create } }),
20419
+ ...(readOption(options.name) ? { name: readOption(options.name) } : {}),
20420
+ ...(readOption(options.upsertKey) ? { upsert_key: readOption(options.upsertKey) } : {}),
20421
+ };
20422
+ }
20423
+ // Human receipt for a web-intent bind. There is no secret and no webhook URL to
20424
+ // print — this feed has no address — so what a reader needs instead is the table
20425
+ // the reveals land in, that they cost nothing, and the one precondition that
20426
+ // decides whether any row ever appears.
20427
+ function writeFeedWebIntentBindReceipt(data) {
20428
+ if (!isRecord(data))
20429
+ return;
20430
+ const feed = isRecord(data.feed) ? data.feed : {};
20431
+ const table = isRecord(feed.table) ? feed.table : {};
20432
+ const lines = [];
20433
+ const feedId = readRecordString(feed, "id");
20434
+ lines.push(`Web intent feed "${readRecordString(feed, "name") ?? "unnamed"}"${feedId ? ` (${feedId})` : ""} bound: RB2B website-visit reveals now land as rows.`);
20435
+ const tableName = readRecordString(table, "display_name");
20436
+ const tableSlug = readRecordString(table, "slug") ?? readRecordString(table, "id");
20437
+ if (tableName || tableSlug) {
20438
+ lines.push(` Table: ${tableName ?? tableSlug}${tableName && tableSlug ? ` (${tableSlug})` : ""}`);
20439
+ }
20440
+ const upsertKey = readRecordString(feed, "upsert_key");
20441
+ if (upsertKey)
20442
+ lines.push(` Upserted on: ${upsertKey}`);
20443
+ lines.push(" Cost: 0 credits per reveal. Every reveal also stays a website_visit Signal.");
20444
+ lines.push(" Rows only start once RB2B is connected to this workspace; there is no self-serve connect step yet.");
20445
+ const nextStep = readRecordString(data, "next_step");
20446
+ if (nextStep)
20447
+ lines.push(` Next: ${nextStep}`);
20448
+ const webUrl = readRecordString(data, "web_url") ?? readRecordString(data, "deepLink");
20449
+ if (webUrl)
20450
+ lines.push(` ${webUrl}`);
20451
+ process.stderr.write(`${lines.join("\n")}\n`);
20452
+ }
20453
+ // Human receipt for a push-feed bind, on stderr ahead of the payload (stdout stays
20454
+ // the machine JSON, like every other command). The secret is returned exactly once —
20455
+ // only its hash is stored — so it is printed with the warning right next to it.
20456
+ function writeFeedPushBindReceipt(data) {
20457
+ if (!isRecord(data))
20458
+ return;
20459
+ const feed = isRecord(data.feed) ? data.feed : {};
20460
+ const table = isRecord(feed.table) ? feed.table : {};
20461
+ const lines = [];
20462
+ const feedId = readRecordString(feed, "id");
20463
+ lines.push(`Push feed "${readRecordString(feed, "name") ?? "unnamed"}"${feedId ? ` (${feedId})` : ""} bound: typed or rejected.`);
20464
+ const tableName = readRecordString(table, "display_name");
20465
+ const tableSlug = readRecordString(table, "slug") ?? readRecordString(table, "id");
20466
+ if (tableName || tableSlug) {
20467
+ lines.push(` Table: ${tableName ?? tableSlug}${tableName && tableSlug ? ` (${tableSlug})` : ""}`);
20468
+ }
20469
+ const webhookUrl = readRecordString(data, "webhookUrl") ?? readRecordString(feed, "endpoint_url");
20470
+ if (webhookUrl)
20471
+ lines.push(` Webhook URL: ${webhookUrl}`);
20472
+ const secret = readRecordString(data, "secret");
20473
+ if (secret) {
20474
+ lines.push(` Secret: ${secret}`);
20475
+ lines.push(" This secret is shown once and never again: store it now. Senders pass it in the x-oxygen-table-webhook-secret header.");
20476
+ }
20477
+ else {
20478
+ lines.push(" No secret (auth mode none): anyone holding the webhook URL can post.");
20479
+ }
20480
+ const schema = isRecord(data.row_schema) ? data.row_schema : isRecord(feed.row_schema) ? feed.row_schema : null;
20481
+ const properties = schema && isRecord(schema.properties) ? schema.properties : {};
20482
+ const required = new Set(schema && Array.isArray(schema.required) ? schema.required.filter((entry) => typeof entry === "string") : []);
20483
+ const names = Object.keys(properties);
20484
+ lines.push(` Row schema (${names.length} propert${names.length === 1 ? "y" : "ies"}; a delivery that does not match is refused with 422):`);
20485
+ for (const name of names) {
20486
+ const property = properties[name];
20487
+ const type = isRecord(property)
20488
+ ? Array.isArray(property.type)
20489
+ ? property.type.filter((entry) => typeof entry === "string").join("|")
20490
+ : typeof property.type === "string" ? property.type : "any"
20491
+ : "any";
20492
+ lines.push(` ${name}: ${type || "any"}${required.has(name) ? " (required)" : ""}`);
20493
+ }
20494
+ if (schema?.additionalProperties === false)
20495
+ lines.push(" no other properties allowed");
20496
+ lines.push(" Cost: 0 credits per delivery.");
20497
+ const nextStep = readRecordString(data, "next_step");
20498
+ if (nextStep)
20499
+ lines.push(` Next: ${nextStep}`);
20500
+ const webUrl = readRecordString(data, "web_url") ?? readRecordString(data, "deepLink");
20501
+ if (webUrl)
20502
+ lines.push(` ${webUrl}`);
20503
+ process.stderr.write(`${lines.join("\n")}\n`);
20504
+ }
19441
20505
  function readSignalsSearchPlanBody(options, promptArg) {
19442
20506
  // The prompt may arrive as --prompt or as the positional argument; the flag wins.
19443
20507
  const promptSource = options.prompt ?? promptArg;
@@ -19550,8 +20614,8 @@ function readCompaniesSearchRunBody(options, promptArg) {
19550
20614
  const promptSource = options.prompt ?? promptArg;
19551
20615
  const prompt = promptSource ? readFileIfPresent(promptSource) : null;
19552
20616
  const plan = options.planJson ? readSearchPlanJson(options.planJson) : null;
19553
- if (!prompt && !plan && options.source !== "company_search") {
19554
- throw new OxygenError("invalid_request", "Pass --prompt, --plan-json, or --source company_search with --source-filters-json.", { exitCode: 1 });
20617
+ if (!prompt && !plan && options.source !== "company_search" && options.source !== "hiring") {
20618
+ throw new OxygenError("invalid_request", "Pass --prompt, --plan-json, or --source company_search|hiring with --source-filters-json.", { exitCode: 1 });
19555
20619
  }
19556
20620
  const maxPages = readPositiveInt(options.maxPages);
19557
20621
  const maxCredits = readPositiveNumber(options.maxCredits);
@@ -19562,6 +20626,7 @@ function readCompaniesSearchRunBody(options, promptArg) {
19562
20626
  ...(plan ? { plan } : {}),
19563
20627
  ...(options.source ? { source: options.source } : {}),
19564
20628
  ...(options.sourceFiltersJson ? { source_filters: parseJsonObject(readFileIfPresent(options.sourceFiltersJson)) } : {}),
20629
+ ...(readOption(options.tableName) ? { table_name: readOption(options.tableName) } : {}),
19565
20630
  ...(options.routeId ? { route_id: options.routeId } : {}),
19566
20631
  ...(options.toolId ? { tool_id: options.toolId } : {}),
19567
20632
  ...(options.table ? { table: options.table } : {}),
@@ -19712,7 +20777,7 @@ function readSearchPlanJson(value) {
19712
20777
  }
19713
20778
  // Table-driven Employee Finder help, written once and shared by `people search
19714
20779
  // plan` and `people search run` so the price shape reads the same on both.
19715
- const PEOPLE_SEARCH_FROM_TABLE_HELP = "Fan out Blitz Employee Finder over every company LinkedIn URL in a table column: 10 credits per company per page, rows land in a new people table linked back to each company row. Pass the table id or slug, plus --linkedin-url-column.";
20780
+ const PEOPLE_SEARCH_FROM_TABLE_HELP = "Search every company LinkedIn URL in a table column for the people you describe — --titles, --exclude-titles, --seniorities, --job-functions, --education, location and --min-connections all apply per company. 10 credits per company per page; rows land in a new people table with a company relation back to each source row. Pass the table id or slug plus --linkedin-url-column, and bound it with --company-limit. For up to 50 companies this is the expensive way: people search preview with company.linkedin_url samples them free and people search run --source people_search charges one page across all of them.";
19716
20781
  const PEOPLE_SEARCH_LINKEDIN_COLUMN_HELP = "Column key in --from-table holding each company's LinkedIn URL.";
19717
20782
  const PEOPLE_SEARCH_COMPANY_OFFSET_HELP = "Skip this many companies in --from-table. Use it to source the rest after a first-company preview.";
19718
20783
  const PEOPLE_SEARCH_COMPANY_LIMIT_HELP = "Fetch people for at most this many companies from --from-table. Start with --company-limit 1.";
@@ -19724,6 +20789,18 @@ const PEOPLE_SEARCH_MIN_CONNECTIONS_HELP = "Only return people with at least thi
19724
20789
  // offset/limit window rides INSIDE `source` when the source is declared here; with
19725
20790
  // a saved plan it rides as `source_overrides`, which the run route applies on top
19726
20791
  // of the plan's own source.
20792
+ /** The company lanes that share Company Search's flow; anything else is a typo, not a provider. */
20793
+ function readCompanySearchSource(value) {
20794
+ const source = readOption(value) ?? "company_search";
20795
+ if (source !== "company_search" && source !== "hiring") {
20796
+ throw new OxygenError("invalid_request", "--source must be company_search or hiring.", { exitCode: 1 });
20797
+ }
20798
+ return source;
20799
+ }
20800
+ function readCompanySourceQuery(value) {
20801
+ const source = readCompanySearchSource(value);
20802
+ return source === "company_search" ? "" : `?source=${source}`;
20803
+ }
19727
20804
  function readPeopleSearchSource(options) {
19728
20805
  const table = readOption(options.fromTable);
19729
20806
  const column = readOption(options.linkedinUrlColumn);
@@ -19784,13 +20861,36 @@ function readPeopleSearchPlanBody(options) {
19784
20861
  }
19785
20862
  function readPeopleSearchRunBody(options) {
19786
20863
  const plan = options.planJson ? readSearchPlanJson(options.planJson) : null;
20864
+ // --source people_search is the Find people lane: the free preview's filters
20865
+ // ARE the request, so no prompt, plan or companies table is synthesized.
20866
+ if (options.source !== undefined && options.source !== "people_search") {
20867
+ throw new OxygenError("invalid_request", "--source must be people_search; use --from-table for people at your companies.", { exitCode: 1 });
20868
+ }
20869
+ if (options.source === "people_search") {
20870
+ if (!options.sourceFiltersJson) {
20871
+ throw new OxygenError("invalid_request", "--source people_search needs --source-filters-json (the same filters as people search preview).", { exitCode: 1 });
20872
+ }
20873
+ const maxCreditsNative = readPositiveNumber(options.maxCredits);
20874
+ const nativeTarget = readPositiveInt(options.targetCount);
20875
+ return {
20876
+ source: "people_search",
20877
+ source_filters: parseJsonObject(readFileIfPresent(options.sourceFiltersJson)),
20878
+ ...(readOption(options.tableName) ? { table_name: readOption(options.tableName) } : {}),
20879
+ ...(options.table ? { table: options.table } : {}),
20880
+ ...(options.project ? { project: options.project } : {}),
20881
+ ...(options.mode ? { mode: options.mode } : {}),
20882
+ ...(maxCreditsNative !== undefined ? { max_credits: maxCreditsNative } : {}),
20883
+ ...(nativeTarget !== undefined ? { target_count: nativeTarget } : {}),
20884
+ ...(options.approved ? { approved: true } : {}),
20885
+ };
20886
+ }
19787
20887
  const source = readPeopleSearchSource(options);
19788
20888
  // --from-table is itself the request, so it satisfies the prompt requirement the
19789
20889
  // same way --plan-json does. A submitted plan already carries its own source and
19790
20890
  // prompt, so nothing is synthesized on top of one.
19791
20891
  const prompt = readPeopleSearchPrompt(options, plan ? null : source);
19792
20892
  if (!prompt && !plan) {
19793
- throw new OxygenError("invalid_request", "Pass --prompt, --plan-json, or --from-table with --linkedin-url-column.", { exitCode: 1 });
20893
+ throw new OxygenError("invalid_request", "Pass --prompt, --plan-json, --from-table with --linkedin-url-column, or --source people_search with --source-filters-json.", { exitCode: 1 });
19794
20894
  }
19795
20895
  const maxPages = readPeopleSearchMaxPages(options);
19796
20896
  const maxCredits = readPositiveNumber(options.maxCredits);
@@ -20037,8 +21137,49 @@ function normalizeSessionStepStatus(value) {
20037
21137
  exitCode: 1,
20038
21138
  });
20039
21139
  }
20040
- // skipcq: JS-R1005 — intentional branching over file format, create/upsert, and sync/background import modes
21140
+ /**
21141
+ * `--url` hands the fetch to the server: the same durable import the file path
21142
+ * enqueues, with the bytes read by OXYGEN behind SSRF, size and content guards,
21143
+ * so the CLI never downloads anything and a 0-credit preview is one flag away.
21144
+ */
21145
+ async function importRowsFromUrl(table, options) {
21146
+ if (options.create && table) {
21147
+ throw new OxygenError("invalid_import_target", "Pass either a table argument or --create, not both.", { exitCode: 1 });
21148
+ }
21149
+ if (!options.preview && !options.create && !table) {
21150
+ throw new OxygenError("invalid_import_target", "Pass a table argument or --create <name>, or add --preview to look first.", { exitCode: 1 });
21151
+ }
21152
+ const result = await requestOxygen("/api/cli/tables/import-from-url", {
21153
+ method: "POST",
21154
+ timeoutMs: 120_000,
21155
+ body: {
21156
+ url: options.url,
21157
+ mode: options.preview ? "preview" : "import",
21158
+ ...(table ? { table } : {}),
21159
+ ...(options.create ? { create_name: options.create } : {}),
21160
+ ...(readOption(options.project) ? { project: readOption(options.project) } : {}),
21161
+ ...(readOption(options.upsertKey) ? { upsert_key: readOption(options.upsertKey) } : {}),
21162
+ ...(readOption(options.format) ? { format: readOption(options.format) } : {}),
21163
+ ...(readOption(options.sheet) ? { sheet: readOption(options.sheet) } : {}),
21164
+ },
21165
+ });
21166
+ if (options.preview)
21167
+ return result;
21168
+ emitQueueWaitStderrNote(readRecord(result, "queue_wait"));
21169
+ return withImportWaitNextStep({ ...result, background: true, autoBackground: true });
21170
+ }
20041
21171
  async function importRows(table, options) {
21172
+ if (options.url)
21173
+ return importRowsFromUrl(table, { ...options, url: options.url });
21174
+ if (!options.file) {
21175
+ throw new OxygenError("invalid_request", "Pass --file <path> or --url <https-link>.", { exitCode: 1 });
21176
+ }
21177
+ if (options.preview) {
21178
+ throw new OxygenError("invalid_request", "--preview only applies to --url imports.", { exitCode: 1 });
21179
+ }
21180
+ return importRowsFromFile(table, { ...options, file: options.file });
21181
+ }
21182
+ async function importRowsFromFile(table, options) {
20042
21183
  const format = normalizeRowsFormat(options.format, inferRowsFileFormat(options.file));
20043
21184
  const parsedRows = await readRowsFile(options.file, format, options.sheet);
20044
21185
  if (parsedRows.length === 0) {
@@ -21679,13 +22820,6 @@ function escapeCsvField(value) {
21679
22820
  : String(value);
21680
22821
  return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, "\"\"")}"` : text;
21681
22822
  }
21682
- function chunk(values, size) {
21683
- const chunks = [];
21684
- for (let index = 0; index < values.length; index += size) {
21685
- chunks.push(values.slice(index, index + size));
21686
- }
21687
- return chunks;
21688
- }
21689
22823
  function readCount(value) {
21690
22824
  return typeof value === "number" && Number.isFinite(value) ? value : 0;
21691
22825
  }
@@ -23192,20 +24326,181 @@ function renderTextTable(headers, rows) {
23192
24326
  // Sequence state is derived only by the shared API. These helpers render those
23193
24327
  // facts; they never infer flowing/blocked/live client-side. JSON remains the
23194
24328
  // untouched API data inside the standard CLI envelope.
23195
- const SEQUENCE_OPERATIONAL_STATE_ORDER = ["empty", "drained", "blocked", "degraded", "flowing", "paced"];
23196
- async function handleSequenceReadAction(command, options, action, render) {
24329
+ const SEQUENCE_OPERATIONAL_STATE_ORDER = ["empty", "drained", "never_started", "blocked", "degraded", "flowing", "paced"];
24330
+ /**
24331
+ * `crm objects`, as an operator reads it.
24332
+ *
24333
+ * Every crm command printed raw JSON whether or not you asked for it, so the
24334
+ * 2026-09-20 blind eval's agent piped this exact command through `python3 -c`
24335
+ * just to see which objects existed. A list of four objects should not need a
24336
+ * program to read. `--json` is untouched and remains the contract.
24337
+ */
24338
+ const CRM_NORMALIZATION_WORDS = {
24339
+ email_v1: "email address",
24340
+ domain_v1: "website domain",
24341
+ linkedin_url_v1: "LinkedIn URL",
24342
+ exact_text_v1: "exact text",
24343
+ uuid_v1: "ID",
24344
+ };
24345
+ function formatCrmObjects(data) {
24346
+ const objects = Array.isArray(data.objects) ? data.objects.filter(isRecord) : [];
24347
+ if (objects.length === 0) {
24348
+ return "No CRM objects yet. Create the standard set with: oxygen crm setup --live\n";
24349
+ }
24350
+ const rows = objects.map((object) => {
24351
+ const attributes = Array.isArray(object.attributes) ? object.attributes.filter(isRecord) : [];
24352
+ const identities = Array.isArray(object.identities) ? object.identities.filter(isRecord) : [];
24353
+ // System attributes are bookkeeping, not fields anyone chose.
24354
+ const fieldCount = attributes.filter((attribute) => attribute.attributeKind !== "system").length;
24355
+ const primary = identities.find((identity) => identity.isPrimary === true) ?? identities[0];
24356
+ return [
24357
+ String(object.pluralName || object.displayName || object.slug || "—"),
24358
+ object.standardKey ? "standard" : "custom",
24359
+ String(fieldCount),
24360
+ describeCrmIdentity(primary, attributes),
24361
+ String(object.slug ?? "—"),
24362
+ ];
24363
+ });
24364
+ return [
24365
+ `${objects.length} object${objects.length === 1 ? "" : "s"}`,
24366
+ "",
24367
+ ...renderTextTable(["OBJECT", "KIND", "FIELDS", "SAME RECORD WHEN", "SLUG"], rows),
24368
+ "",
24369
+ " One object's fields and links: oxygen crm describe <slug>",
24370
+ " In the app: /settings/data-model",
24371
+ "",
24372
+ ].join("\n");
24373
+ }
24374
+ /** "Website domain (website domain)" is noise, so the comparison is named only
24375
+ * when it says something the field name does not. An object with no identity
24376
+ * is called out rather than left blank: it duplicates on every re-import. */
24377
+ function describeCrmIdentity(identity, attributes) {
24378
+ if (!identity)
24379
+ return "nothing — can duplicate";
24380
+ const attributeSlug = typeof identity.attributeSlug === "string" ? identity.attributeSlug : null;
24381
+ const attribute = attributeSlug
24382
+ ? attributes.find((candidate) => candidate.slug === attributeSlug)
24383
+ : undefined;
24384
+ const fieldName = String(attribute?.displayName ?? identity.displayName ?? attributeSlug ?? "—");
24385
+ const normalization = typeof identity.normalization === "string" ? identity.normalization : "";
24386
+ const words = CRM_NORMALIZATION_WORDS[normalization];
24387
+ return words && words !== fieldName.toLowerCase() ? `${fieldName} (${words})` : fieldName;
24388
+ }
24389
+ /** A read command that prints a readable block instead of the raw envelope.
24390
+ * `--json` still emits the untouched API envelope, so the lens is presentation
24391
+ * only and never becomes a second contract. Used by the sequencer lenses and by
24392
+ * `crm objects`. */
24393
+ async function handleReadActionWithLens(command, options, action, render) {
24394
+ try {
24395
+ const data = await action();
24396
+ if (options.json) {
24397
+ writeJson(success(command, data));
24398
+ return;
24399
+ }
24400
+ process.stdout.write(isRecord(data) ? render(data) : `${JSON.stringify(data, null, 2)}\n`);
24401
+ }
24402
+ catch (error) {
24403
+ emitCliFailure(command, error);
24404
+ }
24405
+ }
24406
+ /**
24407
+ * A state-changing sequencer command that prints a readable block instead of the
24408
+ * raw envelope. Same shape as `handleReadActionWithLens`, plus the billing
24409
+ * notices every write path owes the operator.
24410
+ */
24411
+ async function handleSequenceWriteAction(command, options, action, render) {
23197
24412
  try {
23198
24413
  const data = await action();
24414
+ writeBillingNotices(command, data);
23199
24415
  if (options.json) {
23200
24416
  writeJson(success(command, data));
23201
24417
  return;
23202
24418
  }
23203
24419
  process.stdout.write(isRecord(data) ? render(data) : `${JSON.stringify(data, null, 2)}\n`);
24420
+ writeCreditsReceipt(data);
23204
24421
  }
23205
24422
  catch (error) {
23206
24423
  emitCliFailure(command, error);
23207
24424
  }
23208
24425
  }
24426
+ /**
24427
+ * The external-campaign push, in the shape an operator reads before approving:
24428
+ * what campaign it resolved, what is mapped onto which column, how many rows
24429
+ * each gate drops, and — when something is in the way — the one command that
24430
+ * clears it. A dry run that is blocked still prints the row counts, because
24431
+ * "you are not connected" and "your table has no emails" are different problems
24432
+ * and the operator should learn both in one pass.
24433
+ */
24434
+ function formatSequencePushExternal(data) {
24435
+ const lines = [];
24436
+ const provider = stringValue(data.provider) ?? "provider";
24437
+ const campaign = recordValue(data.campaign);
24438
+ const resolved = recordValue(campaign.resolved);
24439
+ const table = recordValue(data.table);
24440
+ const rows = recordValue(data.rows);
24441
+ const live = stringValue(data.mode) === "live";
24442
+ const campaignName = stringValue(resolved.name) ?? stringValue(campaign.name);
24443
+ const campaignId = stringValue(resolved.id) ?? stringValue(campaign.id);
24444
+ lines.push(campaignName
24445
+ ? `${live ? "Pushing to" : "Preview: push to"} ${provider} campaign "${campaignName}"${campaignId ? ` (${campaignId})` : ""}`
24446
+ : `${live ? "Pushing to" : "Preview: push to"} ${provider} campaign "${stringValue(campaign.requested) ?? "?"}" — NOT RESOLVED (${stringValue(campaign.reason) ?? "unknown"})`);
24447
+ lines.push(`Table: ${stringValue(table.display_name) ?? stringValue(table.slug) ?? stringValue(table.id) ?? "?"}`);
24448
+ const connection = recordValue(data.connection);
24449
+ if (stringValue(connection.status) === "missing") {
24450
+ lines.push(`Connection: NOT CONNECTED — ${stringValue(connection.connect_command) ?? `oxygen integrations connect ${provider} --api-key <key>`}`);
24451
+ }
24452
+ else if (stringValue(connection.connection_id)) {
24453
+ lines.push(`Connection: ${stringValue(connection.connection_id)}`);
24454
+ }
24455
+ const identityField = stringValue(rows.identity_field) ?? "email";
24456
+ lines.push([
24457
+ `Rows: ${numericValue(rows.scoped) ?? 0} scoped`,
24458
+ `${numericValue(rows.with_identity) ?? 0} with ${identityField}`,
24459
+ `${numericValue(rows.missing_identity) ?? 0} missing ${identityField}`,
24460
+ `${numericValue(rows.duplicate_identity) ?? 0} duplicate`,
24461
+ `${numericValue(rows.already_pushed) ?? 0} already pushed`,
24462
+ `${numericValue(rows.to_push) ?? 0} to push`,
24463
+ ].join(" | "));
24464
+ if (rows.has_more === true) {
24465
+ lines.push("More rows match than one push covers — re-run after this one to continue.");
24466
+ }
24467
+ const mapping = recordValue(data.field_mapping);
24468
+ const mapped = Object.entries(mapping).map(([field, column]) => `${field}=${String(column)}`);
24469
+ lines.push(`Mapping: ${mapped.length > 0 ? mapped.join(", ") : "none detected"}`);
24470
+ const column = recordValue(data.column);
24471
+ if (stringValue(column.key)) {
24472
+ lines.push(`Provenance column: ${stringValue(column.key)}${column.exists === true ? "" : " (will be created)"}`);
24473
+ }
24474
+ const samples = Array.isArray(data.sample_leads) ? data.sample_leads : [];
24475
+ if (samples.length > 0) {
24476
+ lines.push("Sample leads (exactly as they will be sent):");
24477
+ for (const sample of samples)
24478
+ lines.push(` ${JSON.stringify(sample)}`);
24479
+ }
24480
+ if (stringValue(data.dedupe))
24481
+ lines.push(`Dedupe: ${stringValue(data.dedupe)}`);
24482
+ const candidates = Array.isArray(campaign.candidates) ? campaign.candidates.filter(isRecord) : [];
24483
+ if (candidates.length > 0) {
24484
+ lines.push("Campaigns on this connection:");
24485
+ for (const entry of candidates) {
24486
+ lines.push(` ${stringValue(entry.name) ?? "?"} (${stringValue(entry.id) ?? "?"}${stringValue(entry.status) ? `, ${stringValue(entry.status)}` : ""})`);
24487
+ }
24488
+ }
24489
+ const blockedBy = Array.isArray(data.blocked_by) ? data.blocked_by.filter((entry) => typeof entry === "string") : [];
24490
+ if (blockedBy.length > 0)
24491
+ lines.push(`Blocked: ${blockedBy.join(", ")}`);
24492
+ if (stringValue(data.note))
24493
+ lines.push(stringValue(data.note));
24494
+ const run = recordValue(data.run);
24495
+ if (stringValue(run.id))
24496
+ lines.push(`Run: ${stringValue(run.id)} (${stringValue(run.status) ?? "queued"})`);
24497
+ if (stringValue(data.next_action))
24498
+ lines.push(`Next: ${stringValue(data.next_action)}`);
24499
+ const link = stringValue(data.web_url) ?? stringValue(data.deepLink);
24500
+ if (link)
24501
+ lines.push(link);
24502
+ return `${lines.join("\n")}\n`;
24503
+ }
23209
24504
  function formatSequenceListHealth(data) {
23210
24505
  const sequences = Array.isArray(data.sequences) ? data.sequences.filter(isRecord) : [];
23211
24506
  const lines = [...formatSequenceFleetSummary(data.fleet), ""];
@@ -23221,6 +24516,122 @@ function formatSequenceListHealth(data) {
23221
24516
  lines.push(listLink);
23222
24517
  return `${lines.join("\n")}\n`;
23223
24518
  }
24519
+ /**
24520
+ * Render one funnel metric.
24521
+ *
24522
+ * `numberText` defaults a missing value to "0", which is exactly the lie this
24523
+ * whole contract exists to stop: a campaign whose workspace has no verified
24524
+ * tracking domain would print "0" opens, indistinguishable from "nobody opened
24525
+ * it". An unmeasured metric carries a coverage entry and prints what it is.
24526
+ */
24527
+ function funnelMetricText(funnel, metric) {
24528
+ const value = numericValue(funnel[metric]);
24529
+ if (value !== null)
24530
+ return String(value);
24531
+ const coverage = recordValue(recordValue(funnel.coverage)[metric]);
24532
+ const state = stringValue(coverage.state);
24533
+ if (state === "not_supported")
24534
+ return "n/a";
24535
+ if (state)
24536
+ return "not tracked";
24537
+ return "—";
24538
+ }
24539
+ function funnelRateText(funnel, rate, metric) {
24540
+ const value = numericValue(funnel[rate]);
24541
+ if (value !== null)
24542
+ return `${(value * 100).toFixed(1)}%`;
24543
+ const coverage = recordValue(recordValue(funnel.coverage)[metric]);
24544
+ const state = stringValue(coverage.state);
24545
+ if (state === "not_supported")
24546
+ return "n/a";
24547
+ if (state)
24548
+ return "not tracked";
24549
+ return "—";
24550
+ }
24551
+ /**
24552
+ * The per-channel funnel block, shared by `sequences stats` and `sequences
24553
+ * analytics`.
24554
+ *
24555
+ * `sequences analytics` printed no funnel at all before this, only operational
24556
+ * health. A blind user eval run against dev asked exactly the question this
24557
+ * answers -- "which channel got the most replies, and what is my reply rate on
24558
+ * each" -- and had to open every campaign's stats block one by one and hand-sum
24559
+ * the channels in prose, because no command grouped them.
24560
+ */
24561
+ function formatSequenceChannelFunnels(funnels, replies) {
24562
+ const byChannel = recordValue(funnels);
24563
+ const channels = ["linkedin", "email", "whatsapp"].filter((channel) => isRecord(byChannel[channel]));
24564
+ if (channels.length === 0)
24565
+ return [];
24566
+ const rows = channels.map((channel) => {
24567
+ const funnel = recordValue(byChannel[channel]);
24568
+ const invites = recordValue(funnel.invites);
24569
+ return [
24570
+ channel,
24571
+ numberText(funnel.recipients),
24572
+ numberText(funnel.sends),
24573
+ numericValue(invites.sent) === null ? "n/a" : `${numberText(invites.accepted)}/${numberText(invites.sent)}`,
24574
+ funnelMetricText(funnel, "delivered"),
24575
+ funnelMetricText(funnel, "opened"),
24576
+ funnelMetricText(funnel, "replied"),
24577
+ funnelRateText(funnel, "replyRate", "replied"),
24578
+ funnelMetricText(funnel, "positiveReplied"),
24579
+ ];
24580
+ });
24581
+ const lines = ["", ...renderTextTable(["CHANNEL", "PEOPLE", "SENDS", "ACCEPTED", "DELIVERED", "OPENED", "REPLIED", "REPLY RATE", "POSITIVE"], rows)];
24582
+ // Name the remedy once per distinct gap rather than once per row.
24583
+ const remedies = new Map();
24584
+ for (const channel of channels) {
24585
+ const coverage = recordValue(recordValue(byChannel[channel]).coverage);
24586
+ for (const entry of Object.values(coverage)) {
24587
+ const record = recordValue(entry);
24588
+ if (stringValue(record.state) === "not_supported")
24589
+ continue;
24590
+ const detail = stringValue(record.detail);
24591
+ const command = stringValue(recordValue(record.remedy).command);
24592
+ if (detail)
24593
+ remedies.set(detail, command ?? "");
24594
+ }
24595
+ }
24596
+ for (const [detail, command] of remedies) {
24597
+ lines.push(command ? `Not tracked: ${detail} Turn it on with: ${command}` : `Not tracked: ${detail}`);
24598
+ }
24599
+ // A reply can exist on a channel the table has no row for: an invite-only
24600
+ // campaign whose lead answered the invite note replied on LinkedIn without ever
24601
+ // being MESSAGED, so it is not in that channel's rate denominator. Left
24602
+ // unexplained, the headline ("linkedin 19") disagrees with the table (18) and
24603
+ // the reader has to guess -- a blind user eval hit exactly this and recorded it
24604
+ // as plausible but unverified.
24605
+ const byChannelReplies = recordValue(recordValue(replies).byChannel);
24606
+ for (const channel of ["linkedin", "email", "whatsapp"]) {
24607
+ const counted = numericValue(byChannelReplies[channel]) ?? 0;
24608
+ const inTable = numericValue(recordValue(byChannel[channel]).replied) ?? 0;
24609
+ const outside = counted - inTable;
24610
+ if (outside <= 0)
24611
+ continue;
24612
+ lines.push(`${channel}: ${outside} more ${outside === 1 ? "reply" : "replies"} came from a campaign with no message step `
24613
+ + "(an invite note, say), so they are counted in the total above but have no reply rate here.");
24614
+ }
24615
+ return lines;
24616
+ }
24617
+ /** `Replies: 4 (linkedin 2 · email 1 · unattributed 1)` -- the one-line answer. */
24618
+ function formatSequenceReplyRollup(replies) {
24619
+ const rollup = recordValue(replies);
24620
+ const total = numericValue(rollup.total);
24621
+ if (total === null)
24622
+ return null;
24623
+ const byChannel = recordValue(rollup.byChannel);
24624
+ const parts = ["linkedin", "email", "whatsapp"]
24625
+ .map((channel) => [channel, numericValue(byChannel[channel])])
24626
+ .filter((entry) => entry[1] !== null && entry[1] > 0)
24627
+ .map(([channel, value]) => `${channel} ${value}`);
24628
+ const unattributed = numericValue(rollup.unattributed) ?? 0;
24629
+ // An unattributed reply is a recording gap made visible, never folded into a
24630
+ // channel and never dropped.
24631
+ if (unattributed > 0)
24632
+ parts.push(`unattributed ${unattributed}`);
24633
+ return parts.length > 0 ? `Replies: ${total} (${parts.join(" · ")})` : `Replies: ${total}`;
24634
+ }
23224
24635
  function formatSequenceStatsHealth(data) {
23225
24636
  const stats = recordValue(data.stats);
23226
24637
  const operational = recordValue(stats.operational);
@@ -23236,13 +24647,22 @@ function formatSequenceStatsHealth(data) {
23236
24647
  if (failureMix !== "—")
23237
24648
  lines.push(`Failure mix: ${failureMix}`);
23238
24649
  lines.push(...formatActionKindBreakdown(operational.failureAnalytics));
23239
- lines.push("", ...renderTextTable(["FUNNEL", "COUNT"], [
23240
- ["Enrolled", numberText(stats.enrolled)],
23241
- ["Invites sent", numberText(stats.invitesSent ?? stats.connectionRequestsSent)],
23242
- ["Connected", numberText(stats.connected ?? stats.connectionRequestsAccepted)],
23243
- ["Messages sent", numberText(stats.messagesSent)],
23244
- ["Replies", numberText(stats.totalReplies ?? stats.replied)],
23245
- ]));
24650
+ lines.push("", `Enrolled: ${numberText(stats.enrolled)}`);
24651
+ const replyLine = formatSequenceReplyRollup(stats.replies);
24652
+ if (replyLine)
24653
+ lines.push(replyLine);
24654
+ const funnelLines = formatSequenceChannelFunnels(stats.funnels, stats.replies);
24655
+ if (funnelLines.length > 0) {
24656
+ lines.push(...funnelLines);
24657
+ }
24658
+ else {
24659
+ lines.push("", ...renderTextTable(["FUNNEL", "COUNT"], [
24660
+ ["Invites sent", numberText(stats.invitesSent ?? stats.connectionRequestsSent)],
24661
+ ["Connected", numberText(stats.connected ?? stats.connectionRequestsAccepted)],
24662
+ ["Messages sent", numberText(stats.messagesSent)],
24663
+ ["Replies", numberText(stats.totalReplies ?? stats.replied)],
24664
+ ]));
24665
+ }
23246
24666
  const link = stringValue(data.web_url) ?? stringValue(data.deepLink);
23247
24667
  if (link)
23248
24668
  lines.push("", link);
@@ -23264,8 +24684,13 @@ function formatSequenceAnalyticsHealth(data) {
23264
24684
  `Selected-window outcomes: ${formatSequenceFailureTotals(failureAnalytics)}`,
23265
24685
  `Failure mix: ${formatCountRecord(recordValue(recordValue(failureAnalytics).byFailureClass))}`,
23266
24686
  ...formatActionKindBreakdown(failureAnalytics),
23267
- "",
23268
24687
  ];
24688
+ const totals = recordValue(analytics.totals);
24689
+ const replyLine = formatSequenceReplyRollup(totals.replies);
24690
+ if (replyLine)
24691
+ lines.push(replyLine);
24692
+ lines.push(...formatSequenceChannelFunnels(totals.funnels, totals.replies));
24693
+ lines.push("");
23269
24694
  if (sequences.length === 0) {
23270
24695
  lines.push("No sequences in this analytics scope.");
23271
24696
  }
@@ -23300,9 +24725,16 @@ function formatSequenceFleetSummary(value) {
23300
24725
  if (Object.keys(fleet).length === 0)
23301
24726
  return ["Fleet counts: unavailable (legacy API response)"];
23302
24727
  const states = recordValue(fleet.byOperationalState);
24728
+ // Only when there is something to act on: a workspace with no parked drafts
24729
+ // should not read a line of zeroes every time it lists its sequences.
24730
+ const neverStarted = typeof fleet.neverStarted === "number" ? fleet.neverStarted : 0;
24731
+ const parkedLeads = typeof fleet.neverStartedEnrollments === "number" ? fleet.neverStartedEnrollments : 0;
23303
24732
  return [
23304
24733
  `Fleet: ${numberText(fleet.markedActive)} marked active · ${numberText(fleet.operationallyLive)} operationally live · ${numberText(fleet.total)} total`,
23305
24734
  `States: ${SEQUENCE_OPERATIONAL_STATE_ORDER.map((state) => `${state} ${numberText(states[state])}`).join(" · ")}`,
24735
+ ...(neverStarted > 0
24736
+ ? [`Never started: ${numberText(neverStarted)} draft${neverStarted === 1 ? "" : "s"} holding ${numberText(parkedLeads)} enrolled lead${parkedLeads === 1 ? "" : "s"} — these send nothing until launched. List them with \`oxygen sequences list --operational-state never_started\`, then check one with \`oxygen sequences get <sequence> --readiness\` (read-only).`]
24737
+ : []),
23306
24738
  ];
23307
24739
  }
23308
24740
  function formatSequenceOpenWork(value) {
@@ -26025,14 +27457,28 @@ catch (error) {
26025
27457
  if (hint)
26026
27458
  process.stderr.write(`${hint}\n`);
26027
27459
  }
27460
+ // `error: unknown option '--filter'` on `tables query` got no suggestion
27461
+ // either (blind user eval, 2026-09-18): commander caps its suggester at
27462
+ // three edits, and `--filter-json` is five away.
27463
+ if (error.code === "commander.unknownOption") {
27464
+ const hint = unknownOptionHint(program, cliArgs, error.message);
27465
+ if (hint)
27466
+ process.stderr.write(`${hint}\n`);
27467
+ }
26028
27468
  if (error.code === "commander.excessArguments" &&
26029
27469
  cliArgs[0] === "skills" &&
26030
27470
  cliArgs[1] === "install") {
26031
27471
  const requestedSkill = cliArgs[2]?.trim() ?? "";
27472
+ // A stray path is almost always `--project .`: the flag is a boolean that
27473
+ // already means "this directory", so the hint must say that rather than
27474
+ // offer a --skill name (blind baseline, 2026-09-19).
27475
+ const looksLikePath = requestedSkill === "." || requestedSkill === ".." || /^[.~]?\//.test(requestedSkill);
26032
27476
  const skillArgument = /^[a-z0-9][a-z0-9_-]*$/i.test(requestedSkill)
26033
27477
  ? requestedSkill
26034
27478
  : "<name>";
26035
- process.stderr.write(`Hint: skill names use the --skill option. Did you mean \`${program.name()} skills install --skill ${skillArgument}\`?\n`);
27479
+ process.stderr.write(looksLikePath
27480
+ ? `Hint: --project takes no value; it installs into the current directory. Did you mean \`${program.name()} skills install --project\`?\n`
27481
+ : `Hint: skill names use the --skill option. Did you mean \`${program.name()} skills install --skill ${skillArgument}\`?\n`);
26036
27482
  }
26037
27483
  process.exitCode = error.exitCode;
26038
27484
  }