@oxygen-agent/cli 1.982.3 → 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 (127) 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 +27 -7
  9. package/dist/help.d.ts +29 -0
  10. package/dist/help.js +139 -0
  11. package/dist/index.js +1875 -164
  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/ugc-commands.d.ts +3 -6
  20. package/dist/ugc-commands.js +2 -1200
  21. package/dist/util.d.ts +1 -1
  22. package/dist/util.js +1 -3
  23. package/node_modules/@oxygen/cli-ugc/dist/commands.d.ts +3 -0
  24. package/node_modules/@oxygen/cli-ugc/dist/commands.js +1178 -0
  25. package/node_modules/@oxygen/cli-ugc/dist/field-parser.d.ts +7 -0
  26. package/node_modules/@oxygen/cli-ugc/dist/field-parser.js +25 -0
  27. package/node_modules/@oxygen/cli-ugc/dist/index.d.ts +14 -0
  28. package/node_modules/@oxygen/cli-ugc/dist/index.js +5 -0
  29. package/node_modules/@oxygen/cli-ugc/package.json +15 -0
  30. package/node_modules/@oxygen/formula/dist/coerce.d.ts +10 -0
  31. package/node_modules/@oxygen/formula/dist/coerce.js +10 -0
  32. package/node_modules/@oxygen/formula/dist/expression.js +14 -1
  33. package/node_modules/@oxygen/formula/dist/formula-functions.js +136 -1
  34. package/node_modules/@oxygen/formula/dist/hash.d.ts +19 -0
  35. package/node_modules/@oxygen/formula/dist/hash.js +199 -0
  36. package/node_modules/@oxygen/formula/dist/index.d.ts +1 -0
  37. package/node_modules/@oxygen/formula/dist/index.js +1 -0
  38. package/node_modules/@oxygen/formula/dist/value-cleaners.d.ts +74 -0
  39. package/node_modules/@oxygen/formula/dist/value-cleaners.js +358 -0
  40. package/node_modules/@oxygen/shared/dist/array-utils.d.ts +5 -0
  41. package/node_modules/@oxygen/shared/dist/array-utils.js +11 -0
  42. package/node_modules/@oxygen/shared/dist/billing.d.ts +103 -47
  43. package/node_modules/@oxygen/shared/dist/billing.js +150 -40
  44. package/node_modules/@oxygen/shared/dist/capability-discovery.d.ts +17 -0
  45. package/node_modules/@oxygen/shared/dist/capability-discovery.js +114 -16
  46. package/node_modules/@oxygen/shared/dist/column-autofill.d.ts +52 -0
  47. package/node_modules/@oxygen/shared/dist/column-autofill.js +80 -0
  48. package/node_modules/@oxygen/shared/dist/column-output-fields.js +14 -10
  49. package/node_modules/@oxygen/shared/dist/company-enrichment-fields.d.ts +108 -0
  50. package/node_modules/@oxygen/shared/dist/company-enrichment-fields.js +545 -0
  51. package/node_modules/@oxygen/shared/dist/copilot-playbooks.d.ts +18 -0
  52. package/node_modules/@oxygen/shared/dist/copilot-playbooks.js +43 -0
  53. package/node_modules/@oxygen/shared/dist/copilot-skills.d.ts +15 -0
  54. package/node_modules/@oxygen/shared/dist/copilot-skills.generated.d.ts +31 -0
  55. package/node_modules/@oxygen/shared/dist/copilot-skills.generated.js +41 -0
  56. package/node_modules/@oxygen/shared/dist/copilot-skills.js +6 -0
  57. package/node_modules/@oxygen/shared/dist/deploy-env.d.ts +74 -0
  58. package/node_modules/@oxygen/shared/dist/deploy-env.js +82 -0
  59. package/node_modules/@oxygen/shared/dist/dnc-rules.d.ts +130 -0
  60. package/node_modules/@oxygen/shared/dist/dnc-rules.js +221 -0
  61. package/node_modules/@oxygen/shared/dist/enrichment-intents.d.ts +103 -0
  62. package/node_modules/@oxygen/shared/dist/enrichment-intents.js +819 -0
  63. package/node_modules/@oxygen/shared/dist/error-message.d.ts +1 -0
  64. package/node_modules/@oxygen/shared/dist/error-message.js +3 -0
  65. package/node_modules/@oxygen/shared/dist/error-redaction.js +1 -3
  66. package/node_modules/@oxygen/shared/dist/external-write-policy.d.ts +33 -0
  67. package/node_modules/@oxygen/shared/dist/external-write-policy.js +68 -0
  68. package/node_modules/@oxygen/shared/dist/format-percent.d.ts +8 -0
  69. package/node_modules/@oxygen/shared/dist/format-percent.js +13 -0
  70. package/node_modules/@oxygen/shared/dist/freemail-domains.d.ts +81 -0
  71. package/node_modules/@oxygen/shared/dist/freemail-domains.js +157 -0
  72. package/node_modules/@oxygen/shared/dist/future-signup-lifecycle-projection.d.ts +1 -0
  73. package/node_modules/@oxygen/shared/dist/future-signup-lifecycle-projection.js +1 -1
  74. package/node_modules/@oxygen/shared/dist/index.d.ts +14 -0
  75. package/node_modules/@oxygen/shared/dist/index.js +14 -0
  76. package/node_modules/@oxygen/shared/dist/json-path.js +1 -3
  77. package/node_modules/@oxygen/shared/dist/knowledge-bases.js +1 -3
  78. package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.d.ts +17 -2
  79. package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.js +28 -6
  80. package/node_modules/@oxygen/shared/dist/langfuse.d.ts +12 -1
  81. package/node_modules/@oxygen/shared/dist/langfuse.js +57 -8
  82. package/node_modules/@oxygen/shared/dist/linkedin-countries.d.ts +32 -0
  83. package/node_modules/@oxygen/shared/dist/linkedin-countries.js +359 -0
  84. package/node_modules/@oxygen/shared/dist/log-sink-selector.d.ts +39 -0
  85. package/node_modules/@oxygen/shared/dist/log-sink-selector.js +56 -0
  86. package/node_modules/@oxygen/shared/dist/log.d.ts +1 -0
  87. package/node_modules/@oxygen/shared/dist/log.js +6 -1
  88. package/node_modules/@oxygen/shared/dist/object-storage.d.ts +17 -0
  89. package/node_modules/@oxygen/shared/dist/object-storage.js +21 -0
  90. package/node_modules/@oxygen/shared/dist/otlp-log-sink.d.ts +54 -0
  91. package/node_modules/@oxygen/shared/dist/otlp-log-sink.js +213 -0
  92. package/node_modules/@oxygen/shared/dist/plan-capabilities.js +1 -0
  93. package/node_modules/@oxygen/shared/dist/plan-limits.d.ts +23 -22
  94. package/node_modules/@oxygen/shared/dist/plan-limits.js +45 -18
  95. package/node_modules/@oxygen/shared/dist/pricing-sheet.d.ts +48 -41
  96. package/node_modules/@oxygen/shared/dist/pricing-sheet.js +36 -25
  97. package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.d.ts +22 -22
  98. package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.js +40 -34
  99. package/node_modules/@oxygen/shared/dist/product-analytics-environment.js +9 -0
  100. package/node_modules/@oxygen/shared/dist/product-analytics-events.d.ts +15 -0
  101. package/node_modules/@oxygen/shared/dist/product-analytics-events.js +15 -0
  102. package/node_modules/@oxygen/shared/dist/rate-window.d.ts +5 -0
  103. package/node_modules/@oxygen/shared/dist/rate-window.js +8 -0
  104. package/node_modules/@oxygen/shared/dist/research-output-contract.d.ts +33 -1
  105. package/node_modules/@oxygen/shared/dist/research-output-contract.js +65 -5
  106. package/node_modules/@oxygen/shared/dist/search-vocab.js +4 -5
  107. package/node_modules/@oxygen/shared/dist/select-options.js +6 -1
  108. package/node_modules/@oxygen/shared/dist/sequence-crm-events.d.ts +1 -1
  109. package/node_modules/@oxygen/shared/dist/sequence-failures.js +1 -5
  110. package/node_modules/@oxygen/shared/dist/sequence-hubspot-sync.d.ts +1 -1
  111. package/node_modules/@oxygen/shared/dist/sequences.d.ts +49 -0
  112. package/node_modules/@oxygen/shared/dist/sequences.js +134 -4
  113. package/node_modules/@oxygen/shared/dist/spend-safety.d.ts +22 -10
  114. package/node_modules/@oxygen/shared/dist/spend-safety.js +15 -21
  115. package/node_modules/@oxygen/shared/dist/sql-rows.d.ts +1 -0
  116. package/node_modules/@oxygen/shared/dist/sql-rows.js +3 -0
  117. package/node_modules/@oxygen/shared/dist/telemetry.js +9 -1
  118. package/node_modules/@oxygen/shared/dist/type-guards.d.ts +22 -0
  119. package/node_modules/@oxygen/shared/dist/type-guards.js +35 -0
  120. package/node_modules/@oxygen/shared/dist/value-readers.d.ts +21 -0
  121. package/node_modules/@oxygen/shared/dist/value-readers.js +59 -0
  122. package/node_modules/@oxygen/shared/dist/version.js +1 -1
  123. package/node_modules/@oxygen/shared/package.json +60 -0
  124. package/node_modules/@oxygen/workflows/dist/graph/expression.js +2 -5
  125. package/node_modules/@oxygen/workflows/dist/graph/manifest-schema.d.ts +15 -15
  126. package/node_modules/@oxygen/workflows/dist/graph/params.js +1 -1
  127. package/package.json +6 -3
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 } 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))
@@ -3221,9 +3322,9 @@ export function createProgram() {
3221
3322
  .description("What this workspace actually contains: the newest tables, CRM objects, sequences, workflows, agents, wiki pages, posts and projects, each with its id, last activity, tags and link. Read-only, 0 credits. Capability search tells you what OXYGEN can do; this tells you what is here.")
3222
3323
  .option("--kind <kinds>", "Comma-separated kinds to include: table, crm_object, sequence, workflow, agent, knowledge_page, post, project.")
3223
3324
  .option("--tag <tag>", "Only objects carrying this workspace tag. Answered by the Tags footprint read, so it also covers kinds this map does not model.")
3224
- .option("--query <text>", "Match on name, slug, or tag.")
3325
+ .option("--query <text>", "Match on name, slug, or tag within each kind's scanned window; a kind with `capped: true` may hold older matches this did not search.")
3225
3326
  .option("--json", "Print a JSON envelope.")
3226
- .addHelpText("after", "\nEach kind returns its newest few objects under a character budget the payload echoes back; `capped` says when a kind holds more than the scan saw. Hydrate one object with its own primitive's read (`oxygen tables describe`, `oxygen workflows get`, `oxygen knowledge page get`).\n\nRead-only means read-only: the map creates nothing in your workspace in order to answer, so an empty wiki reports zero pages rather than a starter set this command seeded. Use `oxygen knowledge index` when you do want the starter wiki created.\n")
3327
+ .addHelpText("after", "\nEach kind returns its newest few objects under a character budget the payload echoes back; `capped` says when a kind holds more than the scan saw. It is a snapshot, not a rollup: per-folder table counts come from `oxygen projects list` (`tableCount`). Hydrate one object with its own primitive's read (`oxygen tables describe`, `oxygen workflows get`, `oxygen knowledge page get`).\n\nRead-only means read-only: the map creates nothing in your workspace in order to answer, so an empty wiki reports zero pages rather than a starter set this command seeded. Use `oxygen knowledge index` when you do want the starter wiki created.\n")
3227
3328
  .action(async (options) => {
3228
3329
  await handleAsyncAction("workspace map", options, async () => {
3229
3330
  const params = new URLSearchParams();
@@ -3408,7 +3509,7 @@ export function createProgram() {
3408
3509
  await handleAsyncAction("orgs billing-owners", options, () => requestOxygen("/api/cli/orgs/billing-owners"));
3409
3510
  }))
3410
3511
  .addCommand(new Command("billing-link")
3411
- .description("Cover a workspace with a plan you already pay for in another organization (one plan, many workspaces) instead of buying a second subscription. Omit --owner when exactly one of your organizations can pay and Oxygen will use it; `orgs billing-owners` lists them all. Credit billing moves, workspace data access does not.")
3512
+ .description("Cover a workspace with a plan you already pay for in another organization (one plan, many workspaces) instead of buying a second subscription. Omit --owner when exactly one of your organizations can pay and Oxygen will use it; `orgs billing-owners` lists them all. You must be an admin of BOTH organizations, the workspace being linked must not hold a live subscription of its own, and an org-bound key (oxy_live_…) can only link the workspace it is bound to. Credit billing moves, workspace data access does not; plan gates and sending seats then resolve through the owner, so the linked workspace connects senders against the owner's seats and seats are bought there. `plan_tier` in the response is the plan the workspace now runs on; `free` means the owner holds no active paid plan, so connecting senders stays blocked.")
3412
3513
  .option("--owner <organization>", "Billing owner organization id, Clerk org id, or slug. Optional — when omitted, Oxygen uses your one eligible organization, and refuses to pick if there is more than one.")
3413
3514
  .option("--organization <organization>", "Workspace organization to link. Defaults to the active organization.")
3414
3515
  .option("--organization-id <id>", "Alias for --organization.")
@@ -4049,13 +4150,13 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
4049
4150
  .command("projects")
4050
4151
  .description("Manage table folders (projects). Inspect contents with `oxygen tables list --project <project>`.")
4051
4152
  .addCommand(new Command("list")
4052
- .description("List table projects in the current tenant database.")
4153
+ .description("List table projects (folders) with each one's active table count (`tableCount`), so empty and oversized folders show without listing every folder's tables. Free.")
4053
4154
  .option("--json", "Print a JSON envelope.")
4054
4155
  .action(async (options) => {
4055
4156
  await handleAsyncAction("projects list", options, () => requestOxygen("/api/cli/projects"));
4056
4157
  }))
4057
4158
  .addCommand(new Command("create")
4058
- .description("Create a schema-backed table project.")
4159
+ .description("Create a schema-backed table project (folder). Free. Group tables into it with `oxygen tables move <tables...> --project <folder>`.")
4059
4160
  .argument("<name>", "Display name for the project.")
4060
4161
  .option("--json", "Print a JSON envelope.")
4061
4162
  .action(async (name, options) => {
@@ -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.")
@@ -5656,7 +6167,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5656
6167
  .option("--limit <n>", "Maximum tables to list. Defaults to 200; hard cap is 1000.")
5657
6168
  .option("--all", "List every table instead of the newest window.")
5658
6169
  .option("--json", "Print a JSON envelope.")
5659
- .addHelpText("after", "\nLists the 200 newest tables by default; `capped` in the JSON envelope says when there are more.\n")
6170
+ .addHelpText("after", "\nLists the 200 newest tables by default; `returned` in the JSON envelope is the count in this response and `capped` says when there are more (pass --all for every table). Per-folder totals live on `oxygen projects list` (`tableCount`). `oxygen workspace map --kind table --query <text>` finds a recent table by name but searches only the 200 newest tables (its `capped` says when older ones were not searched); to match a name across every table, read `oxygen tables list --all --json`.\n")
5660
6171
  .action(async (options) => {
5661
6172
  await handleAsyncAction("tables list", options, async () => {
5662
6173
  const params = new URLSearchParams();
@@ -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.")
@@ -5863,14 +6379,17 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5863
6379
  }));
5864
6380
  }))
5865
6381
  .addCommand(new Command("move")
5866
- .description("Move a workspace table to a different project.")
5867
- .argument("<table>", "Table id or slug.")
5868
- .requiredOption("--project <project>", "Destination project id or slug.")
6382
+ .description("Move one or more workspace tables into a project (folder) in one call. Rows, columns, runs, and provenance are preserved. Free; reverse it by moving the table back.")
6383
+ .argument("<tables...>", "Table ids or slugs; several move together, each reported separately.")
6384
+ .requiredOption("--project <project>", "Destination project id or slug (see `oxygen projects list`).")
5869
6385
  .option("--json", "Print a JSON envelope.")
5870
- .action(async (table, options) => {
6386
+ .action(async (tables, options) => {
5871
6387
  await handleAsyncAction("tables move", options, () => requestOxygen("/api/cli/tables/move", {
5872
6388
  method: "POST",
5873
- body: { table, project: options.project },
6389
+ body: {
6390
+ ...(tables.length === 1 ? { table: tables[0] } : { tables }),
6391
+ project: options.project,
6392
+ },
5874
6393
  }));
5875
6394
  }))
5876
6395
  .addCommand(new Command("recover-pending")
@@ -6333,6 +6852,14 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6333
6852
  autoRunStatus: options.autoRunStatus,
6334
6853
  limit: options.limit,
6335
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.';
6336
6863
  tablesCommand.addCommand(new Command("views")
6337
6864
  .description("Create and manage saved table views (grid / kanban configurations).")
6338
6865
  .addCommand(new Command("list")
@@ -6350,8 +6877,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6350
6877
  .description("Create a saved view.")
6351
6878
  .argument("<table>", "Table id or slug.")
6352
6879
  .requiredOption("--name <name>", "View name.")
6353
- .option("--view-type <type>", "table or kanban. Defaults to table.")
6354
- .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)
6355
6883
  .option("--default", "Make this the table's default view.")
6356
6884
  .option("--json", "Print a JSON envelope.")
6357
6885
  .action((table, options) => handleAsyncAction("tables views create", options, () => requestOxygen("/api/cli/tables/views", {
@@ -6360,6 +6888,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6360
6888
  table,
6361
6889
  name: readOption(options.name),
6362
6890
  ...(readOption(options.viewType) ? { view_type: readOption(options.viewType) } : {}),
6891
+ ...(readOption(options.groupBy) ? { group_by: readOption(options.groupBy) } : {}),
6363
6892
  ...(readOption(options.config)
6364
6893
  ? { config: parseJsonValue(readOption(options.config) ?? "", "--config") }
6365
6894
  : {}),
@@ -6371,8 +6900,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6371
6900
  .argument("<table>", "Table id or slug.")
6372
6901
  .argument("<view>", "View id.")
6373
6902
  .option("--name <name>", "Rename the view.")
6374
- .option("--view-type <type>", "table or kanban.")
6375
- .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.`)
6376
6906
  .option("--default", "Make this the table's default view.")
6377
6907
  .option("--position <n>", "0-based order among the table's views.")
6378
6908
  .option("--json", "Print a JSON envelope.")
@@ -6383,6 +6913,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6383
6913
  view,
6384
6914
  ...(readOption(options.name) ? { name: readOption(options.name) } : {}),
6385
6915
  ...(readOption(options.viewType) ? { view_type: readOption(options.viewType) } : {}),
6916
+ ...(readOption(options.groupBy) ? { group_by: readOption(options.groupBy) } : {}),
6386
6917
  ...(readOption(options.config)
6387
6918
  ? { config: parseJsonValue(readOption(options.config) ?? "", "--config") }
6388
6919
  : {}),
@@ -6536,18 +7067,26 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6536
7067
  .option("--json", "Print a JSON envelope.")
6537
7068
  .action((feed, options) => handleAsyncAction("feeds get", options, () => requestOxygen(feedsGetPath(feed, options)))))
6538
7069
  .addCommand(new Command("bind")
6539
- .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.")
6540
- .argument("<table>", "Table id or slug that receives the rows.")
6541
- .requiredOption("--kind <kind>", "Feed kind: company_search or signal_search.")
6542
- .requiredOption("--tool-id <tool>", "Provider tool id the cycle calls, e.g. blitzapi.job_search.")
6543
- .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.")
6544
7083
  .option("--tool-ids <csv>", "Comma-separated cascade of tool ids to try in order. Defaults to just --tool-id.")
6545
7084
  .option("--rows-path <path>", "Dot path to the row array in the provider response.")
6546
7085
  .option("--row-mapping-json <json-or-file>", "Row mapping JSON: table column key -> provider field path.")
6547
7086
  .option("--cursor-path <path>", "Dot path to the next-page cursor in the provider response.")
6548
7087
  .option("--cursor-request-key <key>", "Request key that receives the next cursor.")
6549
7088
  .option("--max-pages <n>", "Maximum provider pages per cycle. Defaults to 1.")
6550
- .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.")
6551
7090
  .option("--name <name>", "Display name for the feed.")
6552
7091
  .option("--every <sugar>", CADENCE_FLAG_DESCRIPTION)
6553
7092
  .option("--max-credits <n>", "Credit ceiling PER sync cycle. Required with --approved to arm a cadence on a paid provider.")
@@ -6559,10 +7098,50 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6559
7098
  .option("--source-prompt <text-or-file>", "The prompt this feed was planned from, or a path to a prompt file.")
6560
7099
  .option("--approved", "Approve the recurring paid pull. Required with --every and --max-credits on a paid provider.")
6561
7100
  .option("--json", "Print a JSON envelope.")
6562
- .action((table, options) => handleAsyncAction("feeds bind", options, () => requestOxygen("/api/cli/tables/feeds", {
6563
- method: "POST",
6564
- body: readFeedBindBody(table, options),
6565
- }))))
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
+ }))
6566
7145
  .addCommand(new Command("pause")
6567
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.")
6568
7147
  .argument("<feed>", "Feed id or endpoint id.")
@@ -7803,7 +8382,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7803
8382
  ].join("\n"));
7804
8383
  program
7805
8384
  .command("recipes")
7806
- .description("Business-case GTM playbooks: proven plays with prerequisites, credit posture, and approval gates spelled out.")
8385
+ .description("Business-case GTM playbooks: proven plays with prerequisites, credit posture, and approval gates spelled out. Whole-motion operator playbooks (TAM sourcing, LinkedIn content strategy, inbound-led outbound, signal-based outbound) are the oxygen-playbooks skill: oxygen skills get oxygen-playbooks --json.")
7807
8386
  .addCommand(new Command("list")
7808
8387
  .description("List recipes, optionally filtered by text, business-case category, journey stage, or audience.")
7809
8388
  .argument("[query]", "Search text (matches slug/title/business case/tags).")
@@ -7973,13 +8552,15 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7973
8552
  }));
7974
8553
  program
7975
8554
  .command("columns")
7976
- .description("Workspace table column commands.")
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.")
7977
8556
  .addCommand(new Command("add")
7978
- .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.")
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.")
7979
8558
  .argument("<table>", "Table id or slug.")
7980
- .option("--preset <preset>", "Add a pre-built enrichment bundle instead of one column: `person_enrich` (one LinkedIn profile lookup, then headline, bio, location and followers for free) or `company_enrich` (domain, LinkedIn page, headcount, industry and full profile, cascading across providers until one answers). Oxygen finds the identity column itself — override with --input. Creating the columns is free; run them afterwards, --dry-run first.")
7981
- .option("--input <slot=column...>", "Bind a preset input to an exact column, e.g. --input url=linkedin_url, or --input company_name=account --input domain=website. Repeatable. Only needed when the automatic match is wrong or missing.", collectRepeatable, [])
7982
- .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>`.")
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.")
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, [])
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>`.")
7983
8564
  .option("--label <label>", "Display label for the new column. Required unless --prompt-key or --capability supplies a default title.")
7984
8565
  .option("--key <key>", "Optional stable column key. Defaults to a normalized label.")
7985
8566
  .option("--data-type <type>", "Column data type: text, numeric, boolean, jsonb, or timestamptz.")
@@ -7992,9 +8573,10 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7992
8573
  .option("--research-exclude-domains <csv>", "Research columns: comma-separated domains to exclude from results.")
7993
8574
  .option("--research-mode <mode>", "Research columns: how tightly the answer is bound to the sources. Default: summarize and combine what they say. strict: answer only from what a source states verbatim (for figures and identifiers). estimate: reason to a figure from the evidence (for revenue or headcount).")
7994
8575
  .option("--research-results <n>", "Research columns: how many search results to ground each row on (1-25, default 5). More evidence costs no extra search, but a larger prompt.")
7995
- .option("--research-engine <engine>", "Research columns: pin the search provider (exa, parallel, or firecrawl). Defaults to exa, falling back automatically. Most users should not set this.")
7996
- .option("--prompt-key <key>", "OXYGEN prompt-library key (e.g. email_draft_v1). Materializes prompt + output_schema and forces kind=ai.")
7997
- .option("--input-mapping <json>", "Required with --prompt-key. JSON object mapping prompt input names to column or literal refs. Copy templates (email_draft_v1, subject_line_variants_v1, ...) reject mappings built only from manual name/title/company columns — ground them on a research or enrichment column, or write a freeform prompt with --prompt instead.")
8576
+ .option("--research-engine <engine>", "Research columns: pin the search provider (exa, parallel, or firecrawl), or with --research-url the page-fetch provider (firecrawl, linkup, or exa). Defaults to exa for search and firecrawl for fetch, falling back automatically. Most users should not set this.")
8577
+ .option("--research-url <column_key>", "Research columns: read the ONE page whose URL is in this column for each row instead of searching the web — a pricing page, a job posting, an event page, a 10-K. The page's content is the only evidence, the answer cites it as its source, and the row costs the fetch plus the model (see --dry-run). A URL ending in .pdf is read by the PDF-capable fetch lane first.")
8578
+ .option("--prompt-key <key>", "Add a column template from `oxygen columns catalog` (e.g. pricing_plan_summary_v1 reads a pricing-page URL column and returns plan count, price range and enterprise plan; company_founders_v1 reads a domain column and returns the founders with sources; person_skill_set_v1 reads the person_enrich payload column with --input profile=person_enrich). Materializes the template's prompt, output schema and grounding and sets its kind (ai, research, formula, or search — a managed web search whose cell is a page URL). Bind each declared input with --input <name>=<column_key>.")
8579
+ .option("--input-mapping <json>", "JSON alternative to --input for --prompt-key: an object mapping template input names to columns, e.g. '{\"url\":{\"column\":\"pricing_url\"}}' (a bare column-key string or {\"literal\": ...} also works). Copy templates (email_draft_v1, subject_line_variants_v1, ...) reject mappings built only from manual name/title/company columns — ground them on a research or enrichment column, or write a freeform prompt with --prompt instead.")
7998
8580
  .option("--model <id>", "AI column model id (e.g. claude-sonnet-4-5). Explicit models require credentialMode byok unless allow-listed managed.")
7999
8581
  .option("--reasoning-level <level>", "AI column reasoning level: low, medium, or high (shown in the web UI as Oxygen Fast, Oxygen Balanced, and Oxygen Max).")
8000
8582
  .option("--run-condition <formula>", "Formula expression gating whether the AI column runs per row. A formula over bare column keys (eu_israel = true), not {{tokens}}.")
@@ -8059,6 +8641,12 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8059
8641
  presetBody.inputs = inputs;
8060
8642
  if (options.key)
8061
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;
8062
8650
  return requestOxygen("/api/cli/tables/columns", {
8063
8651
  method: "POST",
8064
8652
  body: presetBody,
@@ -8069,8 +8657,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8069
8657
  if (!options.promptKey && !capability && !options.label) {
8070
8658
  throw new OxygenError("invalid_request", "--label is required.", { exitCode: 1 });
8071
8659
  }
8072
- if (options.promptKey && !options.inputMapping) {
8073
- throw new OxygenError("missing_input_mapping", "--input-mapping is required when --prompt-key is provided.", { exitCode: 1 });
8660
+ const templateInputs = options.promptKey ? parseKeyValuePairs(options.input ?? []) : {};
8661
+ if (options.promptKey && !options.inputMapping && Object.keys(templateInputs).length === 0) {
8662
+ throw new OxygenError("missing_input_mapping", `--prompt-key ${options.promptKey} reads named inputs; bind each one to a column with --input <name>=<column_key> (e.g. --input url=pricing_url), or pass --input-mapping <json>. See \`oxygen columns catalog\` for the inputs a template reads.`, { exitCode: 1 });
8074
8663
  }
8075
8664
  const prompt = readAiPromptOption(options.prompt);
8076
8665
  if (prompt !== null && options.promptKey) {
@@ -8185,8 +8774,14 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8185
8774
  const body = { table, column };
8186
8775
  if (options.promptKey)
8187
8776
  body.prompt_key = options.promptKey;
8188
- if (options.inputMapping)
8777
+ if (options.inputMapping) {
8189
8778
  body.input_mapping = parseJsonObject(options.inputMapping);
8779
+ }
8780
+ else if (Object.keys(templateInputs).length > 0) {
8781
+ // `--input url=pricing_url` → the same typed column refs the JSON form
8782
+ // carries, so the server sees one shape from both spellings.
8783
+ body.input_mapping = Object.fromEntries(Object.entries(templateInputs).map(([name, columnKey]) => [name, { type: "column", columnKey }]));
8784
+ }
8190
8785
  // Every authoring path, not just --prompt: a definition pasted into
8191
8786
  // --definition-json used to skip this entirely and fail once per row.
8192
8787
  await assertColumnDefinitionReferences(table, column.definition);
@@ -8200,7 +8795,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8200
8795
  });
8201
8796
  }))
8202
8797
  .addCommand(new Command("run")
8203
- .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.")
8204
8799
  .argument("<table>", "Table id or slug.")
8205
8800
  .argument("<column>", "Column id or key.")
8206
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`.")
@@ -8210,12 +8805,12 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8210
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.")
8211
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.")
8212
8807
  .option("--connection-id <connection_id>", "Optional provider integration connection id.")
8213
- .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.")
8214
8809
  .option("--approved", "Confirm a paid durable run, or a bind create-mode run (onNoMatch=create), after inspecting a dry run or preview.")
8215
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.")
8216
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.")
8217
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).")
8218
- .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.")
8219
8814
  .option("--local-concurrency <n>", "Maximum concurrent custom HTTP requests for --local. Defaults to 3.")
8220
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.")
8221
8816
  .option("--json", "Print a JSON envelope.")
@@ -8559,6 +9154,32 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8559
9154
  },
8560
9155
  });
8561
9156
  });
9157
+ }))
9158
+ .addCommand(new Command("catalog")
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`.")
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.")
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.")
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).")
9163
+ .option("--search <term>", "Match a word against template keys, titles and descriptions, e.g. pricing or 10-K.")
9164
+ .option("--json", "Print a JSON envelope.")
9165
+ .action(async (options) => {
9166
+ await handleAsyncAction("columns catalog", options, async () => {
9167
+ const params = new URLSearchParams();
9168
+ for (const [name, value] of [
9169
+ ["category", readOption(options.category)],
9170
+ ["family", readOption(options.family)],
9171
+ ["kind", readOption(options.kind)],
9172
+ ["search", readOption(options.search)],
9173
+ ]) {
9174
+ if (value)
9175
+ params.set(name, value);
9176
+ }
9177
+ const query = params.toString();
9178
+ const result = await requestOxygen(`/api/cli/tables/columns/catalog${query ? `?${query}` : ""}`, { method: "GET" });
9179
+ if (!options.json)
9180
+ printColumnCatalog(result);
9181
+ return result;
9182
+ });
8562
9183
  }))
8563
9184
  .addCommand(new Command("deps")
8564
9185
  .description("Show the column dependency graph: which columns feed which (formulas, input mappings, run conditions, waterfall targets), transitive up/downstream, and cycles. Read-only.")
@@ -8625,7 +9246,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8625
9246
  .command("formulas")
8626
9247
  .description("Formula language for formula columns and per-column run conditions (only-run-if).")
8627
9248
  .addCommand(new Command("functions")
8628
- .description("List every formula function (name, signature, examples) and the operator grammar. The same language powers formula columns and --run-condition gates.")
9249
+ .description("List every formula function (name, signature, examples), the operator grammar, and the expression_notes that a signature cannot teach (bare column keys, string escapes, no regex flags, read-time evaluation). The same language powers formula columns and --run-condition gates.")
8629
9250
  .option("--category <category>", "Filter by category: logic, string, number, date, url_email, array, json, null_handling, cross_row.")
8630
9251
  .option("--json", "Print a JSON envelope.")
8631
9252
  .action(async (options) => {
@@ -9023,7 +9644,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9023
9644
  method: "POST",
9024
9645
  body: {
9025
9646
  table,
9026
- 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),
9027
9650
  request: parseJsonObject(options.requestJson),
9028
9651
  ...(readOption(options.route) ? { route_id: readOption(options.route) } : {}),
9029
9652
  ...(readOption(options.mode) ? { mode: readOption(options.mode) } : {}),
@@ -9099,6 +9722,27 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9099
9722
  .description("Company prospecting and account enrichment workflows.")
9100
9723
  .addCommand(new Command("search")
9101
9724
  .description("Plan, dry-run, or queue provider-backed company search.")
9725
+ .addCommand(new Command("filters")
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.")
9728
+ .option("--json", "Print a JSON envelope.")
9729
+ .action(async (options) => {
9730
+ await handleAsyncAction("companies search filters", options, () => requestOxygen(`/api/cli/companies/search/preview${readCompanySourceQuery(options.source)}`));
9731
+ }))
9732
+ .addCommand(new Command("preview")
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.")
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\"]}}}.")
9736
+ .option("--json", "Print a JSON envelope.")
9737
+ .action(async (options) => {
9738
+ await handleAsyncAction("companies search preview", options, () => requestOxygen("/api/cli/companies/search/preview", {
9739
+ method: "POST",
9740
+ body: {
9741
+ filters: parseJsonObject(readFileIfPresent(options.filtersJson)),
9742
+ ...(readCompanySearchSource(options.source) !== "company_search" ? { source: readCompanySearchSource(options.source) } : {}),
9743
+ },
9744
+ }));
9745
+ }))
9102
9746
  .addCommand(new Command("plan")
9103
9747
  .description("Compile a company-search prompt into ordered provider routes without provider calls. To turn a list of company NAMES into websites or domains, pass --source-intent url_recovery (e.g. --prompt \"Find the websites for these companies: <names>\" --source-intent url_recovery). The plan carries provider_availability: a route on a benched managed provider reads degraded with a next_action.")
9104
9748
  .argument("[prompt]", "Prompt text or @file (same as --prompt). The --prompt flag wins if both are given.")
@@ -9118,7 +9762,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9118
9762
  .option("--founded <range>", "Founded year range: 2015-2024, 2020+, or -2010.")
9119
9763
  .option("--lookalike <csv>", "Comma-separated lookalike company domains.")
9120
9764
  .option("--estimate", "Run a free server-side preflight pass: resolves provider enums and a free count probe for an estimated match count (zero credits).")
9121
- .option("--materialize-preview", "Create a preview table with route rows.")
9765
+ .option("--materialize-preview", "Create a route-plan table, without company results. For free company rows use companies search preview.")
9122
9766
  .option("--json", "Print a JSON envelope.")
9123
9767
  .action(async (promptArg, options) => {
9124
9768
  await handleAsyncAction("companies search plan", options, () => requestOxygen("/api/cli/companies/search/plan", {
@@ -9128,6 +9772,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9128
9772
  }))
9129
9773
  .addCommand(new Command("run")
9130
9774
  .description("Return a dry-run request or queue a live company-search ingestion run.")
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.")
9131
9778
  .argument("[prompt]", "Prompt text or @file (same as --prompt). The --prompt flag wins if both are given.")
9132
9779
  .option("--prompt <text-or-file>", "Company-search prompt, or a path to a prompt file.")
9133
9780
  .option("--plan-json <json-or-file>", "Plan JSON returned by companies search plan, or a path to a JSON file.")
@@ -9137,9 +9784,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9137
9784
  .option("--project <project>", "Folder (project) id or slug the created table lands in. Ignored with --table. Defaults to the workspace default folder.")
9138
9785
  .option("--upsert-key <column>", "Column key used for live upsert. Must match the plan upsert key, usually domain.")
9139
9786
  .option("--mode <mode>", "dry_run or live. Defaults to dry_run.")
9140
- .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.")
9141
9788
  .option("--max-credits <n>", "Required credit ceiling for live runs.")
9142
- .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.")
9143
9790
  .option("--source-intent <intent>", "Override detected intent when planning from --prompt.")
9144
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.")
9145
9792
  .option("--industries <csv>", "Comma-separated industries to include when planning from --prompt.")
@@ -9167,7 +9814,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9167
9814
  .addCommand(new Command("preview")
9168
9815
  .description("Inspect missing company fields, provider routing, and credit estimates without provider calls.")
9169
9816
  .argument("<table>", "Table id or slug.")
9170
- .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.")
9171
9818
  .option("--providers <providers>", "Comma-separated provider order pool. Defaults to scraper,blitzapi,crustdata,ai_ark,prospeo,leadmagic.")
9172
9819
  .option("--all", "Preview all rows.")
9173
9820
  .option("--limit <n>", "Preview a limited row scope.")
@@ -9186,7 +9833,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9186
9833
  .argument("<table>", "Table id or slug.")
9187
9834
  .option("--missing-fields <fields>", "Comma-separated fields to fill.")
9188
9835
  .option("--providers <providers>", "Comma-separated provider pool.")
9189
- .option("--mode <mode>", "dry_run or live. Defaults to live.")
9836
+ .option("--mode <mode>", "dry_run or live. Defaults to live. dry_run returns the same plan as `companies enrich preview` and creates no column; only a live run creates the target columns it fills. To attach a company field for free and run it later, bind the OXYGEN-managed Function instead (`functions list --managed`, then `functions bind`).")
9190
9837
  .option("--max-credits <n>", "Required credit ceiling for live runs.")
9191
9838
  .option("--approved", "Approve this live paid run after inspecting the preview.")
9192
9839
  .option("--all", "Run on all rows.")
@@ -9207,9 +9854,25 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9207
9854
  .description("People and contact prospecting workflows.")
9208
9855
  .addCommand(new Command("search")
9209
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
+ }))
9210
9873
  .addCommand(new Command("plan")
9211
9874
  .description("Compile a people-search prompt and optional typed persona filters into ordered provider routes without provider calls.")
9212
- .requiredOption("--prompt <text-or-file>", "People-search prompt, or a path to a prompt file.")
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.")
9213
9876
  .option("--target-count <n>", "Desired contact count. Single-plan ceiling 50,000; larger requests return an explicit clamp warning and segmentation guidance.")
9214
9877
  .option("--source-intent <intent>", "Override detected intent: persona_search, account_contacts, audience_sizing, profile_lookup, concept_persona, or fallback_broad.")
9215
9878
  .option("--filters-json <json-or-file>", "PeopleSearchFilters JSON inline or a @file/path; wins over individual flags per top-level filter path.")
@@ -9219,7 +9882,15 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9219
9882
  .option("--exclude-titles <csv>", "Comma-separated job titles to exclude.")
9220
9883
  .option("--seniorities <csv>", "Comma-separated seniority levels: C-Suite, VP, Director, Manager, Staff.")
9221
9884
  .option("--departments <csv>", "Comma-separated departments/functions: Sales, Marketing, Engineering, ...")
9885
+ .option("--job-functions <csv>", PEOPLE_SEARCH_JOB_FUNCTIONS_HELP)
9222
9886
  .option("--countries <csv>", "Comma-separated ISO 3166-1 alpha-2 person country codes.")
9887
+ .option("--continents <csv>", PEOPLE_SEARCH_CONTINENTS_HELP)
9888
+ .option("--sales-regions <csv>", PEOPLE_SEARCH_SALES_REGIONS_HELP)
9889
+ .option("--min-connections <n>", PEOPLE_SEARCH_MIN_CONNECTIONS_HELP)
9890
+ .option("--from-table <table>", PEOPLE_SEARCH_FROM_TABLE_HELP)
9891
+ .option("--linkedin-url-column <key>", PEOPLE_SEARCH_LINKEDIN_COLUMN_HELP)
9892
+ .option("--company-offset <n>", PEOPLE_SEARCH_COMPANY_OFFSET_HELP)
9893
+ .option("--company-limit <n>", PEOPLE_SEARCH_COMPANY_LIMIT_HELP)
9223
9894
  .option("--keywords <csv>", "Comma-separated free-text persona keywords.")
9224
9895
  .option("--company-domains <csv>", "Comma-separated company domains to scope contacts to.")
9225
9896
  .option("--company-names <csv>", "Comma-separated company names to scope contacts to.")
@@ -9239,6 +9910,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9239
9910
  }))
9240
9911
  .addCommand(new Command("run")
9241
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.")
9242
9916
  .option("--prompt <text-or-file>", "People-search prompt, or a path to a prompt file.")
9243
9917
  .option("--plan-json <json-or-file>", "Plan JSON returned by people search plan, or a path to a JSON file.")
9244
9918
  .option("--route-id <id>", "Route id from the plan to execute.")
@@ -9247,7 +9921,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9247
9921
  .option("--project <project>", "Folder (project) id or slug the created table lands in. Ignored with --table. Defaults to the workspace default folder.")
9248
9922
  .option("--upsert-key <column>", "Column key used for live upsert. Must match the plan upsert key (linkedin_url).")
9249
9923
  .option("--mode <mode>", "dry_run or live. Defaults to dry_run.")
9250
- .option("--max-pages <n>", "Maximum provider pages to ingest. Defaults to the route estimate.")
9924
+ .option("--max-pages <n>", "Maximum provider pages to ingest. Defaults to the route estimate. With --from-table it means pages per company.")
9251
9925
  .option("--max-credits <n>", "Required credit ceiling for live runs.")
9252
9926
  .option("--target-count <n>", "Desired contact count when planning from --prompt. Single-plan ceiling 50,000.")
9253
9927
  .option("--source-intent <intent>", "Override detected intent when planning from --prompt.")
@@ -9258,7 +9932,16 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9258
9932
  .option("--exclude-titles <csv>", "Comma-separated job titles to exclude when planning from --prompt.")
9259
9933
  .option("--seniorities <csv>", "Comma-separated seniority levels when planning from --prompt.")
9260
9934
  .option("--departments <csv>", "Comma-separated departments/functions when planning from --prompt.")
9935
+ .option("--job-functions <csv>", PEOPLE_SEARCH_JOB_FUNCTIONS_HELP)
9261
9936
  .option("--countries <csv>", "Comma-separated ISO 3166-1 alpha-2 person country codes when planning from --prompt.")
9937
+ .option("--continents <csv>", PEOPLE_SEARCH_CONTINENTS_HELP)
9938
+ .option("--sales-regions <csv>", PEOPLE_SEARCH_SALES_REGIONS_HELP)
9939
+ .option("--min-connections <n>", PEOPLE_SEARCH_MIN_CONNECTIONS_HELP)
9940
+ .option("--from-table <table>", PEOPLE_SEARCH_FROM_TABLE_HELP)
9941
+ .option("--linkedin-url-column <key>", PEOPLE_SEARCH_LINKEDIN_COLUMN_HELP)
9942
+ .option("--company-offset <n>", PEOPLE_SEARCH_COMPANY_OFFSET_HELP)
9943
+ .option("--company-limit <n>", PEOPLE_SEARCH_COMPANY_LIMIT_HELP)
9944
+ .option("--pages-per-company <n>", "Alias for --max-pages on a --from-table run: provider pages per company, 50 people per page.")
9262
9945
  .option("--keywords <csv>", "Comma-separated persona keywords when planning from --prompt.")
9263
9946
  .option("--company-domains <csv>", "Comma-separated company domains when planning from --prompt.")
9264
9947
  .option("--company-names <csv>", "Comma-separated company names when planning from --prompt.")
@@ -9386,7 +10069,11 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9386
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.")
9387
10070
  .addCommand(new Command("change")
9388
10071
  .description("Preview an upgrade or downgrade and return a Stripe confirmation link. Nothing changes until confirmed in Stripe.")
9389
- .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(", ")}.`)
9390
10077
  .option("--json", "Print a JSON envelope.")
9391
10078
  .action(async (options) => {
9392
10079
  await handleAsyncAction("billing change", options, () => requestOxygen("/api/cli/billing/change", {
@@ -9575,9 +10262,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9575
10262
  }));
9576
10263
  }))
9577
10264
  .addCommand(new Command("topup")
9578
- .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.")
9579
10266
  .argument("[pack]", "Legacy pack alias in USD: 10, 25, 100, or 250.")
9580
- .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).")
9581
10268
  .option("--json", "Print a JSON envelope.")
9582
10269
  .action(async (pack, options) => {
9583
10270
  const credits = readOption(options.credits);
@@ -10927,13 +11614,13 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
10927
11614
  }));
10928
11615
  program
10929
11616
  .command("tools")
10930
- .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.")
10931
11618
  .addCommand(new Command("search")
10932
- .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.")
10933
11620
  .argument("[query]", "Search text.")
10934
11621
  .option("--verbosity <verbosity>", "minimal, summary, or full. Defaults to minimal; hydrate one result with tools get.")
10935
11622
  .option("--terse", "Alias for --verbosity minimal.")
10936
- .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.")
10937
11624
  .option("--only-runnable", "Only return tools runnable by the active organization.")
10938
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.")
10939
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.")
@@ -10942,11 +11629,21 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
10942
11629
  .option("--provider <provider>", "Filter to one exact provider id, such as blitzapi.")
10943
11630
  .option("--limit <n>", "Maximum number of tools to return. Capped at 100.")
10944
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.")
10945
11635
  .option("--json", "Print a JSON envelope.")
10946
11636
  .action(async (query, options) => {
10947
11637
  await handleAsyncAction("tools search", options, async () => {
10948
11638
  const params = new URLSearchParams();
10949
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");
10950
11647
  const verbosity = options.terse ? "minimal" : readOption(options.verbosity);
10951
11648
  if (verbosity)
10952
11649
  params.set("verbosity", verbosity);
@@ -10981,6 +11678,32 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
10981
11678
  .option("--json", "Print a JSON envelope.")
10982
11679
  .action(async (toolId, options) => {
10983
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"));
10984
11707
  }))
10985
11708
  .addCommand(new Command("enums")
10986
11709
  .description("Provider enum catalogs for fields that accept normalized values.")
@@ -11045,28 +11768,32 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
11045
11768
  // 24.94", so an integer reader would refuse the very command the CLI
11046
11769
  // just told the operator to run.
11047
11770
  const maxCredits = readNonNegativeNumber(options.maxCredits);
11048
- await handleAsyncAction("tools run", options, () => requestOxygen("/api/cli/tools/run", {
11049
- method: "POST",
11050
- body: {
11051
- tool_id: toolId,
11052
- input: parseJsonObject(options.inputJson),
11053
- ...(readOption(options.mode) ? { mode: readOption(options.mode) } : {}),
11054
- ...(readOption(options.credentialMode) ? { credential_mode: readOption(options.credentialMode) } : {}),
11055
- ...(readOption(options.org) ? { org_id: readOption(options.org) } : {}),
11056
- ...(readOption(options.orgId) ? { org_id: readOption(options.orgId) } : {}),
11057
- ...(readOption(options.connectionId) ? { connection_id: readOption(options.connectionId) } : {}),
11058
- ...(readOption(options.fields) ? { fields: readCsvOption(options.fields) } : {}),
11059
- ...(readOption(options["return"]) ? { return: readOption(options["return"]) } : {}),
11060
- ...(readOption(options.returnMode) ? { return_mode: readOption(options.returnMode) } : {}),
11061
- ...(readOption(options.oxygenCursor) ? { oxygen_cursor: readOption(options.oxygenCursor) } : {}),
11062
- ...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
11063
- ...(options.approved ? { approved: true } : {}),
11064
- },
11065
- }));
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
+ });
11066
11793
  }));
11067
11794
  program
11068
11795
  .command("enrich-column")
11069
- .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).")
11070
11797
  .addCommand(new Command("preview")
11071
11798
  .description("Preflight an enrichment column without provider calls or credit usage.")
11072
11799
  .argument("<table>", "Table id or slug.")
@@ -11079,7 +11806,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
11079
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.")
11080
11807
  .option("--company-name-column <column>", "Column key or id containing the company name for work_email when no domain is available.")
11081
11808
  .option("--company-linkedin-url-column <column>", "Column key or id containing the company's LinkedIn URL for company identity fallback.")
11082
- .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.")
11083
11810
  .option("--target-column <column>", "Target enrichment column key. Defaults to the capability payload column.")
11084
11811
  .option("--on-existing-manual-column <mode>", "How to handle an existing manual target: error, write_if_empty, or create_enrichment_column.")
11085
11812
  .option("--provider-order <providers>", "Comma-separated provider order. Overrides the default cost-aware waterfall profile.")
@@ -11114,7 +11841,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
11114
11841
  .option("--company-name-column <column>", "Column key or id containing the company name for work_email when no domain is available.")
11115
11842
  .option("--company-linkedin-url-column <column>", "Column key or id containing the company's LinkedIn URL for company identity fallback.")
11116
11843
  .requiredOption("--max-credits <credits>", "Required credit ceiling for the queued run.")
11117
- .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.")
11118
11845
  .option("--target-column <column>", "Target enrichment column key. Defaults to the capability payload column.")
11119
11846
  .option("--on-existing-manual-column <mode>", "How to handle an existing manual target: error, write_if_empty, or create_enrichment_column.")
11120
11847
  .option("--provider-order <providers>", "Comma-separated provider order. Overrides the default cost-aware waterfall profile.")
@@ -11162,6 +11889,19 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
11162
11889
  .option("--json", "Print a JSON envelope.")
11163
11890
  .action(async (options) => {
11164
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) }));
11165
11905
  }))
11166
11906
  .addCommand(new Command("phone")
11167
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.")
@@ -11189,13 +11929,33 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
11189
11929
  .option("--json", "Print a JSON envelope.")
11190
11930
  .action(async (options) => {
11191
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) }));
11192
11950
  }))
11193
11951
  .addCommand(new Command("company")
11194
- .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.")
11195
11953
  .option("--domain <domain>", "Company apex domain.")
11196
11954
  .option("--name <name>", "Company name.")
11197
11955
  .option("--linkedin-url <url>", "Company LinkedIn URL.")
11198
- .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).")
11199
11959
  .option("--mode <mode>", "dry_run (default) previews the plan and spends nothing; live spends credits and returns the answer.")
11200
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.")
11201
11961
  .option("--json", "Print a JSON envelope.")
@@ -11226,7 +11986,32 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
11226
11986
  }));
11227
11987
  program
11228
11988
  .command("enrichment")
11229
- .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
+ }))
11230
12015
  .addCommand(new Command("apply-default-cascade")
11231
12016
  .description("Patch an existing enrichment column to the server-side default provider cascade for its intent.")
11232
12017
  .argument("<table>", "Table id or slug.")
@@ -11595,7 +12380,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
11595
12380
  await handleAsyncAction("senders checkpoints", options, () => requestOxygen("/api/cli/senders/checkpoints"));
11596
12381
  }))
11597
12382
  .addCommand(new Command("connect")
11598
- .description("Get a Unipile hosted-auth URL to connect a new LinkedIn account (or reconnect with --reconnect). New accounts require --country (the owner's normal LinkedIn login country) and default to syncing only conversations OXYGEN starts; use --inbox-scope all to opt into the full LinkedIn inbox. Use --cookie-auth and --custom-proxy to expose those inputs inside Unipile's hosted wizard; their secrets never pass through OXYGEN. Use --count for bulk onboarding. Links are shareable and valid for 10 minutes.")
12383
+ .description("Get a Unipile hosted-auth URL to connect a new LinkedIn account (or reconnect with --reconnect). Each connected LinkedIn account occupies one LinkedIn sending seat — $30/month, billed in dollars on the billing owner's seat subscription — unless the billing owner still has grandfathered LinkedIn capacity; check `oxygen billing seats --json` before connecting, and a workspace linked to another organization's plan draws on that organization's seats. New accounts require --country (the owner's normal LinkedIn login country) and default to syncing only conversations OXYGEN starts; use --inbox-scope all to opt into the full LinkedIn inbox. Use --cookie-auth and --custom-proxy to expose those inputs inside Unipile's hosted wizard; their secrets never pass through OXYGEN. Use --count for bulk onboarding. Links are shareable and valid for 10 minutes.")
11599
12384
  .option("--reconnect <connection_id>", "Reconnect an existing connection instead of creating a new one. Accepts a connection id.")
11600
12385
  .option("--country <code>", "Required for new accounts: ISO 3166-1 alpha-2 code for the account owner's normal LinkedIn login country (for example DE or US).")
11601
12386
  .option("--sales-nav", "Request Classic + Sales Navigator access during Unipile hosted authentication.")
@@ -12673,14 +13458,14 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12673
13458
  });
12674
13459
  })));
12675
13460
  program.addCommand(new Command("inbox")
12676
- .description("Unified inbox (unibox): private email + LinkedIn DM + WhatsApp conversations in one stream (--channel all), or a single channel. Public comments on owned LinkedIn posts are not inbox messages; use `publishing comments`. Scan, read threads, and reply.")
13461
+ .description("Unified inbox (unibox): private email + LinkedIn DM + WhatsApp conversations in one stream (the default), or a single channel with --channel; `get` and `send` resolve a conversation's channel from its id. Public comments on owned LinkedIn posts are not inbox messages; use `publishing comments`. Scan, read threads, and reply.")
12677
13462
  .addCommand(new Command("list")
12678
- .description("List conversations newest first. --channel all merges email + LinkedIn + WhatsApp into one stream (narrow it with --channels); --channel email/linkedin/whatsapp lists a single channel. Primary excludes the negative tier (not_now, not_interested, lost, bounced); All includes every ordinary conversation.")
12679
- .option("--channel <channel>", "Inbox channel: all (merged), linkedin (default), whatsapp, or email.")
12680
- .option("--channels <list>", "channel=all only: comma-separated channel groups to include (email,linkedin,whatsapp). Empty = all three.")
13463
+ .description("List conversations newest first across email, LinkedIn, and WhatsApp — the merged stream is the default, --channels narrows it, and --channel email/linkedin/whatsapp lists a single channel. Primary excludes the negative tier (not_now, not_interested, lost, bounced); All includes every ordinary conversation.")
13464
+ .option("--channel <channel>", "Inbox channel: all (default, the merged stream), email, linkedin, or whatsapp.")
13465
+ .option("--channels <list>", "Narrow the merged stream: comma-separated channel groups to include (email,linkedin,whatsapp). Implies --channel all.")
12681
13466
  .option("--account <id>", "LinkedIn only: filter to one sender account (sender id, connection id, or Unipile account id).")
12682
13467
  .option("--unread", "Only show conversations with unread messages.")
12683
- .option("--unanswered", "Only conversations awaiting YOUR reply — the last message in the thread is inbound. Cross-channel. Off by default: an unfiltered list still shows answered threads. (The web Unibox turns this on by default for its Primary tab.)")
13468
+ .option("--unanswered", "Only conversations awaiting YOUR reply — the last message in the thread is inbound. Cross-channel. Off by default: an unfiltered list still shows answered threads. (The web Unibox turns this on by default for its Primary tab.) It includes inbound mail no campaign sent — unsolicited or spam — so for replies to your outreach add --sequence-id; the ids and per-campaign counts are in sidebar_counts.byCampaign of the same --json response.")
12684
13469
  .option("--responses-only", "Email only: only conversations with an inbound reply (never sent-only threads).")
12685
13470
  .option("--bucket <bucket>", "Email only: primary or others (superseded by --segment).")
12686
13471
  .option("--segment <segment>", "Top tab: primary (everything but the negative status tier), all, or an email-only folder (others, sent, warmup, dmarc).")
@@ -12696,7 +13481,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12696
13481
  .option("--search <text>", "Fuzzy search (typos, word order, prefixes) over names, addresses, subjects and the text of every message in EVERY non-warmup conversation: all channels unless --channel narrows, archived and sent-only threads included. --segment, --unanswered and the Primary tab's negative-tier exclusion are ignored while set; explicit facets such as --status still narrow.")
12697
13482
  .option("--include-archived", "Include archived conversations.")
12698
13483
  .option("--limit <n>", "Maximum conversations to return (1-200). Defaults to 50.")
12699
- .option("--cursor <cursor>", "channel=all only: the previous page's next_cursor — resumes the merged stream after that row.")
13484
+ .option("--cursor <cursor>", "Merged stream only: the previous page's next_cursor — resumes after that row.")
12700
13485
  .option("--no-counts", "channel=all only: skip the sidebar facet counts for a faster paged read (total_conversations/unread_conversations/sidebar_counts omitted from the envelope). Other channels ignore it.")
12701
13486
  .option("--json", "Print a JSON envelope.")
12702
13487
  .action(async (options) => {
@@ -12750,9 +13535,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12750
13535
  });
12751
13536
  }))
12752
13537
  .addCommand(new Command("get")
12753
- .description("Get one conversation with its full message thread. <conversation> accepts a conversation id, Unipile chat id, or (email) Zapbox thread id.")
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.")
12754
13539
  .argument("<conversation>", "Conversation id, Unipile chat id, or Zapbox thread id.")
12755
- .option("--channel <channel>", "Inbox channel: linkedin (default), whatsapp, or email.")
13540
+ .option("--channel <channel>", "Inbox channel: all (default, resolves the id on any channel), email, linkedin, or whatsapp.")
12756
13541
  .option("--message-limit <n>", "Maximum messages to return (1-500). Defaults to 100.")
12757
13542
  .option("--json", "Print a JSON envelope.")
12758
13543
  .action(async (conversation, options) => {
@@ -12769,10 +13554,10 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12769
13554
  });
12770
13555
  }))
12771
13556
  .addCommand(new Command("send")
12772
- .description("Reply into a conversation. --channel email replies from the conversation's own mailbox (Zapbox/native Gmail/Graph, threaded); --channel whatsapp warm-replies into an existing WhatsApp conversation; default replies into the LinkedIn conversation. Sends a real message — requires --approved. Without it, returns a preview.")
13557
+ .description("Reply into a conversation on its own channel, resolved from the conversation when --channel is omitted: an email thread replies from its own mailbox (Zapbox/native Gmail/Graph, threaded); a WhatsApp thread gets a warm reply; a LinkedIn thread replies via Unipile. Sends a real message — requires --approved. Without it, returns a preview.")
12773
13558
  .argument("<conversation>", "Conversation id, Unipile chat id, or (email) Zapbox thread id.")
12774
13559
  .requiredOption("--text <message>", "Reply text to send.")
12775
- .option("--channel <channel>", "Inbox channel: linkedin (default), whatsapp (warm reply), or email.")
13560
+ .option("--channel <channel>", "Optional: email, linkedin, or whatsapp. Resolved from the conversation when omitted.")
12776
13561
  .option("--approved", "Approve and send the message. Without this flag, returns a preview only.")
12777
13562
  .option("--draft-id <id>", "When approving an AI reply-agent draft (email or LinkedIn), its id (marks it sent on success). Use the matching --channel.")
12778
13563
  .option("--attach <url>", "LinkedIn only: attach a file by public URL (image/document). Repeatable, up to 5.", collectRepeatable, [])
@@ -12902,7 +13687,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12902
13687
  });
12903
13688
  }))
12904
13689
  .addCommand(new Command("sync")
12905
- .description("Force a backstop inbox sync from Unipile for all active sender accounts (pulls recent chats + messages into the unibox).")
13690
+ .description("Force a backstop inbox sync from Unipile for the active LinkedIn and WhatsApp sender accounts (pulls recent chats + messages into the unibox). Email mailboxes are not synced here: they are polled automatically every worker tick, so email replies reach `inbox list` on their own.")
12906
13691
  .option("--account <id>", "Force-sync one sender account (sender id, connection id, or Unipile account id).")
12907
13692
  .option("--chat-limit <n>", "Maximum chats to sync per account. Defaults to 30.")
12908
13693
  .option("--message-limit <n>", "Maximum messages to sync per chat. Defaults to 20.")
@@ -12923,14 +13708,24 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12923
13708
  });
12924
13709
  }))
12925
13710
  .addCommand(new Command("backfill")
12926
- .description("Opt-in: pull a LinkedIn account's OLDER message history into the unibox. A SLOW, durable background drip walks each conversation's thread strictly backward — one page per worker tick under a dedicated conservative read budget — so a long history fills in over many days and never burns the account's limits. New messages keep arriving in real time via webhooks; this only backfills old history. No messages are sent and no Oxygen credits are charged.")
13711
+ .description("Opt-in, LinkedIn only: pull a LinkedIn account's OLDER message history into the unibox. A SLOW, durable background drip walks each conversation's thread strictly backward — one page per worker tick under a dedicated conservative read budget — so a long history fills in over many days and never burns the account's limits. New messages keep arriving in real time via webhooks; this only backfills old history. --cancel stops an armed drip. No messages are sent and no Oxygen credits are charged.")
12927
13712
  .option("--account <ref>", "Sender account to backfill (sender id, connection id, or Unipile account id). Omit to backfill every active LinkedIn account.")
12928
13713
  .option("--conversation <ref>", "Scope to a single conversation (conversation id or Unipile chat id).")
13714
+ .option("--cancel", "Cancel the active history backfill drip for --account (or every LinkedIn account) instead of arming one. Messages already mirrored stay.")
12929
13715
  .option("--json", "Print a JSON envelope.")
12930
13716
  .action(async (options) => {
12931
13717
  await handleAsyncAction("inbox backfill", options, () => {
12932
13718
  const account = readOption(options.account);
12933
13719
  const conversation = readOption(options.conversation);
13720
+ if (options.cancel) {
13721
+ if (conversation) {
13722
+ throw new OxygenError("invalid_request", "--cancel stops a sender's whole drip; it cannot be combined with --conversation. Pass --account (or nothing) with --cancel.", { exitCode: 1 });
13723
+ }
13724
+ return requestOxygen("/api/cli/linkedin/inbox/backfill/cancel", {
13725
+ method: "POST",
13726
+ body: { ...(account ? { account } : {}) },
13727
+ });
13728
+ }
12934
13729
  return requestOxygen("/api/cli/linkedin/inbox/backfill", {
12935
13730
  method: "POST",
12936
13731
  body: {
@@ -13242,7 +14037,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13242
14037
  });
13243
14038
  }))));
13244
14039
  program.addCommand(new Command("messages")
13245
- .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).")
13246
14041
  .addCommand(new Command("query")
13247
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`.")
13248
14043
  .option("-q, --query <text>", "Full-text search over message bodies (relevance-ranked).")
@@ -13288,7 +14083,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13288
14083
  });
13289
14084
  }))
13290
14085
  .addCommand(new Command("stats")
13291
- .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.")
13292
14087
  .option("--sequence-id <ids>", "Comma-separated campaign (sequence) ids to scope to.")
13293
14088
  .option("--channel <channel>", "Channel: all (default), email, linkedin, or whatsapp.")
13294
14089
  .option("--since <iso>", "Only messages sent at or after this ISO timestamp. Defaults to 30 days ago.")
@@ -13319,12 +14114,13 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13319
14114
  .addCommand(new Command("list")
13320
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.")
13321
14116
  .option("--status <status>", "Filter by status: draft, active, paused, or archived.")
13322
- .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"]))
13323
14119
  .option("--tag <tag>", "Only sequences carrying this workspace tag (see `oxygen tags list`).")
13324
14120
  .option("--stats", "Attach lifetime stats per sequence (a stats pass per row — slower on large workspaces).")
13325
14121
  .option("--json", "Print a JSON envelope.")
13326
14122
  .action(async (options) => {
13327
- await handleSequenceReadAction("sequences list", options, () => {
14123
+ await handleReadActionWithLens("sequences list", options, () => {
13328
14124
  const params = new URLSearchParams();
13329
14125
  const status = readOption(options.status);
13330
14126
  if (status)
@@ -13332,6 +14128,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13332
14128
  const operationalState = readOption(options.operationalState);
13333
14129
  if (operationalState)
13334
14130
  params.set("operational_state", operationalState);
14131
+ const channel = readOption(options.channel);
14132
+ if (channel)
14133
+ params.set("channel", channel);
13335
14134
  const tag = readOption(options.tag);
13336
14135
  if (tag)
13337
14136
  params.set("tag", tag);
@@ -13562,7 +14361,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13562
14361
  .option("--sequence <id-or-slug>", "Limit analytics to one sequence.")
13563
14362
  .option("--json", "Print a JSON envelope.")
13564
14363
  .action(async (options) => {
13565
- await handleSequenceReadAction("sequences analytics", options, () => {
14364
+ await handleReadActionWithLens("sequences analytics", options, () => {
13566
14365
  const params = new URLSearchParams();
13567
14366
  const range = readOption(options.range);
13568
14367
  const from = readOption(options.from);
@@ -13584,14 +14383,16 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13584
14383
  .description("Create a draft multichannel sequence from a steps JSON file. Supports LinkedIn, email, WhatsApp, human call_task, and connected-CRM crm_task journeys. Assign LinkedIn senders with --senders (optional at create — a draft can sit senderless, but enroll/start require at least one for LinkedIn journeys); bind an Instantly email track with --email-*. Install/read the oxygen-sequencer skill for complete mapped CRM task examples.")
13585
14384
  .requiredOption("--name <name>", "Human-readable sequence name.")
13586
14385
  .requiredOption("--slug <slug>", "Unique slug for the sequence.")
13587
- .requiredOption("--steps-file <path>", "Path to a JSON file: { \"steps\": [...] }. LinkedIn steps (visit_profile | invite | wait_for_connection | message | inmail | follow | like_post | comment_post | withdraw_invite), email steps (email_send | email_enroll | email_move | email_stop), WhatsApp (whatsapp_message), human call tasks (call_task), connected-CRM tasks (crm_task; channel crm; exact identity record_mappings + explicit create/update policy + record_links + dynamic task property_mappings/associations), and control steps (wait | wait_for_signal | branch | stop), each with an `id`. Copy templates support {{column}} interpolation from the lead's row, {{column|fallback}} inline fallbacks, deterministic spintax — {{RANDOM|a|b|c}} or industry-standard bare {Hi|Hey|Hello}, nestable like {Would {Tuesday|Thursday} work|next week?} — and {% if column %}…{% endif %} conditionals; a bare {…} region is spintax only when it contains a top-level |, so literal braces (CSS/JSON) pass through. Native email sends (email_send) also expose three reserved sender variables from the sending mailbox: {{sender_name}} (mailbox display name), {{sender_first_name}} (its first word), and {{sender_email}} (the from address); a row column of the same name WINS on collision, and a missing display name renders empty. comment_post takes text_template and/or ai_prompt — a KG-grounded comment generated at send time (a paid AI call) that falls back to text_template if generation fails. A `branch` routes on signals (then/else) or the legacy connection_accepted/already_connected/open_profile sugar (then_id/else_id). A signal condition can also branch on the LEAD'S DATA: a data leaf { has_column: \"email\" } is true when that row_values column has a non-empty value (add present:false for \"missing\") — e.g. route leads that have an email down an email arm and the rest down a LinkedIn arm. MINIMAL EMAIL STEP SHAPE: { \"id\": \"s1\", \"channel\": \"email\", \"kind\": \"email_send\", \"subject_template\": \"...\", \"body_template\": \"...\" } — body_template is REQUIRED on email_send; subject_template is OPTIONAL and is the one control that decides threading: give a step its own subject and it goes out as a NEW email, omit it and the step continues the lead's previous email in the same thread (what the retired email_reply kind used to be — still accepted on input and rewritten into this shape). The FIRST email step of a sequence must carry a subject, since it has no earlier thread to continue; A/B tests use an explicit `variants` array of copy partials on the step (up to 25 alternates, a–z; spintax varies wording INSIDE one variant and is not A/B-tracked). For the complete replay-safe crm_task upsert mapping shape, install/read the oxygen-sequencer skill.")
14386
+ .requiredOption("--steps-file <path>", "Path to a JSON file: { \"steps\": [...] }. LinkedIn steps (visit_profile | invite | wait_for_connection | message | inmail | follow | like_post | comment_post | withdraw_invite), email steps (email_send | email_enroll | email_move | email_stop), WhatsApp (whatsapp_message), human call tasks (call_task), connected-CRM tasks (crm_task; channel crm; exact identity record_mappings + explicit create/update policy + record_links + dynamic task property_mappings/associations), and control steps (wait | wait_for_signal | branch | stop), each with an `id`. Copy templates support {{column}} interpolation from the lead's row, {{column|fallback}} inline fallbacks, deterministic spintax — {{RANDOM|a|b|c}} or industry-standard bare {Hi|Hey|Hello}, nestable like {Would {Tuesday|Thursday} work|next week?} — and {% if column %}…{% endif %} conditionals; a bare {…} region is spintax only when it contains a top-level |, so literal braces (CSS/JSON) pass through. Native email sends (email_send) also expose three reserved sender variables from the sending mailbox: {{sender_name}} (mailbox display name), {{sender_first_name}} (its first word), and {{sender_email}} (the from address); a row column of the same name WINS on collision, and a missing display name renders empty. comment_post takes text_template and/or ai_prompt — a KG-grounded comment generated at send time (a paid AI call) that falls back to text_template if generation fails. A `branch` routes on signals (then/else) or the legacy connection_accepted/already_connected/open_profile sugar (then_id/else_id). A signal condition can also branch on the LEAD'S DATA: a data leaf { has_column: \"email\" } is true when that row_values column has a non-empty value (add present:false for \"missing\") — e.g. route leads that have an email down an email arm and the rest down a LinkedIn arm. MINIMAL EMAIL STEP SHAPE: { \"id\": \"s1\", \"channel\": \"email\", \"kind\": \"email_send\", \"subject_template\": \"...\", \"body_template\": \"...\" } — body_template is REQUIRED on email_send; subject_template is OPTIONAL and is the one control that decides threading: give a step its own subject and it goes out as a NEW email, omit it and the step continues the lead's previous email in the same thread, going out as \"Re: <the previous email's subject>\" (what the retired email_reply kind used to be — still accepted on input, rewritten into this shape, and reported under `warnings` in the response). The FIRST email step of a sequence must carry a subject, since it has no earlier thread to continue. MINIMAL WHATSAPP STEP SHAPE: { \"id\": \"s1\", \"channel\": \"whatsapp\", \"kind\": \"whatsapp_message\", \"template\": \"Hi {{first_name|there}} …\" } — template is REQUIRED, and a live WhatsApp start also needs the sequence's --whatsapp-cold-initiate opt-in. A/B tests use an explicit `variants` array of copy partials on the step (up to 25 alternates, a–z; spintax varies wording INSIDE one variant and is not A/B-tracked). For the complete replay-safe crm_task upsert mapping shape, install/read the oxygen-sequencer skill.")
13588
14387
  .option("--channels <list>", "Comma-separated channels: linkedin,email,whatsapp,call,crm. Defaults to the channels the journey touches. crm_task currently supports HubSpot.")
13589
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.")
13590
14389
  .option("--phone-column-key <key>", "WhatsApp: row_values key holding each lead's phone number (else falls back to phone/phone_number/mobile).")
13591
- .option("--senders <ids>", "Comma-separated LinkedIn sender account ids (or connection / Unipile ids). Optional at create; enroll and start require at least one when the journey has LinkedIn steps (attach later with `sequences update --senders`).")
13592
- .option("--table <id>", "Source table id whose rows supply {{column}} template values.")
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`).")
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.")
13593
14394
  .option("--url-column <key>", "Column key holding each lead's LinkedIn URL/provider id.")
13594
- .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.")
13595
14396
  .option("--email-connection <id>", "Instantly connection id for the email track. Defaults to the org's active Instantly connection.")
13596
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.")
13597
14398
  .option("--max-credits <n>", "Credit cap for the LinkedIn track (also set when starting).")
@@ -13633,6 +14434,13 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13633
14434
  const maxCredits = readPositiveNumber(options.maxCredits);
13634
14435
  const maxLiveSends = readPositiveInt(options.maxLiveSends);
13635
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
+ }
13636
14444
  return requestOxygen("/api/cli/sequences", {
13637
14445
  method: "POST",
13638
14446
  body: {
@@ -13642,7 +14450,8 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13642
14450
  ...(channels.length > 0 ? { channels } : {}),
13643
14451
  ...(senders.length > 0 ? { senders } : {}),
13644
14452
  ...(tags.length > 0 ? { tags } : {}),
13645
- ...(readOption(options.table) ? { source_table_id: readOption(options.table) } : {}),
14453
+ ...(sourceTable ? { source_table_id: sourceTable } : {}),
14454
+ ...(options.enroll ? { enroll_from_table: true } : {}),
13646
14455
  ...(readOption(options.urlColumn) ? { linkedin_url_column_key: readOption(options.urlColumn) } : {}),
13647
14456
  ...(email ? { email } : {}),
13648
14457
  ...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
@@ -13691,7 +14500,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13691
14500
  .option("--phone-column-key <key>", "WhatsApp: row_values key holding each lead's phone number (else falls back to phone/phone_number/mobile).")
13692
14501
  .option("--email-column-key <key>", "Native email: row_values key holding each lead's email (else falls back to email/email_address/work_email). Maps a non-English header (e.g. E-Mail).")
13693
14502
  .option("--linkedin-url-column-key <key>", "LinkedIn: row_values key holding each lead's profile URL (else falls back to linkedin_url/linkedinUrl/linkedin/profile_url). \"\" clears it back to auto-detect.")
13694
- .option("--senders <ids>", "Comma-separated LinkedIn sender account ids (or connection / Unipile ids).")
14503
+ .option("--senders <ids>", "Comma-separated LinkedIn or WhatsApp sender account ids (or connection / Unipile ids; `linkedin senders` / `whatsapp accounts` list them).")
13695
14504
  .option("--source-table <idOrSlug>", "Bind the table this sequence enrolls leads from (id or slug). Only lands while the sequence has no source table yet (first-writer-wins); its rows become enrollable leads. Draft/paused only.")
13696
14505
  .option("--email-provider <provider>", "Email provider for the email track. Only 'instantly' is supported.")
13697
14506
  .option("--email-connection <id>", "Instantly connection id for the email track.")
@@ -13784,14 +14593,15 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13784
14593
  });
13785
14594
  }))
13786
14595
  .addCommand(new Command("get")
13787
- .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.")
13788
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.")
13789
14599
  .option("--json", "Print a JSON envelope.")
13790
14600
  .action(async (sequence, options) => {
13791
- 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" : ""}`));
13792
14602
  }))
13793
14603
  .addCommand(new Command("enroll")
13794
- .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`.")
13795
14605
  .argument("<sequence>", "Sequence id or slug.")
13796
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.")
13797
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.")
@@ -13832,14 +14642,58 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13832
14642
  },
13833
14643
  });
13834
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);
13835
14689
  }))
13836
14690
  .addCommand(new Command("start")
13837
- .description("Preview or start a sequence. Without --approved, preview 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.")
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.")
13838
14692
  .argument("<sequence>", "Sequence id or slug.")
13839
14693
  .option("--approved", "Approve and activate live. Without this flag, returns a preview only, including copy_preview rendered samples and blockers.")
13840
14694
  .option("--max-credits <n>", "Optional credit ceiling for the LinkedIn track (omit for an unbounded run).")
13841
14695
  .option("--max-live-sends <n>", "Live external-action ceiling (positive integer). Required for email, WhatsApp, or crm_task steps.")
13842
- .option("--dry-run", "Activate in dry-run mode: advance every step with simulated actions, no sends, CRM writes, provider calls, or credits.")
14696
+ .option("--dry-run", "Activate the sequence (status draft → active, startedAt set) in dry-run mode: every step advances with simulated actions — no sends, CRM writes, provider calls, or credits. This is NOT the preview: `sequences start` with no flags is the side-effect-free launch preview, and a dry-run sequence must be paused or deleted to leave that state.")
13843
14697
  .option("--json", "Print a JSON envelope.")
13844
14698
  .action(async (sequence, options) => {
13845
14699
  await handleAsyncAction("sequences start", options, () => {
@@ -14053,7 +14907,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
14053
14907
  .argument("<sequence>", "Sequence id or slug.")
14054
14908
  .option("--json", "Print a JSON envelope.")
14055
14909
  .action(async (sequence, options) => {
14056
- 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);
14057
14911
  }))
14058
14912
  .addCommand(new Command("esp")
14059
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.")
@@ -14340,7 +15194,74 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
14340
15194
  });
14341
15195
  }))));
14342
15196
  program.addCommand(new Command("suppressions")
14343
- .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
+ }))
14344
15265
  .addCommand(new Command("list")
14345
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).")
14346
15267
  .option("--reason <reason>", "Filter by reason: manual, replied, unsubscribed, bounced, do_not_contact, friends.")
@@ -15113,7 +16034,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
15113
16034
  await handleAsyncAction("mailboxes get", options, () => requestOxygen(`/api/cli/mailboxes/${encodeURIComponent(mailbox)}`));
15114
16035
  }))
15115
16036
  .addCommand(new Command("delete")
15116
- .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.")
15117
16038
  .requiredOption("--mailboxes <list>", "Comma-separated mailbox ids or addresses (maximum 500).")
15118
16039
  .option("--approved", "Execute the fresh preview. Requires --plan-hash and --confirmation.")
15119
16040
  .option("--plan-hash <hash>", "Fresh preview plan_hash.")
@@ -15452,7 +16373,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
15452
16373
  .addCommand(new Command("emailguard")
15453
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.")
15454
16375
  .addCommand(new Command("connect")
15455
- .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.")
15456
16377
  .option("--mailboxes <list>", "Comma-separated mailbox ids or addresses. Omit for the whole pool.")
15457
16378
  .option("--approved", "Perform the external EmailGuard account write.")
15458
16379
  .option("--plan <hash>", "Exact hash from the fresh preview (required with --approved).")
@@ -15569,10 +16490,10 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
15569
16490
  });
15570
16491
  }))
15571
16492
  .addCommand(new Command("warmup")
15572
- .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.")
15573
16494
  .addHelpText("after", "\nDocs: https://oxygen-agent.com/docs/providers/mailboxes\n")
15574
16495
  .addCommand(new Command("enable")
15575
- .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.")
15576
16497
  .addHelpText("after", "\nDocs: https://oxygen-agent.com/docs/providers/mailboxes\n")
15577
16498
  .option("--mailboxes <list>", "Comma-separated mailbox ids or addresses to warm. Omit to warm the whole pool.")
15578
16499
  .option("--approved", "Execute the PRICED enrollment. Without it the response is a priced preview and nothing is enrolled or billed.")
@@ -15802,7 +16723,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
15802
16723
  });
15803
16724
  }))
15804
16725
  .addCommand(new Command("disable")
15805
- .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.")
15806
16727
  .option("--mailboxes <list>", "Comma-separated mailbox ids or addresses. Omit for the whole pool.")
15807
16728
  .option("--json", "Print a JSON envelope.")
15808
16729
  .action(async (options) => {
@@ -17224,7 +18145,7 @@ Full trigger schema: oxygen workflows schema --subject trigger --json
17224
18145
  .option("--api-url <url>", "Oxygen app URL. Defaults to OXYGEN_API_URL or https://oxygen-agent.com.")
17225
18146
  .option("--agents <agents...>", "Space or comma separated agents. Defaults to codex, claude-code, and cursor.")
17226
18147
  .option("--skill <skill>", "Skill name or '*'. Defaults to '*'.")
17227
- .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.")
17228
18149
  .option("--copy", "Copy skill files instead of symlinking when supported by npx skills. Default on Windows, where symlinks need Developer Mode or admin.")
17229
18150
  .option("--json", "Print a JSON envelope.")
17230
18151
  .action(async (options) => {
@@ -17235,6 +18156,9 @@ Full trigger schema: oxygen workflows schema --subject trigger --json
17235
18156
  registerFunctionsCommands(program, handleAsyncAction);
17236
18157
  registerUgcCommands(program, handleAsyncAction);
17237
18158
  registerKnowledgeRepositoryCommands(program, handleAsyncAction);
18159
+ // Last, so every command registered above is covered — including the ones the
18160
+ // register* helpers add after applyOxygenHelp.
18161
+ enableCommandSuggestions(program);
17238
18162
  return program;
17239
18163
  }
17240
18164
  /**
@@ -18526,6 +19450,79 @@ async function assertPromptColumnReferences(table, prompt, inputNames = []) {
18526
19450
  exitCode: 1,
18527
19451
  });
18528
19452
  }
19453
+ /**
19454
+ * Human rendering of `columns catalog`: one line per template with the input it
19455
+ * reads and the credits per row, grouped by family. The JSON envelope is the
19456
+ * contract; this is what a person reads in a terminal.
19457
+ */
19458
+ function printColumnCatalog(result) {
19459
+ // `requestOxygen` hands back the envelope's `data` already unwrapped, so the
19460
+ // entries sit at the top level; a wrapped envelope is accepted too. Reading
19461
+ // only `result.data` printed "No column templates match." for every catalog
19462
+ // that actually matched (v1.985.2–v1.987.0).
19463
+ const data = isRecord(result) ? (isRecord(result.data) ? result.data : result) : null;
19464
+ const entries = data && Array.isArray(data.entries) ? data.entries.filter(isRecord) : [];
19465
+ const bundles = data && Array.isArray(data.bundles) ? data.bundles.filter(isRecord) : [];
19466
+ if (entries.length === 0 && bundles.length === 0) {
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");
19470
+ return;
19471
+ }
19472
+ const byFamily = new Map();
19473
+ for (const entry of entries) {
19474
+ const family = typeof entry.family === "string" ? entry.family : (typeof entry.category === "string" ? entry.category : "other");
19475
+ const bucket = byFamily.get(family) ?? [];
19476
+ bucket.push(entry);
19477
+ byFamily.set(family, bucket);
19478
+ }
19479
+ const lines = [];
19480
+ for (const [family, bucket] of byFamily) {
19481
+ lines.push(`${family.replaceAll("_", " ")}:`);
19482
+ for (const entry of bucket) {
19483
+ const inputs = Array.isArray(entry.inputs)
19484
+ ? entry.inputs.filter(isRecord).map((input) => `${String(input.name)}${input.required === false ? "?" : ""}`).join(", ")
19485
+ : "";
19486
+ const credits = isRecord(entry.credit_estimate) && typeof entry.credit_estimate.label === "string"
19487
+ ? entry.credit_estimate.label
19488
+ : "";
19489
+ lines.push(` ${String(entry.key)} [${String(entry.source)}${inputs ? ` · ${inputs}` : ""}] ${credits}`);
19490
+ if (typeof entry.description === "string" && entry.description)
19491
+ lines.push(` ${entry.description}`);
19492
+ }
19493
+ }
19494
+ // Bundles after the templates, because a bundle is the bigger commitment (it
19495
+ // creates several columns) and a search that matches both should read as
19496
+ // "here is the single column, and here is the pre-built set". Before this,
19497
+ // `columns catalog --search domain` printed nothing at all for the
19498
+ // domain_check preset and the only surface naming it was `columns add --help`
19499
+ // (blind user eval, 2026-09-17).
19500
+ if (bundles.length > 0) {
19501
+ lines.push("");
19502
+ lines.push("Bundles (several columns at once):");
19503
+ for (const bundle of bundles) {
19504
+ const credits = isRecord(bundle.credit_estimate) && typeof bundle.credit_estimate.label === "string"
19505
+ ? bundle.credit_estimate.label
19506
+ : "";
19507
+ const columnCount = typeof bundle.column_count === "number" ? `${bundle.column_count} columns` : "";
19508
+ const facts = [columnCount, credits].filter(Boolean).join(" · ");
19509
+ lines.push(` ${String(bundle.id)} [${String(bundle.category ?? "")}${facts ? ` · ${facts}` : ""}]`);
19510
+ if (typeof bundle.description === "string" && bundle.description)
19511
+ lines.push(` ${bundle.description}`);
19512
+ if (typeof bundle.caveat === "string" && bundle.caveat)
19513
+ lines.push(` ${bundle.caveat}`);
19514
+ if (typeof bundle.add_command === "string" && bundle.add_command)
19515
+ lines.push(` ${bundle.add_command}`);
19516
+ }
19517
+ }
19518
+ lines.push("");
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.");
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.");
19524
+ process.stderr.write(`${lines.join("\n")}\n`);
19525
+ }
18529
19526
  function applyAiColumnConfig(definition, options) {
18530
19527
  const model = readOption(options.model);
18531
19528
  if (model)
@@ -18563,6 +19560,7 @@ function applyAiColumnConfig(definition, options) {
18563
19560
  return definition;
18564
19561
  }
18565
19562
  const RESEARCH_ENGINES = new Set(["exa", "parallel", "firecrawl"]);
19563
+ const RESEARCH_FETCH_ENGINES = new Set(["firecrawl", "linkup", "exa"]);
18566
19564
  const RESEARCH_MODES = new Set(["strict", "estimate"]);
18567
19565
  /**
18568
19566
  * Fold the `--research-*` flags into `definition.webSearch`.
@@ -18590,10 +19588,24 @@ function applyResearchColumnConfig(definition, options) {
18590
19588
  }
18591
19589
  webSearch.evidenceMode = mode;
18592
19590
  }
19591
+ // Fetch mode: the column reads one page per row instead of searching. The
19592
+ // URL column is a row column key (the same thing a {{token}} would name), so
19593
+ // the server can reject a typo at write time instead of once per row.
19594
+ const researchUrl = readOption(options.researchUrl);
19595
+ if (researchUrl) {
19596
+ if (readOption(options.researchQuery)) {
19597
+ throw new OxygenError("invalid_request", "--research-url reads one page per row, so --research-query does not apply. Drop one of the two.", { exitCode: 1 });
19598
+ }
19599
+ webSearch.source = "fetch";
19600
+ webSearch.urlInput = researchUrl;
19601
+ }
18593
19602
  const engine = readOption(options.researchEngine)?.toLowerCase();
18594
19603
  if (engine) {
18595
- if (!RESEARCH_ENGINES.has(engine)) {
18596
- throw new OxygenError("invalid_request", `--research-engine must be exa, parallel, or firecrawl (got ${engine}).`, { exitCode: 1 });
19604
+ const allowed = researchUrl ? RESEARCH_FETCH_ENGINES : RESEARCH_ENGINES;
19605
+ if (!allowed.has(engine)) {
19606
+ throw new OxygenError("invalid_request", researchUrl
19607
+ ? `--research-engine with --research-url must be firecrawl, linkup, or exa (got ${engine}).`
19608
+ : `--research-engine must be exa, parallel, or firecrawl (got ${engine}).`, { exitCode: 1 });
18597
19609
  }
18598
19610
  webSearch.engine = engine;
18599
19611
  }
@@ -19247,6 +20259,7 @@ function readFeedBindBody(table, options) {
19247
20259
  feed_kind: options.kind,
19248
20260
  route: {
19249
20261
  tool_id: options.toolId,
20262
+ // assertPullFeedBindArguments guarantees --request-json before this reader runs.
19250
20263
  request: parseJsonObject(readFileIfPresent(options.requestJson)),
19251
20264
  ...(toolIds.length > 0 ? { tool_ids: toolIds } : {}),
19252
20265
  ...(readOption(options.rowsPath) ? { rows_path: readOption(options.rowsPath) } : {}),
@@ -19270,6 +20283,225 @@ function readFeedBindBody(table, options) {
19270
20283
  ...(options.approved ? { approved: true } : {}),
19271
20284
  };
19272
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
+ }
19273
20505
  function readSignalsSearchPlanBody(options, promptArg) {
19274
20506
  // The prompt may arrive as --prompt or as the positional argument; the flag wins.
19275
20507
  const promptSource = options.prompt ?? promptArg;
@@ -19382,8 +20614,8 @@ function readCompaniesSearchRunBody(options, promptArg) {
19382
20614
  const promptSource = options.prompt ?? promptArg;
19383
20615
  const prompt = promptSource ? readFileIfPresent(promptSource) : null;
19384
20616
  const plan = options.planJson ? readSearchPlanJson(options.planJson) : null;
19385
- if (!prompt && !plan) {
19386
- throw new OxygenError("invalid_request", "Pass --prompt or --plan-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 });
19387
20619
  }
19388
20620
  const maxPages = readPositiveInt(options.maxPages);
19389
20621
  const maxCredits = readPositiveNumber(options.maxCredits);
@@ -19392,6 +20624,9 @@ function readCompaniesSearchRunBody(options, promptArg) {
19392
20624
  return {
19393
20625
  ...(prompt ? { prompt } : {}),
19394
20626
  ...(plan ? { plan } : {}),
20627
+ ...(options.source ? { source: options.source } : {}),
20628
+ ...(options.sourceFiltersJson ? { source_filters: parseJsonObject(readFileIfPresent(options.sourceFiltersJson)) } : {}),
20629
+ ...(readOption(options.tableName) ? { table_name: readOption(options.tableName) } : {}),
19395
20630
  ...(options.routeId ? { route_id: options.routeId } : {}),
19396
20631
  ...(options.toolId ? { tool_id: options.toolId } : {}),
19397
20632
  ...(options.table ? { table: options.table } : {}),
@@ -19540,13 +20775,84 @@ function readSearchPlanJson(value) {
19540
20775
  ? data
19541
20776
  : parsed;
19542
20777
  }
20778
+ // Table-driven Employee Finder help, written once and shared by `people search
20779
+ // plan` and `people search run` so the price shape reads the same on both.
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.";
20781
+ const PEOPLE_SEARCH_LINKEDIN_COLUMN_HELP = "Column key in --from-table holding each company's LinkedIn URL.";
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.";
20783
+ const PEOPLE_SEARCH_COMPANY_LIMIT_HELP = "Fetch people for at most this many companies from --from-table. Start with --company-limit 1.";
20784
+ const PEOPLE_SEARCH_JOB_FUNCTIONS_HELP = "Comma-separated job functions: Sales, Marketing, Engineering, Finance, ...";
20785
+ const PEOPLE_SEARCH_CONTINENTS_HELP = "Comma-separated continents: Europe, North America, Asia, ...";
20786
+ const PEOPLE_SEARCH_SALES_REGIONS_HELP = "Comma-separated sales regions: EMEA, NAMER, APAC, LATAM.";
20787
+ const PEOPLE_SEARCH_MIN_CONNECTIONS_HELP = "Only return people with at least this many LinkedIn connections (0-500).";
20788
+ // `source` names the companies table the Employee Finder fans out over. The
20789
+ // offset/limit window rides INSIDE `source` when the source is declared here; with
20790
+ // a saved plan it rides as `source_overrides`, which the run route applies on top
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
+ }
20804
+ function readPeopleSearchSource(options) {
20805
+ const table = readOption(options.fromTable);
20806
+ const column = readOption(options.linkedinUrlColumn);
20807
+ if (!table && !column)
20808
+ return null;
20809
+ if (!table || !column) {
20810
+ throw new OxygenError("invalid_request", "--from-table and --linkedin-url-column go together: name the table holding your companies and the column holding each company's LinkedIn URL.", { details: { from_table: table ?? null, linkedin_url_column: column ?? null }, exitCode: 1 });
20811
+ }
20812
+ return { table, linkedin_url_column: column, ...readPeopleSearchCompanyWindow(options) };
20813
+ }
20814
+ function readPeopleSearchCompanyWindow(options) {
20815
+ const offset = readNonNegativeInt(options.companyOffset);
20816
+ const limit = readPositiveInt(options.companyLimit);
20817
+ return {
20818
+ ...(offset !== undefined ? { company_offset: offset } : {}),
20819
+ ...(limit !== undefined ? { company_limit: limit } : {}),
20820
+ };
20821
+ }
20822
+ // `--pages-per-company` is the customer-readable spelling of `--max-pages` on a
20823
+ // table-driven run, where a page is one provider request per company. Two spellings
20824
+ // of one ceiling must never disagree silently on a paid run.
20825
+ function readPeopleSearchMaxPages(options) {
20826
+ const perCompany = readPositiveInt(options.pagesPerCompany);
20827
+ const maxPages = readPositiveInt(options.maxPages);
20828
+ if (perCompany !== undefined && maxPages !== undefined && perCompany !== maxPages) {
20829
+ throw new OxygenError("invalid_request", "--pages-per-company and --max-pages set the same ceiling. Pass one of them.", { details: { pages_per_company: perCompany, max_pages: maxPages }, exitCode: 1 });
20830
+ }
20831
+ return perCompany ?? maxPages;
20832
+ }
20833
+ // A customer pointing at a table has already said what they want; making them
20834
+ // also invent a sentence is a question with one answer. The synthesized prompt is
20835
+ // the same one the web wizard sends, so both surfaces plan from identical input.
20836
+ function readPeopleSearchPrompt(options, source) {
20837
+ if (options.prompt)
20838
+ return readFileIfPresent(options.prompt);
20839
+ if (!source)
20840
+ return null;
20841
+ return `Employees at companies in ${String(source.table)}`;
20842
+ }
19543
20843
  function readPeopleSearchPlanBody(options) {
19544
20844
  const targetCount = readPositiveInt(options.targetCount);
19545
20845
  const filters = readPeopleSearchFilters(options);
20846
+ const source = readPeopleSearchSource(options);
20847
+ const prompt = readPeopleSearchPrompt(options, source);
20848
+ if (!prompt) {
20849
+ throw new OxygenError("invalid_request", "Pass --prompt, or --from-table with --linkedin-url-column to source employees from a table you already have.", { exitCode: 1 });
20850
+ }
19546
20851
  return {
19547
- prompt: readFileIfPresent(options.prompt),
20852
+ prompt,
19548
20853
  ...(targetCount !== undefined ? { target_count: targetCount } : {}),
19549
20854
  ...(options.sourceIntent ? { source_intent: options.sourceIntent } : {}),
20855
+ ...(source ? { source } : {}),
19550
20856
  ...(filters ? { filters } : {}),
19551
20857
  // Free sizing is on by default; only --no-estimate (options.estimate === false) opts out.
19552
20858
  estimate: options.estimate !== false,
@@ -19554,15 +20860,43 @@ function readPeopleSearchPlanBody(options) {
19554
20860
  };
19555
20861
  }
19556
20862
  function readPeopleSearchRunBody(options) {
19557
- const prompt = options.prompt ? readFileIfPresent(options.prompt) : null;
19558
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
+ }
20887
+ const source = readPeopleSearchSource(options);
20888
+ // --from-table is itself the request, so it satisfies the prompt requirement the
20889
+ // same way --plan-json does. A submitted plan already carries its own source and
20890
+ // prompt, so nothing is synthesized on top of one.
20891
+ const prompt = readPeopleSearchPrompt(options, plan ? null : source);
19559
20892
  if (!prompt && !plan) {
19560
- throw new OxygenError("invalid_request", "Pass --prompt or --plan-json.", { 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 });
19561
20894
  }
19562
- const maxPages = readPositiveInt(options.maxPages);
20895
+ const maxPages = readPeopleSearchMaxPages(options);
19563
20896
  const maxCredits = readPositiveNumber(options.maxCredits);
19564
20897
  const targetCount = readPositiveInt(options.targetCount);
19565
20898
  const filters = prompt ? readPeopleSearchFilters(options) : null;
20899
+ const sourceOverrides = source ? {} : readPeopleSearchCompanyWindow(options);
19566
20900
  return {
19567
20901
  ...(prompt ? { prompt } : {}),
19568
20902
  ...(plan ? { plan } : {}),
@@ -19576,6 +20910,8 @@ function readPeopleSearchRunBody(options) {
19576
20910
  ...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
19577
20911
  ...(targetCount !== undefined ? { target_count: targetCount } : {}),
19578
20912
  ...(options.sourceIntent ? { source_intent: options.sourceIntent } : {}),
20913
+ ...(source ? { source } : {}),
20914
+ ...(Object.keys(sourceOverrides).length > 0 ? { source_overrides: sourceOverrides } : {}),
19579
20915
  ...(filters ? { filters } : {}),
19580
20916
  // Free sizing is on by default when planning from a prompt; --no-estimate opts out.
19581
20917
  ...(prompt ? { estimate: options.estimate !== false } : {}),
@@ -19616,12 +20952,18 @@ function readPeopleSearchFilters(options) {
19616
20952
  const departments = readCsvOption(options.departments);
19617
20953
  if (departments.length > 0)
19618
20954
  filters.departments = { include: departments };
20955
+ const jobFunctions = readCsvOption(options.jobFunctions);
20956
+ if (jobFunctions.length > 0)
20957
+ filters.job_functions = jobFunctions;
19619
20958
  const keywords = readCsvOption(options.keywords);
19620
20959
  if (keywords.length > 0)
19621
20960
  filters.keywords = { include: keywords };
19622
- const countries = readCsvOption(options.countries);
19623
- if (countries.length > 0)
19624
- filters.geo = { countries };
20961
+ const geo = readPeopleSearchGeo(options);
20962
+ if (geo)
20963
+ filters.geo = geo;
20964
+ const minConnections = readNonNegativeInt(options.minConnections);
20965
+ if (minConnections !== undefined)
20966
+ filters.min_connections = minConnections;
19625
20967
  const contactability = {};
19626
20968
  if (options.requireEmail)
19627
20969
  contactability.require_work_email = true;
@@ -19652,6 +20994,22 @@ function readPeopleSearchFilters(options) {
19652
20994
  }
19653
20995
  return Object.keys(filters).length > 0 ? filters : null;
19654
20996
  }
20997
+ // Person location filters share one `geo` object: country, continent, and sales
20998
+ // region are separate provider fields, so each one must merge into it rather than
20999
+ // replace it.
21000
+ function readPeopleSearchGeo(options) {
21001
+ const geo = {};
21002
+ const countries = readCsvOption(options.countries);
21003
+ if (countries.length > 0)
21004
+ geo.countries = countries;
21005
+ const continents = readCsvOption(options.continents);
21006
+ if (continents.length > 0)
21007
+ geo.continents = continents;
21008
+ const salesRegions = readCsvOption(options.salesRegions);
21009
+ if (salesRegions.length > 0)
21010
+ geo.sales_regions = salesRegions;
21011
+ return Object.keys(geo).length > 0 ? geo : null;
21012
+ }
19655
21013
  function readCompaniesEnrichBody(table, options) {
19656
21014
  const body = { table };
19657
21015
  const fields = readCsvOption(options.missingFields);
@@ -19779,8 +21137,49 @@ function normalizeSessionStepStatus(value) {
19779
21137
  exitCode: 1,
19780
21138
  });
19781
21139
  }
19782
- // 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
+ }
19783
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) {
19784
21183
  const format = normalizeRowsFormat(options.format, inferRowsFileFormat(options.file));
19785
21184
  const parsedRows = await readRowsFile(options.file, format, options.sheet);
19786
21185
  if (parsedRows.length === 0) {
@@ -21421,13 +22820,6 @@ function escapeCsvField(value) {
21421
22820
  : String(value);
21422
22821
  return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, "\"\"")}"` : text;
21423
22822
  }
21424
- function chunk(values, size) {
21425
- const chunks = [];
21426
- for (let index = 0; index < values.length; index += size) {
21427
- chunks.push(values.slice(index, index + size));
21428
- }
21429
- return chunks;
21430
- }
21431
22823
  function readCount(value) {
21432
22824
  return typeof value === "number" && Number.isFinite(value) ? value : 0;
21433
22825
  }
@@ -22934,20 +24326,181 @@ function renderTextTable(headers, rows) {
22934
24326
  // Sequence state is derived only by the shared API. These helpers render those
22935
24327
  // facts; they never infer flowing/blocked/live client-side. JSON remains the
22936
24328
  // untouched API data inside the standard CLI envelope.
22937
- const SEQUENCE_OPERATIONAL_STATE_ORDER = ["empty", "drained", "blocked", "degraded", "flowing", "paced"];
22938
- 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) {
22939
24412
  try {
22940
24413
  const data = await action();
24414
+ writeBillingNotices(command, data);
22941
24415
  if (options.json) {
22942
24416
  writeJson(success(command, data));
22943
24417
  return;
22944
24418
  }
22945
24419
  process.stdout.write(isRecord(data) ? render(data) : `${JSON.stringify(data, null, 2)}\n`);
24420
+ writeCreditsReceipt(data);
22946
24421
  }
22947
24422
  catch (error) {
22948
24423
  emitCliFailure(command, error);
22949
24424
  }
22950
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
+ }
22951
24504
  function formatSequenceListHealth(data) {
22952
24505
  const sequences = Array.isArray(data.sequences) ? data.sequences.filter(isRecord) : [];
22953
24506
  const lines = [...formatSequenceFleetSummary(data.fleet), ""];
@@ -22963,6 +24516,122 @@ function formatSequenceListHealth(data) {
22963
24516
  lines.push(listLink);
22964
24517
  return `${lines.join("\n")}\n`;
22965
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
+ }
22966
24635
  function formatSequenceStatsHealth(data) {
22967
24636
  const stats = recordValue(data.stats);
22968
24637
  const operational = recordValue(stats.operational);
@@ -22978,13 +24647,22 @@ function formatSequenceStatsHealth(data) {
22978
24647
  if (failureMix !== "—")
22979
24648
  lines.push(`Failure mix: ${failureMix}`);
22980
24649
  lines.push(...formatActionKindBreakdown(operational.failureAnalytics));
22981
- lines.push("", ...renderTextTable(["FUNNEL", "COUNT"], [
22982
- ["Enrolled", numberText(stats.enrolled)],
22983
- ["Invites sent", numberText(stats.invitesSent ?? stats.connectionRequestsSent)],
22984
- ["Connected", numberText(stats.connected ?? stats.connectionRequestsAccepted)],
22985
- ["Messages sent", numberText(stats.messagesSent)],
22986
- ["Replies", numberText(stats.totalReplies ?? stats.replied)],
22987
- ]));
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
+ }
22988
24666
  const link = stringValue(data.web_url) ?? stringValue(data.deepLink);
22989
24667
  if (link)
22990
24668
  lines.push("", link);
@@ -23006,8 +24684,13 @@ function formatSequenceAnalyticsHealth(data) {
23006
24684
  `Selected-window outcomes: ${formatSequenceFailureTotals(failureAnalytics)}`,
23007
24685
  `Failure mix: ${formatCountRecord(recordValue(recordValue(failureAnalytics).byFailureClass))}`,
23008
24686
  ...formatActionKindBreakdown(failureAnalytics),
23009
- "",
23010
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("");
23011
24694
  if (sequences.length === 0) {
23012
24695
  lines.push("No sequences in this analytics scope.");
23013
24696
  }
@@ -23042,9 +24725,16 @@ function formatSequenceFleetSummary(value) {
23042
24725
  if (Object.keys(fleet).length === 0)
23043
24726
  return ["Fleet counts: unavailable (legacy API response)"];
23044
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;
23045
24732
  return [
23046
24733
  `Fleet: ${numberText(fleet.markedActive)} marked active · ${numberText(fleet.operationallyLive)} operationally live · ${numberText(fleet.total)} total`,
23047
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
+ : []),
23048
24738
  ];
23049
24739
  }
23050
24740
  function formatSequenceOpenWork(value) {
@@ -25760,14 +27450,35 @@ catch (error) {
25760
27450
  cliArgs.slice(-2).join(" ") === "limits --json") {
25761
27451
  process.stderr.write(`Hint: --json belongs to the show subcommand. Run \`${program.name()} limits show --json\`.\n`);
25762
27452
  }
27453
+ // `error: unknown command 'get'` names what is wrong and nothing to try
27454
+ // next; commander's edit-distance suggester cannot reach a synonym.
27455
+ if (error.code === "commander.unknownCommand") {
27456
+ const hint = unknownCommandHint(program, cliArgs);
27457
+ if (hint)
27458
+ process.stderr.write(`${hint}\n`);
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
+ }
25763
27468
  if (error.code === "commander.excessArguments" &&
25764
27469
  cliArgs[0] === "skills" &&
25765
27470
  cliArgs[1] === "install") {
25766
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);
25767
27476
  const skillArgument = /^[a-z0-9][a-z0-9_-]*$/i.test(requestedSkill)
25768
27477
  ? requestedSkill
25769
27478
  : "<name>";
25770
- 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`);
25771
27482
  }
25772
27483
  process.exitCode = error.exitCode;
25773
27484
  }