@oxygen-agent/cli 1.948.1 → 1.982.3

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 (54) hide show
  1. package/README.md +1 -1
  2. package/dist/admin-primary-providers-render.js +9 -1
  3. package/dist/cli-values.d.ts +14 -0
  4. package/dist/cli-values.js +26 -0
  5. package/dist/command-manifest.js +6 -0
  6. package/dist/functions-commands.js +13 -5
  7. package/dist/help.js +1 -0
  8. package/dist/index.js +1171 -240
  9. package/dist/knowledge-repository-commands.d.ts +6 -0
  10. package/dist/knowledge-repository-commands.js +198 -0
  11. package/dist/skills.js +20 -0
  12. package/dist/ugc-commands.js +122 -8
  13. package/node_modules/@oxygen/recipe-sdk/dist/index.d.ts +2 -0
  14. package/node_modules/@oxygen/shared/dist/capability-discovery.d.ts +8 -0
  15. package/node_modules/@oxygen/shared/dist/capability-discovery.js +99 -15
  16. package/node_modules/@oxygen/shared/dist/copilot-errors.js +3 -0
  17. package/node_modules/@oxygen/shared/dist/copilot-journeys.d.ts +19 -1
  18. package/node_modules/@oxygen/shared/dist/copilot-journeys.generated.d.ts +19 -0
  19. package/node_modules/@oxygen/shared/dist/copilot-journeys.generated.js +26 -0
  20. package/node_modules/@oxygen/shared/dist/copilot-journeys.js +8 -41
  21. package/node_modules/@oxygen/shared/dist/inbox-avatar-url.d.ts +28 -0
  22. package/node_modules/@oxygen/shared/dist/inbox-avatar-url.js +57 -0
  23. package/node_modules/@oxygen/shared/dist/index.d.ts +4 -0
  24. package/node_modules/@oxygen/shared/dist/index.js +4 -0
  25. package/node_modules/@oxygen/shared/dist/knowledge-bases.d.ts +74 -0
  26. package/node_modules/@oxygen/shared/dist/knowledge-bases.js +456 -0
  27. package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.d.ts +17 -38
  28. package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.js +14 -39
  29. package/node_modules/@oxygen/shared/dist/knowledge-repository.d.ts +22 -0
  30. package/node_modules/@oxygen/shared/dist/knowledge-repository.js +121 -0
  31. package/node_modules/@oxygen/shared/dist/knowledge-vault-markdown.d.ts +20 -0
  32. package/node_modules/@oxygen/shared/dist/knowledge-vault-markdown.js +155 -0
  33. package/node_modules/@oxygen/shared/dist/mailbox-import.d.ts +10 -0
  34. package/node_modules/@oxygen/shared/dist/mailbox-import.js +53 -0
  35. package/node_modules/@oxygen/shared/dist/plan-limits.d.ts +8 -0
  36. package/node_modules/@oxygen/shared/dist/plan-limits.js +8 -0
  37. package/node_modules/@oxygen/shared/dist/pricing-sheet.d.ts +1 -1
  38. package/node_modules/@oxygen/shared/dist/pricing-sheet.js +1 -1
  39. package/node_modules/@oxygen/shared/dist/product-analytics-events.d.ts +24 -0
  40. package/node_modules/@oxygen/shared/dist/product-analytics-events.js +24 -0
  41. package/node_modules/@oxygen/shared/dist/recipes.d.ts +6 -0
  42. package/node_modules/@oxygen/shared/dist/recipes.js +23 -0
  43. package/node_modules/@oxygen/shared/dist/sequences.d.ts +126 -2
  44. package/node_modules/@oxygen/shared/dist/sequences.js +280 -4
  45. package/node_modules/@oxygen/shared/dist/ugc-amplification-identity.d.ts +2 -0
  46. package/node_modules/@oxygen/shared/dist/ugc-amplification-identity.js +24 -0
  47. package/node_modules/@oxygen/shared/dist/ugc.d.ts +8 -0
  48. package/node_modules/@oxygen/shared/dist/user-capability-routing.js +8 -1
  49. package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
  50. package/node_modules/@oxygen/shared/dist/version.js +3 -1
  51. package/node_modules/@oxygen/shared/dist/workspace-file-storage.d.ts +6 -2
  52. package/node_modules/@oxygen/shared/dist/workspace-file-storage.js +15 -4
  53. package/node_modules/@oxygen/shared/package.json +15 -0
  54. package/package.json +2 -1
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { createHash, randomUUID } from "node:crypto";
3
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { closeSync, createReadStream, createWriteStream, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { basename, dirname, extname, join, resolve } from "node:path";
6
6
  import { createInterface } from "node:readline/promises";
@@ -8,12 +8,13 @@ import { stdin as input, stdout as output } from "node:process";
8
8
  import { fileURLToPath, pathToFileURL } from "node:url";
9
9
  import { Command, CommanderError, Option } from "commander";
10
10
  import { registerUgcCommands } from "./ugc-commands.js";
11
+ import { registerKnowledgeRepositoryCommands } from "./knowledge-repository-commands.js";
11
12
  import { registerVisualCommands } from "./visual-commands.js";
12
13
  import { renderPrimaryProviderBoard } from "./admin-primary-providers-render.js";
13
14
  import { registerFunctionsCommands } from "./functions-commands.js";
14
15
  import { applyOxygenHelp } from "./help.js";
15
16
  import { buildCommandManifest, getCommandManifestEntry, searchCommandManifest, suggestCommandNames, } from "./command-manifest.js";
16
- 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_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, 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
18
  import { TAG_COLORS } from "@oxygen/shared/select-options";
18
19
  import { inferImportColumnLabels, inferRowsFileFormat, normalizeImportColumnKey, normalizeRowsForNewTable, normalizeRowsFormat, parseRowsFileBuffer, parseXlsxWorkbookBuffer, } from "@oxygen/shared/file-import";
19
20
  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";
@@ -24,7 +25,7 @@ import { clearCredentials, defaultApiUrl, listCredentialProfiles, loadCredential
24
25
  import { ensureFreshCliForApiUrl, requestOxygen } from "./http-client.js";
25
26
  import { acquireMirrorLock, clearConflictFiles, deletePageFile, emptyMirrorState, findMirrorSlugByPageId, isFileDirty, listConflictFiles, listLocalMirrors, localPageSha256, markMirrorStale, mirrorExists, pageFilePath, planMirrorPush, purgeMirror, resetMirrorForFullResync, quarantineDirtyFile, readMirrorState, releaseMirrorLock, resolveDefaultConfigDir, resolveMirrorDir, writeGeneratedIndexFile, writeGeneratedLogFile, writeMirrorState, writePageFile, } from "./knowledge-mirror.js";
26
27
  import { waitForCliRun } from "./run-wait.js";
27
- import { assertModeFlagsExclusive, parseKeyValuePairs, parseJsonObject, readJsonObjectOption, readPositiveInt, readRecordString, resolveLiveDryRunMode, } from "./cli-values.js";
28
+ import { assertModeFlagsExclusive, parseKeyValuePairs, parseJsonObject, readJsonObjectOption, readNonNegativeInt, readPositiveInt, readRecordString, resolveLiveDryRunMode, } from "./cli-values.js";
28
29
  import { formatAiPromptPreviewNotice, formatColumnReferenceNotices, } from "./column-run-notices.js";
29
30
  import { runLocalCustomHttpColumn } from "./local-custom-http-column.js";
30
31
  import { captureCurrentTranscript, collectFeedbackEnvironment, TranscriptCaptureError, } from "./transcript.js";
@@ -415,6 +416,335 @@ function writeTableLinkPreview(table, data, options) {
415
416
  if (data.web_url)
416
417
  out(` ${data.web_url}`);
417
418
  }
419
+ /**
420
+ * The active warm-up filter as the flags that produced it, or null for a
421
+ * pool-wide read.
422
+ *
423
+ * The server's echo wins because it is what was actually applied; the flags this
424
+ * CLI sent are the fallback for a server too old to echo anything. Neither path
425
+ * invents a number — the choice here is only about WORDING, because the counts
426
+ * are already scoped to the matches and printing them bare reads as the whole
427
+ * workspace.
428
+ */
429
+ function describeMailboxFilters(echo, filterFlags) {
430
+ const parts = [];
431
+ if (echo) {
432
+ // Query order, so the line reads back as the command that produced it.
433
+ if (typeof echo.status === "string" && echo.status.length > 0) {
434
+ parts.push(`--status ${echo.status}`);
435
+ }
436
+ if (Array.isArray(echo.tag) && echo.tag.length > 0)
437
+ parts.push(`--tag ${echo.tag.join(",")}`);
438
+ if (Array.isArray(echo.warmup) && echo.warmup.length > 0) {
439
+ parts.push(`--warmup ${echo.warmup.join(",")}`);
440
+ }
441
+ if (Array.isArray(echo.next_action) && echo.next_action.length > 0) {
442
+ parts.push(`--next-action ${echo.next_action.join(",")}`);
443
+ }
444
+ if (echo.stalled === true)
445
+ parts.push("--stalled");
446
+ }
447
+ if (parts.length === 0)
448
+ parts.push(...filterFlags);
449
+ return parts.length > 0 ? parts.join(" ") : null;
450
+ }
451
+ const MAILBOX_POOL_COLUMNS = 80;
452
+ /**
453
+ * How many mailboxes may owe an action before a block each stops being a list
454
+ * and becomes a wall. Measured on dev's eval workspace: 223 of 224 mailboxes
455
+ * needed one, and a block each printed ~1,100 lines in which the same four
456
+ * sentences repeated 93 times and the single unusual seat was buried in the
457
+ * middle of them. Under this limit the per-mailbox detail IS the value, so it
458
+ * stays exactly as it is; over it, the same rows are grouped by the step they
459
+ * need and the filter that selects each group is printed instead.
460
+ */
461
+ const MAILBOX_POOL_DETAIL_LIMIT = 12;
462
+ /** Example addresses per group, and rows sampled from a long settled list. */
463
+ const MAILBOX_POOL_SAMPLES = 3;
464
+ /** Distinct cause sentences named before the rest are counted off. */
465
+ const MAILBOX_POOL_CAUSE_SAMPLES = 3;
466
+ /**
467
+ * Wrap prose under a label, hanging-indented to the label's width. Commands are
468
+ * never passed through here.
469
+ */
470
+ function writeWrappedPoolProse(out, prefix, text,
471
+ // Defaults to the label's own width; the headline overrides it, because a
472
+ // continuation starting at column 0 with `--next-action` reads as a separate
473
+ // command rather than the rest of a sentence.
474
+ hangingIndent) {
475
+ const [first, ...rest] = text.split(/\s+/).filter(Boolean);
476
+ if (!first)
477
+ return;
478
+ const indent = hangingIndent ?? " ".repeat(prefix.length);
479
+ let line = `${prefix}${first}`;
480
+ for (const word of rest) {
481
+ if (line.length + 1 + word.length > MAILBOX_POOL_COLUMNS) {
482
+ out(line);
483
+ line = `${indent}${word}`;
484
+ continue;
485
+ }
486
+ line = `${line} ${word}`;
487
+ }
488
+ out(line);
489
+ }
490
+ /**
491
+ * `warmupNeedsAction`, read off the wire instead of recomputed: the server
492
+ * publishes the code, the CLI only compares it. A row from a server that sends no
493
+ * warmupTruth cannot owe an action this renderer could name, so it reads as
494
+ * settled rather than being invented into the urgent list.
495
+ */
496
+ function mailboxNeedsWarmupAction(row) {
497
+ const code = row.warmupTruth?.next_action?.code;
498
+ return typeof code === "string" && code !== "none";
499
+ }
500
+ function mailboxPoolRow(row) {
501
+ const address = row.emailAddress ?? row.id ?? "(unnamed mailbox)";
502
+ const truth = row.warmupTruth;
503
+ const state = truth?.state ?? row.warmupState ?? "unknown";
504
+ const dispatch = truth?.dispatch;
505
+ const facts = [];
506
+ if (typeof dispatch?.current_day === "number")
507
+ facts.push(`day ${dispatch.current_day}`);
508
+ // A measured 0 is the entire story of a billed, silent seat, so it is printed.
509
+ // A null count was never reported by the rail and must not be shown as zero.
510
+ if (typeof dispatch?.sent === "number") {
511
+ facts.push(`${dispatch.sent.toLocaleString("en-US")} sent`);
512
+ }
513
+ // Only when it is not the boring answer: a mailbox OXYGEN is not sending from
514
+ // at all explains a silent warm-up faster than any warm-up field can.
515
+ if (typeof row.status === "string" && row.status !== "active")
516
+ facts.push(row.status);
517
+ return ` ${address.padEnd(34)} ${state.padEnd(12)} ${facts.join(" ")}`.trimEnd();
518
+ }
519
+ /** One mailbox's block: what it is, why, who stopped it, and what to run. */
520
+ function writeMailboxActionDetail(out, row) {
521
+ out(mailboxPoolRow(row));
522
+ const truth = row.warmupTruth;
523
+ if (truth?.label)
524
+ writeWrappedPoolProse(out, " Why: ", truth.label);
525
+ const pauseLabel = truth?.pause?.paused === true ? truth.pause.label : null;
526
+ // Who stopped it, when the state sentence did not already say so.
527
+ if (pauseLabel && !(truth?.label ?? "").includes(pauseLabel)) {
528
+ writeWrappedPoolProse(out, " Stop: ", pauseLabel);
529
+ }
530
+ const action = truth?.next_action;
531
+ if (action?.label)
532
+ writeWrappedPoolProse(out, " Fix: ", action.label);
533
+ if (action?.command)
534
+ out(` Run: ${action.command}`);
535
+ out();
536
+ }
537
+ /**
538
+ * One mailbox's next command with its own address replaced by a placeholder, so
539
+ * 93 rows that differ only by recipient collapse to one printable shape instead
540
+ * of 93 near-identical lines.
541
+ */
542
+ function mailboxCommandShape(row) {
543
+ const command = row.warmupTruth?.next_action?.command;
544
+ if (typeof command !== "string" || command.length === 0)
545
+ return null;
546
+ const address = row.emailAddress;
547
+ return address ? command.replaceAll(address, "<address>") : command;
548
+ }
549
+ function groupMailboxesByNextAction(rows) {
550
+ const groups = new Map();
551
+ for (const row of rows) {
552
+ const action = row.warmupTruth?.next_action;
553
+ const code = typeof action?.code === "string" ? action.code : "unknown";
554
+ const group = groups.get(code) ?? { code, rows: [], causes: [], commands: [] };
555
+ group.rows.push(row);
556
+ // Collected, never merged: two mailboxes can need the same step for
557
+ // different reasons, and printing one of those reasons over both is a lie
558
+ // the reader would act on.
559
+ if (action?.label && !group.causes.includes(action.label))
560
+ group.causes.push(action.label);
561
+ const shape = mailboxCommandShape(row);
562
+ if (shape && !group.commands.includes(shape))
563
+ group.commands.push(shape);
564
+ groups.set(code, group);
565
+ }
566
+ // Biggest first — the wall is what most needs collapsing — and alphabetically
567
+ // inside a tie so two runs over the same pool read the same way.
568
+ return [...groups.values()].sort((left, right) => right.rows.length - left.rows.length || left.code.localeCompare(right.code));
569
+ }
570
+ /**
571
+ * Example addresses, fitted to the line rather than counted out blindly: the
572
+ * trailing "(+N more)" is part of the budget, so this line cannot be the one
573
+ * that breaks the 80-column promise.
574
+ */
575
+ function mailboxSampleLine(rows, prefix) {
576
+ const addresses = rows.map((row) => row.emailAddress ?? row.id ?? "(unnamed mailbox)");
577
+ const shown = [];
578
+ for (const address of addresses.slice(0, MAILBOX_POOL_SAMPLES)) {
579
+ const remaining = addresses.length - (shown.length + 1);
580
+ const suffix = remaining > 0 ? ` (+${remaining} more)` : "";
581
+ const candidate = `${prefix}${[...shown, address].join(", ")}${suffix}`;
582
+ if (candidate.length > MAILBOX_POOL_COLUMNS && shown.length > 0)
583
+ break;
584
+ shown.push(address);
585
+ }
586
+ const remaining = addresses.length - shown.length;
587
+ return `${prefix}${shown.join(", ")}${remaining > 0 ? ` (+${remaining} more)` : ""}`;
588
+ }
589
+ /** The opening sentence, marked as clipped when there was more after it. */
590
+ function firstSentenceOf(text) {
591
+ const [first] = text.split(/(?<=[.!?])\s+/);
592
+ if (!first || first.length === text.length)
593
+ return text;
594
+ // The clipped sentence keeps its own question or exclamation mark; a full stop
595
+ // becomes the ellipsis, so the line never reads ". ...".
596
+ return `${first.replace(/\.$/, "")}\u2026`;
597
+ }
598
+ /**
599
+ * `listAll` is the other side of the `List:` line this function prints: once the
600
+ * reader has narrowed to one group, the samples they were given to choose with
601
+ * are no longer the answer — the addresses are. So a single group states its
602
+ * shared cause and command once, then lists every member compactly, and drops
603
+ * the filter line that would only point back at the view they are already in.
604
+ */
605
+ function writeMailboxActionGroups(out, groups, listAll = false) {
606
+ for (const group of groups) {
607
+ const count = group.rows.length;
608
+ const [only] = group.rows;
609
+ const noun = `${count} mailbox${count === 1 ? "" : "es"}`;
610
+ // A group of one is the odd seat in a pool of hundreds — the one thing a
611
+ // wall of repeated blocks buries. Name it on its own header line.
612
+ out(count === 1 && only
613
+ ? ` ${group.code} ${noun}: ${only.emailAddress ?? only.id ?? "(unnamed mailbox)"}`
614
+ : ` ${group.code} ${noun}`);
615
+ const [firstCause, ...otherCauses] = group.causes;
616
+ if (firstCause && otherCauses.length === 0) {
617
+ writeWrappedPoolProse(out, " Why: ", firstCause);
618
+ }
619
+ else if (firstCause) {
620
+ out(` Why: ${group.causes.length} different causes share this next step:`);
621
+ for (const cause of group.causes.slice(0, MAILBOX_POOL_CAUSE_SAMPLES)) {
622
+ // Opening sentence only: what differs between two causes sharing one
623
+ // next step is stated up front (the domain, the fault), and the rest is
624
+ // the shared remedy repeated verbatim. The full text is one `List:` line
625
+ // away, printed directly below.
626
+ writeWrappedPoolProse(out, " - ", firstSentenceOf(cause));
627
+ }
628
+ if (group.causes.length > MAILBOX_POOL_CAUSE_SAMPLES) {
629
+ out(` ... ${group.causes.length - MAILBOX_POOL_CAUSE_SAMPLES} more distinct causes`);
630
+ }
631
+ }
632
+ // One member means the real command; more means the shape, because a
633
+ // placeholder is honest about the 92 addresses it stands for.
634
+ const single = count === 1 && only ? only.warmupTruth?.next_action?.command : null;
635
+ const commands = single ? [single] : group.commands;
636
+ for (const command of commands.slice(0, 2))
637
+ out(` Run: ${command}`);
638
+ if (commands.length > 2) {
639
+ const rest = commands.length - 2;
640
+ out(` ... ${rest} more command ${rest === 1 ? "shape" : "shapes"} in this group`);
641
+ }
642
+ if (listAll) {
643
+ out();
644
+ for (const row of group.rows)
645
+ out(mailboxPoolRow(row));
646
+ out();
647
+ continue;
648
+ }
649
+ if (count > 1)
650
+ out(mailboxSampleLine(group.rows, " e.g. "));
651
+ // The exact filter that turns this group back into every one of its members.
652
+ out(` List: oxygen mailboxes list --next-action ${group.code}`);
653
+ out();
654
+ }
655
+ }
656
+ function writeMailboxPoolOverview(data, view) {
657
+ const out = (line = "") => process.stdout.write(`${line}\n`);
658
+ const rows = data.mailboxes ?? [];
659
+ const total = typeof data.count === "number" ? data.count : rows.length;
660
+ const warmup = data.summary?.warmup;
661
+ const filterLabel = describeMailboxFilters(data.filters, view.filterFlags);
662
+ if (filterLabel) {
663
+ // count, summary.total and summary.warmup are all scoped to what matched, so
664
+ // the pool-wide headline would be right about the wrong population: "2
665
+ // mailboxes" to a founder with 224 inboxes is not a smaller number, it is a
666
+ // false one. Name the filter, count the matches, and say which is which.
667
+ const stalled = warmup?.stalled;
668
+ const clause = typeof stalled === "number" && stalled > 0 && stalled < total
669
+ ? ` — ${stalled} of them have never sent`
670
+ : "";
671
+ writeWrappedPoolProse(out, "", `${total} mailbox${total === 1 ? "" : "es"} match${total === 1 ? "es" : ""} ${filterLabel}${clause}`, " ");
672
+ out("(a filtered view — not your whole pool; run oxygen mailboxes list for that)");
673
+ }
674
+ else {
675
+ const headline = [`${total} mailbox${total === 1 ? "" : "es"}`];
676
+ if (typeof warmup?.enrolled === "number")
677
+ headline.push(`${warmup.enrolled} in warm-up`);
678
+ if (typeof warmup?.stalled === "number") {
679
+ headline.push(`${warmup.stalled} enrolled but never sent`);
680
+ }
681
+ out(headline.join(" "));
682
+ }
683
+ // An older server has no summary.warmup at all. Say that under either
684
+ // headline, rather than printing a 0 a customer would read as "nothing is
685
+ // stalled" — or, in the filtered view, silently omitting the one count that
686
+ // explains why no stalled clause appeared.
687
+ if (!warmup)
688
+ out("(this server does not report warm-up pool counts)");
689
+ out();
690
+ if (rows.length === 0) {
691
+ out(view.filtered ? "No mailbox matches that filter." : "No mailboxes in this workspace yet.");
692
+ const emptyLink = data.web_url ?? data.deepLink;
693
+ if (emptyLink) {
694
+ out();
695
+ out(emptyLink);
696
+ }
697
+ return;
698
+ }
699
+ const needsAction = rows.filter((row) => mailboxNeedsWarmupAction(row));
700
+ const settled = rows.filter((row) => !mailboxNeedsWarmupAction(row));
701
+ // First, and never behind the healthy ones: this block is the reason the
702
+ // command was run.
703
+ if (needsAction.length > 0 && needsAction.length <= MAILBOX_POOL_DETAIL_LIMIT) {
704
+ out(`NEEDS ACTION (${needsAction.length})`);
705
+ out();
706
+ for (const row of needsAction)
707
+ writeMailboxActionDetail(out, row);
708
+ }
709
+ else if (needsAction.length > 0) {
710
+ const groups = groupMailboxesByNextAction(needsAction);
711
+ if (groups.length === 1) {
712
+ out(`NEEDS ACTION (${needsAction.length}) — all of them need the same next step`);
713
+ out();
714
+ writeMailboxActionGroups(out, groups, true);
715
+ }
716
+ else {
717
+ out(`NEEDS ACTION (${needsAction.length}) — grouped by next step, not listed one by one`);
718
+ out();
719
+ writeMailboxActionGroups(out, groups);
720
+ // Nothing is hidden, and the reader should not have to add the groups up
721
+ // to be sure of it.
722
+ out(` All ${needsAction.length} are in a group above. Add --json for every row.`);
723
+ out();
724
+ }
725
+ }
726
+ if (settled.length > 0) {
727
+ out(`NOTHING TO DO (${settled.length})`);
728
+ out();
729
+ // A healthy mailbox has nothing to say beyond existing, so past the limit a
730
+ // sample plus the count carries the same information as the whole list.
731
+ const shown = settled.length <= MAILBOX_POOL_DETAIL_LIMIT
732
+ ? settled
733
+ : settled.slice(0, MAILBOX_POOL_SAMPLES);
734
+ for (const row of shown)
735
+ out(mailboxPoolRow(row));
736
+ if (shown.length < settled.length) {
737
+ out(` ... ${settled.length - shown.length} more, not listed`);
738
+ }
739
+ out();
740
+ }
741
+ const link = data.web_url ?? data.deepLink;
742
+ if (link)
743
+ out(link);
744
+ if (!view.filtered && needsAction.length > 0) {
745
+ out("Narrow it with --stalled, --warmup <state>, or --next-action <code>.");
746
+ }
747
+ }
418
748
  function emitSuccess(command, data, options) {
419
749
  if (options.json) {
420
750
  writeJson(success(command, data));
@@ -432,6 +762,7 @@ async function handleAsyncAction(command, options, action) {
432
762
  writeBillingNotices(command, data);
433
763
  emitSuccess(command, data, options);
434
764
  writeDryRunNotice(data);
765
+ writeManagedProviderAvailabilityNotice(data);
435
766
  writeAvatarWarning(data);
436
767
  writeCreditsReceipt(data);
437
768
  }
@@ -439,6 +770,37 @@ async function handleAsyncAction(command, options, action) {
439
770
  emitCliFailure(command, error);
440
771
  }
441
772
  }
773
+ // The preview-time face of the managed-provider credit bench (OXP-1.14.11).
774
+ // `companies search plan` / `companies search run --mode dry_run` carry
775
+ // `provider_availability` and `verify email` (dry_run) carries
776
+ // `catch_all_escalation_available`; both are in the envelope for --json readers,
777
+ // but a benched provider buried in a 150-line plan is exactly how a customer
778
+ // sizes and approves a run that Oxygen already knows will be refused. One line
779
+ // per benched provider on stderr, same stdout/stderr split as the dry-run notice.
780
+ function writeManagedProviderAvailabilityNotice(data) {
781
+ const payload = asPayloadRecord(data);
782
+ if (!payload)
783
+ return;
784
+ const availability = asPayloadRecord(payload.provider_availability);
785
+ if (availability) {
786
+ const providers = asPayloadRecord(availability.providers) ?? {};
787
+ for (const [provider, entry] of Object.entries(providers)) {
788
+ const record = asPayloadRecord(entry);
789
+ if (record?.status !== "benched" && record?.status !== "unknown")
790
+ continue;
791
+ const message = typeof record.message === "string" ? record.message
792
+ : record.status === "unknown" ? `Oxygen could not check its managed ${provider} account availability.`
793
+ : `Oxygen's managed ${provider} account is benched.`;
794
+ const nextAction = typeof record.next_action === "string" ? ` ${record.next_action}` : "";
795
+ process.stderr.write(`! ${message}${nextAction}\n`);
796
+ }
797
+ return;
798
+ }
799
+ if ((payload.catch_all_escalation_available === false || payload.catch_all_escalation_availability === "unknown")
800
+ && typeof payload.next_action === "string") {
801
+ process.stderr.write(`! ${payload.next_action}\n`);
802
+ }
803
+ }
442
804
  // Single-source the CLI failure-emit contract: write the machine-readable
443
805
  // failure envelope to stdout, surface any spend-gate hint on stderr, and set the
444
806
  // process exit code from the error. Command handlers that don't route through
@@ -1760,6 +2122,7 @@ function buildPublishingPostCreateBody(options) {
1760
2122
  const providerConnection = readOption(options.providerConnection);
1761
2123
  const title = readOption(options.title);
1762
2124
  const timezone = readOption(options.timezone);
2125
+ const ugcParticipationId = readOption(options.ugcParticipationId);
1763
2126
  const content = buildPublishingContent(options);
1764
2127
  if (provider)
1765
2128
  body.provider = provider;
@@ -1773,6 +2136,12 @@ function buildPublishingPostCreateBody(options) {
1773
2136
  body.title = title;
1774
2137
  if (timezone)
1775
2138
  body.timezone = timezone;
2139
+ if (ugcParticipationId) {
2140
+ if (options.approved) {
2141
+ throw new OxygenError("ugc_draft_required", "A program-bound personal post must be saved unapproved. Remove --approved, then review it in Publishing → UGC.", { exitCode: 1 });
2142
+ }
2143
+ body.ugc_participation_id = ugcParticipationId;
2144
+ }
1776
2145
  if (content)
1777
2146
  body.content = content;
1778
2147
  return body;
@@ -1857,6 +2226,12 @@ function buildPublishingContent(options) {
1857
2226
  }
1858
2227
  function resolvePublishingCreateStatus(options) {
1859
2228
  const status = readOption(options.status);
2229
+ if (readOption(options.ugcParticipationId)) {
2230
+ if (status && status !== "draft") {
2231
+ throw new OxygenError("ugc_draft_required", "--ugc-participation-id only creates an unapproved draft; omit --status or pass --status draft.", { exitCode: 1 });
2232
+ }
2233
+ return "draft";
2234
+ }
1860
2235
  if (options.draft === true && status && status !== "draft") {
1861
2236
  throw new OxygenError("conflicting_flags", "Pass either --draft or --status, not both.", { exitCode: 1 });
1862
2237
  }
@@ -2690,7 +3065,7 @@ export function createProgram() {
2690
3065
  const directoryDocsUrl = `${defaultApiUrl()}/docs/agencies`;
2691
3066
  program
2692
3067
  .name(binaryName)
2693
- .description("Revenue infrastructure for B2B startups — agent-operated GTM: tables, enrichment, sequences, workflows, CRM, knowledge. MCP + CLI first; every state-changing action returns a web_url deep-link.")
3068
+ .description("Revenue infrastructure for B2B startups — agent-operated GTM: tables, enrichment, sequences, workflows, CRM, knowledge. Everything here also runs in the web app and over MCP; every state-changing action returns a web_url deep-link.")
2694
3069
  .version(OXYGEN_VERSION)
2695
3070
  .option("--profile <name>", "Use a stored CLI profile for this command.")
2696
3071
  .option("--org <organization>", "Use an organization id, Clerk org id, or slug for this command.");
@@ -3091,7 +3466,7 @@ export function createProgram() {
3091
3466
  .description("Open and track Plain support conversations for the active OXYGEN organization. In the app, the support chat is the widget in the bottom-right corner of every page. Retry is the only recovery control; it returns after each failed connection attempt and never creates a Thread. `support chat` starts or continues a zero-credit conversation without making you write a ticket title. Guide: https://oxygen-agent.com/docs/surfaces/support.")
3092
3467
  .addCommand(new Command("chat")
3093
3468
  .description("Start a Plain Chat conversation from one message, or pass an existing Thread ID to continue it. New conversations derive Plain's internal title from the message; you never have to file each message as a separate titled ticket.")
3094
- .argument("[threadId]", "Existing Plain Thread ID (th_...) to continue. Omit to start a new conversation.")
3469
+ .argument("[threadId]", "Existing Plain Thread ID (th_...) or Thread ref (T-<n>) to continue. Omit to start a new conversation.")
3095
3470
  .requiredOption("-m, --message <message>", "Your message to OXYGEN support.")
3096
3471
  .option("--severity <severity>", "New conversation only: low | normal | high. Defaults to normal.")
3097
3472
  .option("--category <category>", "New conversation only: question, bug, feature_request, billing, security_data, configuration, agency_directory, or other. Defaults to question.")
@@ -3152,14 +3527,14 @@ Examples:
3152
3527
  }))
3153
3528
  .addCommand(new Command("get")
3154
3529
  .description("Show one Plain support Thread and its customer-visible timeline.")
3155
- .argument("<ticketId>", "Plain Thread ID (th_...) or migrated legacy ticket UUID.")
3530
+ .argument("<ticketId>", "Plain Thread ID (th_...) or Thread ref (T-<n>) — both shown by `oxygen support list` as `id` and `ref` — or a migrated legacy ticket UUID.")
3156
3531
  .option("--json", "Print a JSON envelope.")
3157
3532
  .action(async (ticketId, options) => {
3158
3533
  await handleAsyncAction("support ticket get", options, () => requestOxygen(`/api/cli/support/tickets/${encodeURIComponent(ticketId)}`));
3159
3534
  }))
3160
3535
  .addCommand(new Command("reply")
3161
3536
  .description("Reply to a Plain Chat thread; migrated or native-channel history continues in one linked Chat thread.")
3162
- .argument("<ticketId>", "Plain Thread ID (th_...) or migrated legacy ticket UUID.")
3537
+ .argument("<ticketId>", "Plain Thread ID (th_...) or Thread ref (T-<n>) — both shown by `oxygen support list` as `id` and `ref` — or a migrated legacy ticket UUID.")
3163
3538
  .requiredOption("--body <body>", "Your reply.")
3164
3539
  .option("--json", "Print a JSON envelope.")
3165
3540
  .action(async (ticketId, options) => {
@@ -3190,14 +3565,14 @@ Examples:
3190
3565
  }))
3191
3566
  .addCommand(new Command("get")
3192
3567
  .description("Show one live Plain Thread, its routing metadata, and customer-visible messages (staff only).")
3193
- .argument("<ticketId>", "Plain Thread ID (th_...).")
3568
+ .argument("<ticketId>", "Plain Thread ID (th_...) or Thread ref (T-<n>), as shown by `oxygen support admin list`.")
3194
3569
  .option("--json", "Print a JSON envelope.")
3195
3570
  .action(async (ticketId, options) => {
3196
3571
  await handleAsyncAction("support admin get", options, () => requestOxygen(`/api/cli/admin/support/tickets/${encodeURIComponent(ticketId)}`));
3197
3572
  }))
3198
3573
  .addCommand(new Command("open")
3199
3574
  .description("Return the exact Plain Inbox URL for one live Thread (staff only).")
3200
- .argument("<ticketId>", "Plain Thread ID (th_...).")
3575
+ .argument("<ticketId>", "Plain Thread ID (th_...) or Thread ref (T-<n>), as shown by `oxygen support admin list`.")
3201
3576
  .option("--json", "Print a JSON envelope.")
3202
3577
  .action(async (ticketId, options) => {
3203
3578
  await handleAsyncAction("support admin open", options, () => requestOxygen(`/api/cli/admin/support/tickets/${encodeURIComponent(ticketId)}`)
@@ -3205,7 +3580,7 @@ Examples:
3205
3580
  }))
3206
3581
  .addCommand(new Command("claim")
3207
3582
  .description("Claim a live Plain Thread and move it to In progress without overwriting another assignee (staff only).")
3208
- .argument("<ticketId>", "Plain Thread ID (th_...).")
3583
+ .argument("<ticketId>", "Plain Thread ID (th_...) or Thread ref (T-<n>), as shown by `oxygen support admin list`.")
3209
3584
  .option("--agent", "Claim as the authenticated Oxygen Support machine user instead of the signed-in human.")
3210
3585
  .option("--json", "Print a JSON envelope.")
3211
3586
  .action(async (ticketId, options) => {
@@ -3216,7 +3591,7 @@ Examples:
3216
3591
  }))
3217
3592
  .addCommand(new Command("start")
3218
3593
  .description("Set a live Plain Thread to Plain TODO with the In progress detail when OXYGEN work remains after a reply, preserving its assignee (staff only).")
3219
- .argument("<ticketId>", "Plain Thread ID (th_...).")
3594
+ .argument("<ticketId>", "Plain Thread ID (th_...) or Thread ref (T-<n>), as shown by `oxygen support admin list`.")
3220
3595
  .requiredOption("--agent", "Required safety declaration: reconcile this Thread's status as the Oxygen Support machine user.")
3221
3596
  .requiredOption("--confirm-message <messageId>", "Apply only while this is still the exact latest customer-visible conversation-head message ID. From support admin get --json, use the id of the data.messages[] entry with the greatest created_at.")
3222
3597
  .option("--json", "Print a JSON envelope.")
@@ -3243,7 +3618,7 @@ The final readback must show ticket.status triaging, plain_status TODO, the In p
3243
3618
  }))
3244
3619
  .addCommand(new Command("priority")
3245
3620
  .description("Set the native Plain priority for a live Thread (staff only).")
3246
- .argument("<ticketId>", "Plain Thread ID (th_...).")
3621
+ .argument("<ticketId>", "Plain Thread ID (th_...) or Thread ref (T-<n>), as shown by `oxygen support admin list`.")
3247
3622
  .requiredOption("--priority <priority>", "urgent | high | normal | low")
3248
3623
  .option("--json", "Print a JSON envelope.")
3249
3624
  .action(async (ticketId, options) => {
@@ -3253,7 +3628,7 @@ The final readback must show ticket.status triaging, plain_status TODO, the In p
3253
3628
  }))
3254
3629
  .addCommand(new Command("snooze")
3255
3630
  .description("Park a live Thread until a named time, so work blocked on a release, a provider, or a scheduled retry stops reading as unanswered (staff only).")
3256
- .argument("<ticketId>", "Plain Thread ID (th_...).")
3631
+ .argument("<ticketId>", "Plain Thread ID (th_...) or Thread ref (T-<n>), as shown by `oxygen support admin list`.")
3257
3632
  .option("--days <n>", "Snooze for this many days.")
3258
3633
  .option("--hours <n>", "Snooze for this many hours. Combined with --days when both are given.")
3259
3634
  .requiredOption("--confirm-message <messageId>", "Confirm the exact latest customer-visible message ID. A Thread whose customer just wrote must be answered, not snoozed past.")
@@ -3266,14 +3641,14 @@ The final readback must show ticket.status triaging, plain_status TODO, the In p
3266
3641
  }))
3267
3642
  .addCommand(new Command("todo")
3268
3643
  .description("Return a snoozed Thread to the queue when its blocker clears (staff only). Never reopens a Done Thread: Plain does that on real customer activity.")
3269
- .argument("<ticketId>", "Plain Thread ID (th_...).")
3644
+ .argument("<ticketId>", "Plain Thread ID (th_...) or Thread ref (T-<n>), as shown by `oxygen support admin list`.")
3270
3645
  .option("--json", "Print a JSON envelope.")
3271
3646
  .action(async (ticketId, options) => {
3272
3647
  await handleSupportAdminUpdateRequest("todo", ticketId, options, {});
3273
3648
  }))
3274
3649
  .addCommand(new Command("assign")
3275
3650
  .description("Hand a live Thread to a named human, for escalation past an agent's authority or judgment boundary (staff only).")
3276
- .argument("<ticketId>", "Plain Thread ID (th_...).")
3651
+ .argument("<ticketId>", "Plain Thread ID (th_...) or Thread ref (T-<n>), as shown by `oxygen support admin list`.")
3277
3652
  .requiredOption("--user <email>", "The staff email to assign the Thread to.")
3278
3653
  .option("--allow-takeover", "Required to move a Thread that already has an owner. Without it, an owned Thread is refused rather than silently reassigned.")
3279
3654
  .option("--json", "Print a JSON envelope.")
@@ -3285,7 +3660,7 @@ The final readback must show ticket.status triaging, plain_status TODO, the In p
3285
3660
  }))
3286
3661
  .addCommand(new Command("field-set")
3287
3662
  .description("Record OXYGEN workflow state on a live Thread as structured, filterable Plain data — which version and commit carry a fix, and the PR that shipped it (staff only).")
3288
- .argument("<ticketId>", "Plain Thread ID (th_...).")
3663
+ .argument("<ticketId>", "Plain Thread ID (th_...) or Thread ref (T-<n>), as shown by `oxygen support admin list`.")
3289
3664
  .requiredOption("--field <key>", "oxygen_fix_version | oxygen_fix_sha | oxygen_fix_pr. Customer-intake fields are read-only evidence and cannot be written here.")
3290
3665
  .requiredOption("--value <value>", "The value to record.")
3291
3666
  .option("--json", "Print a JSON envelope.")
@@ -3297,7 +3672,7 @@ The final readback must show ticket.status triaging, plain_status TODO, the In p
3297
3672
  }))
3298
3673
  .addCommand(new Command("label-add")
3299
3674
  .description("Add an active Plain label by external ID (staff only).")
3300
- .argument("<ticketId>", "Plain Thread ID (th_...).")
3675
+ .argument("<ticketId>", "Plain Thread ID (th_...) or Thread ref (T-<n>), as shown by `oxygen support admin list`.")
3301
3676
  .requiredOption("--label <externalId>", "Plain label external ID, for example oxygen_request_bug.")
3302
3677
  .option("--json", "Print a JSON envelope.")
3303
3678
  .action(async (ticketId, options) => {
@@ -3307,7 +3682,7 @@ The final readback must show ticket.status triaging, plain_status TODO, the In p
3307
3682
  }))
3308
3683
  .addCommand(new Command("label-remove")
3309
3684
  .description("Remove a Plain label by external ID (staff only).")
3310
- .argument("<ticketId>", "Plain Thread ID (th_...).")
3685
+ .argument("<ticketId>", "Plain Thread ID (th_...) or Thread ref (T-<n>), as shown by `oxygen support admin list`.")
3311
3686
  .requiredOption("--label <externalId>", "Plain label external ID.")
3312
3687
  .option("--json", "Print a JSON envelope.")
3313
3688
  .action(async (ticketId, options) => {
@@ -3317,7 +3692,7 @@ The final readback must show ticket.status triaging, plain_status TODO, the In p
3317
3692
  }))
3318
3693
  .addCommand(new Command("note")
3319
3694
  .description("Add a team-only internal note to a live Plain Thread (staff only).")
3320
- .argument("<ticketId>", "Plain Thread ID (th_...).")
3695
+ .argument("<ticketId>", "Plain Thread ID (th_...) or Thread ref (T-<n>), as shown by `oxygen support admin list`.")
3321
3696
  .requiredOption("--body <body>", "Internal note body.")
3322
3697
  .option("--json", "Print a JSON envelope.")
3323
3698
  .action(async (ticketId, options) => {
@@ -3327,7 +3702,7 @@ The final readback must show ticket.status triaging, plain_status TODO, the In p
3327
3702
  }))
3328
3703
  .addCommand(new Command("draft")
3329
3704
  .description("Stage a reply as a team-only Plain note; this never sends to the customer (staff and agents).")
3330
- .argument("<ticketId>", "Plain Thread ID (th_...).")
3705
+ .argument("<ticketId>", "Plain Thread ID (th_...) or Thread ref (T-<n>), as shown by `oxygen support admin list`.")
3331
3706
  .requiredOption("--body <body>", "Proposed customer reply.")
3332
3707
  .option("--json", "Print a JSON envelope.")
3333
3708
  .action(async (ticketId, options) => {
@@ -3337,7 +3712,7 @@ The final readback must show ticket.status triaging, plain_status TODO, the In p
3337
3712
  }))
3338
3713
  .addCommand(new Command("reply")
3339
3714
  .description("Preview or send a guarded public reply on the Thread's native Plain channel as Oxygen Support (staff only). CLI delivery is agent-only; named humans use Plain Inbox or Plain MCP.")
3340
- .argument("<ticketId>", "Plain Thread ID (th_...).")
3715
+ .argument("<ticketId>", "Plain Thread ID (th_...) or Thread ref (T-<n>), as shown by `oxygen support admin list`.")
3341
3716
  .requiredOption("--body <body>", "Customer-visible reply body.")
3342
3717
  .requiredOption("--agent", "Required safety declaration: reply as the authenticated Oxygen Support machine user after it owns the Thread.")
3343
3718
  .option("--confirm-ref <ref>", "Send only when this exactly matches the previewed Plain ref (for example T-10). Omit to preview without sending.")
@@ -3364,7 +3739,7 @@ The final readback must show ticket.status triaging, plain_status TODO, the In p
3364
3739
  }))
3365
3740
  .addCommand(new Command("done")
3366
3741
  .description("Preview or mark a Plain Thread Done after the exact verified agent reply; new customer activity reopens it to Todo (staff only). CLI completion is agent-only.")
3367
- .argument("<ticketId>", "Plain Thread ID (th_...).")
3742
+ .argument("<ticketId>", "Plain Thread ID (th_...) or Thread ref (T-<n>), as shown by `oxygen support admin list`.")
3368
3743
  .requiredOption("--agent", "Required safety declaration: mark Done as the authenticated Oxygen Support machine user while it still owns the Thread.")
3369
3744
  .requiredOption("--reply-token <token>", "Use the exact agent_done_token returned by the preceding live `support admin reply --agent`; previews and errors never mint or reveal one.")
3370
3745
  .option("--confirm-ref <ref>", "Complete only when this exactly matches the previewed Plain ref.")
@@ -3802,7 +4177,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
3802
4177
  }));
3803
4178
  program
3804
4179
  .command("publishing")
3805
- .description("Social publishing, performance, and public-comment operations. Start Community triage with `oxygen publishing comments list --view unanswered --json`.")
4180
+ .description("Social publishing, performance, and public-comment operations. Start Community triage with `oxygen publishing comments list --view unanswered --json`. Docs: https://oxygen-agent.com/docs/execution/publishing")
3806
4181
  .addCommand(new Command("mentions")
3807
4182
  .description("Resolve LinkedIn identities for a publish-faithful post preview.")
3808
4183
  .addCommand(new Command("resolve")
@@ -3839,21 +4214,22 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
3839
4214
  await handleAsyncAction("publishing posts list", options, () => requestOxygen(buildPublishingPostsListPath(options)));
3840
4215
  }))
3841
4216
  .addCommand(new Command("create")
3842
- .description("Create a scheduled post. Publishing requires approval before the worker can send.")
3843
- .requiredOption("--publish-at <iso>", "ISO date-time when the post should publish.")
3844
- .option("--provider <provider>", "Publishing provider: linkedin, x, instagram, tiktok, facebook, or youtube. Defaults to linkedin.")
4217
+ .description("Create a personal Publishing post. Use --ugc-participation-id to save it into one program as an unapproved draft; otherwise it defaults to scheduled and publishes only after approval.")
4218
+ .requiredOption("--publish-at <iso>", "ISO date-time when the post should publish, e.g. 2026-09-15T10:00:00+02:00. A bare local time with no offset is stored as UTC, never converted into --timezone, so pass an offset or Z to fix the instant; --timezone only changes how it displays.")
4219
+ .option("--provider <provider>", "Publishing provider: linkedin, x, instagram, tiktok, facebook, or youtube. Defaults to linkedin. LinkedIn publishes through --sender; every other provider publishes through a connected account (--provider-connection), which the worker needs before it can deliver.")
3845
4220
  .option("--channel <channel>", "Publishing channel override. Defaults to provider.")
3846
4221
  .option("--sender <sender_account_id>", "LinkedIn sender account id. Required for LinkedIn before the worker can publish.")
3847
- .option("--provider-connection <connection_id>", "Oxygen integration connection id for Composio-backed providers.")
4222
+ .option("--provider-connection <connection_id>", "Oxygen integration connection id for Composio-backed providers. Find it with `integrations list` (each integration's connection); create one with `integrations connect <provider>` (an OAuth redirect URL, or Settings > Connections on the web).")
3848
4223
  .option("--title <title>", "Internal title for the queue.")
3849
4224
  .option("--text <text>", "Post text. For LinkedIn mentions, use @<public-identifier> directly.")
3850
4225
  .option("--text-file <path>", "Read post text from a local file.")
3851
4226
  .option("--content-json <json>", "Structured content. Use media_asset_ids for Oxygen uploads. LinkedIn attachments require [{content:<base64>,content_type:<MIME>,filename:<name>}]; content.mentions is rejected, so put verified @<public-identifier> values in --text. Composio accepts provider_arguments or composio.arguments.")
3852
4227
  .option("--composio-action <slug>", "Override the Composio action slug for this scheduled post.")
3853
- .option("--timezone <tz>", "Display timezone for the scheduled date. Defaults to UTC.")
3854
- .option("--status <status>", "draft or scheduled. Defaults to scheduled.")
4228
+ .option("--timezone <tz>", "Display timezone for the scheduled date. Defaults to the LinkedIn sender's own timezone when --sender is set, else UTC.")
4229
+ .option("--status <status>", "draft or scheduled. Defaults to scheduled. A draft sits outside the queue until `publishing posts approve` schedules and arms it in one step; scheduled is queued for its publish time and still waits for approval.")
3855
4230
  .option("--draft", "Create as a draft instead of scheduled.")
3856
- .option("--approved", "Mark the post approved for the scheduler.")
4231
+ .option("--ugc-participation-id <id>", "Also enroll this personal post in one active creator participation. It is always saved as an unapproved draft; omit --approved.")
4232
+ .option("--approved", "Mark an ordinary post approved for the scheduler. Invalid with --ugc-participation-id.")
3857
4233
  .option("--json", "Print a JSON envelope.")
3858
4234
  .action(async (options) => {
3859
4235
  await handleAsyncAction("publishing posts create", options, () => requestOxygen("/api/cli/publishing/posts", {
@@ -5233,7 +5609,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5233
5609
  .option("--create <name>", "Create a new table with columns inferred from the file before importing.")
5234
5610
  .option("--project <project>", "Project id or slug for --create. Defaults to General.")
5235
5611
  .option("--upsert-key <key>", "Column key used to upsert instead of inserting.")
5236
- .option("--batch-size <n>", "Requested rows per import chunk, not a limit on the file. Defaults to 500; values up to 5000 are accepted, but Oxygen splits writes to at most 500 rows (and smaller for wide rows) so they fit request and worker lease budgets.")
5612
+ .option("--batch-size <n>", `Requested rows per import chunk, not a limit on the file. Defaults to 500; values up to 5000 are accepted, but Oxygen splits writes to at most 500 rows (and smaller for wide rows, about ${formatJsonBodyLimit()} of JSON per request) so they fit request and worker lease budgets.`)
5237
5613
  .option("--background", "Enqueue durable import chunks for the background worker.")
5238
5614
  .option("--sync", `Force foreground import even for files above ${LARGE_IMPORT_BACKGROUND_ROW_THRESHOLD} rows. That threshold picks foreground vs background; it does not cap how many rows a file may hold.`)
5239
5615
  .option("--max-concurrency <n>", "Maximum concurrent import chunks for background mode. Defaults to 5.")
@@ -5242,7 +5618,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5242
5618
  await handleAsyncAction("tables import", options, () => importRows(table, options));
5243
5619
  }))
5244
5620
  .addCommand(new Command("export")
5245
- .description("Export workspace table rows as JSON, JSONL, CSV, or a human-readable table.")
5621
+ .description("Export workspace table rows as JSON, JSONL, CSV, or a human-readable table. Rows only, capped at 1000; to copy a whole table including its columns and every row, use `tables export-bundle`.")
5246
5622
  .argument("<table>", "Table id or slug.")
5247
5623
  .option("--format <format>", "json, jsonl, csv, or table. Defaults to json. Use table for a typed, human-readable rendering with thousands grouping.")
5248
5624
  .option("--output <path>", "Write export content to a file.")
@@ -5252,16 +5628,16 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5252
5628
  await handleAsyncAction("tables export", options, () => exportRows(table, options));
5253
5629
  }))
5254
5630
  .addCommand(new Command("export-bundle")
5255
- .description("Export a workspace table as a portable bundle (schema + every row) for cross-environment / cross-org copies.")
5631
+ .description("Export a workspace table as a portable bundle (schema + every row) for cross-environment / cross-org copies. Effect: reads the table and writes only a local file — 0 credits, nothing changes in the workspace. With --output the bundle streams to the file one row per line, so a table of any size exports; without it the whole bundle prints as one JSON document, which only fits a small table.")
5256
5632
  .argument("<table>", "Table id or slug to export.")
5257
- .option("--output <path>", "Write the bundle JSON to a file. Defaults to stdout.")
5633
+ .option("--output <path>", "Write the bundle to this file as line-delimited JSON: a header line with the table and columns, then one {\"row\":{…}} line per row carrying every column value plus the source _row_id, _created_at and _updated_at (import-bundle drops those three), then a {\"totals\":{…}} trailer. Streams, so memory stays flat at any row count. Defaults to stdout as a single JSON document.")
5258
5634
  .option("--page-size <n>", "Rows per cursor-paginated request. Defaults to 500; hard cap is 1000.")
5259
5635
  .option("--json", "Print a JSON envelope (omit row payload — use --output to keep the rows).")
5260
5636
  .action(async (table, options) => {
5261
5637
  await handleAsyncAction("tables export-bundle", options, () => exportTableBundle(table, options));
5262
5638
  }))
5263
5639
  .addCommand(new Command("import-bundle")
5264
- .description("Recreate a workspace table from an export-bundle file in this org. Restores columns (incl. enrichment/tool definitions) and inserts every row. Pass --key to make the import idempotent (re-runnable), and --into to resume a failed import into the table it already created.")
5640
+ .description("Recreate a workspace table from an export-bundle file in this org. Effect: creates one new table in this workspace and inserts every bundled row — 0 credits, no provider calls, nothing runs. Reads both bundle formats and streams a line-delimited bundle row by row, so a multi-GB file imports without loading into memory. Restores columns (incl. enrichment/tool definitions). Pass --key to make the import idempotent (re-runnable), and --into to resume a failed import into the table it already created.")
5265
5641
  .requiredOption("--file <path>", "Bundle JSON file produced by `tables export-bundle`.")
5266
5642
  .option("--name <name>", "Override the table display name. Defaults to the bundle's table name. Ignored with --into.")
5267
5643
  .option("--project <project>", "Project id or slug for the new table. Defaults to General. Ignored with --into.")
@@ -5270,7 +5646,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5270
5646
  .option("--batch-size <n>", "Requested rows per write group. Defaults to 500; values up to 5000 are accepted and split into safe writes of at most 500 rows.")
5271
5647
  .option("--json", "Print a JSON envelope.")
5272
5648
  .action(async (options) => {
5273
- await handleAsyncAction("tables import-bundle", options, () => importTableBundle(options));
5649
+ await handleAsyncAction("tables import-bundle", options, () => importTableBundle(options, binaryName));
5274
5650
  }))
5275
5651
  .addCommand(new Command("list")
5276
5652
  .description("List workspace tables in the current tenant database.")
@@ -5312,7 +5688,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5312
5688
  .option("--offset <n>", "Skip this many rows and start there — jump straight to a row without paging to it. Mutually exclusive with --cursor.")
5313
5689
  .option("--fields <columns>", "Comma-separated column keys or ids to include.")
5314
5690
  .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.")
5315
- .option("--filter-tree-json <json>", "Airtable-style filter group, e.g. '{\"type\":\"group\",\"conjunction\":\"and\",\"children\":[{\"type\":\"leaf\",\"columnKey\":\"stage\",\"operator\":\"is\",\"value\":\"won\"}]}'. Mutually exclusive with --filter-json.")
5691
+ .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.")
5316
5692
  .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.")
5317
5693
  .option("--sort-json <json>", "Ordered sort rules, e.g. '[{\"columnKey\":\"_created_at\",\"direction\":\"desc\"}]'. Earlier rules dominate. Mutually exclusive with --filter-json.")
5318
5694
  .option("--no-system-fields", "Omit _row_id, _created_at, and _updated_at from returned rows (included by default).")
@@ -5324,14 +5700,14 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5324
5700
  const filterTree = readJsonObjectOption(options.filterTreeJson);
5325
5701
  const formulaValues = readFormulaValuesOption(options.formulaValues);
5326
5702
  const sorts = readSortJsonOption(options.sortJson);
5327
- const offset = readPositiveInt(options.offset);
5703
+ const offset = readNonNegativeInt(options.offset);
5328
5704
  if (filters && (filterTree || sorts)) {
5329
5705
  throw new OxygenError("invalid_filter", "Pass either --filter-json (legacy) or --filter-tree-json/--sort-json, not both.", { exitCode: 1 });
5330
5706
  }
5331
5707
  if (formulaValues && !filters && !filterTree) {
5332
5708
  throw new OxygenError("invalid_filter", "--formula-values requires --filter-json or --filter-tree-json.", { exitCode: 1 });
5333
5709
  }
5334
- if (readOption(options.cursor) && offset) {
5710
+ if (readOption(options.cursor) && offset !== undefined) {
5335
5711
  throw new OxygenError("invalid_request", "Pass either --cursor or --offset, not both.", { exitCode: 1 });
5336
5712
  }
5337
5713
  return requestOxygen("/api/cli/tables/query", {
@@ -5340,7 +5716,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5340
5716
  table,
5341
5717
  ...(limit ? { limit } : {}),
5342
5718
  ...(readOption(options.cursor) ? { cursor: readOption(options.cursor) } : {}),
5343
- ...(offset ? { offset } : {}),
5719
+ ...(offset !== undefined ? { offset } : {}),
5344
5720
  ...(readOption(options.fields) ? { fields: readCsvOption(options.fields) } : {}),
5345
5721
  ...(filters ? { filters } : {}),
5346
5722
  ...(filterTree ? { filterTree } : {}),
@@ -5358,7 +5734,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5358
5734
  .option("--fields <columns>", "Comma-separated column keys or ids to include.")
5359
5735
  .option("--include-system-fields", "Include _row_id, _created_at, and _updated_at in preview rows.")
5360
5736
  .option("--include-cell-states", "Include per-cell run state in the preview response.")
5361
- .option("--summary-only", "Return table metadata and stats without row JSON.")
5737
+ .option("--summary-only", "Return per-column fill stats plus table metadata, without row JSON. Stats cover the whole table independent of --limit: exact up to 20,000 rows, then a 2,000-row sample labelled summary.statsSampled; rowCount stays exact. Higher fidelity than `tables describe --stats`, which samples at 100. fillRate counts non-null cells, nonEmptyRate also excludes blank strings. 0 credits.")
5362
5738
  .option("--json", "Print a JSON envelope.")
5363
5739
  .action(async (table, options) => {
5364
5740
  await handleAsyncAction("tables preview", options, () => {
@@ -5377,9 +5753,10 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5377
5753
  });
5378
5754
  }))
5379
5755
  .addCommand(new Command("describe")
5380
- .description("Describe a workspace table and its columns.")
5756
+ .description("Describe a workspace table and its columns. Add --stats for row count and per-column fill rates (0 credits).")
5381
5757
  .argument("<table>", "Table id or slug.")
5382
5758
  .option("--include-archived", "Include archived columns.")
5759
+ .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.")
5383
5760
  .option("--json", "Print a JSON envelope.")
5384
5761
  .action(async (table, options) => {
5385
5762
  await handleAsyncAction("tables describe", options, () => requestOxygen("/api/cli/tables/describe", {
@@ -5387,6 +5764,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5387
5764
  body: {
5388
5765
  table,
5389
5766
  ...(options.includeArchived ? { include_archived: true } : {}),
5767
+ ...(options.stats ? { include_stats: true } : {}),
5390
5768
  },
5391
5769
  }));
5392
5770
  }))
@@ -6249,7 +6627,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6249
6627
  .description("Workspace-level GTM context commands.")
6250
6628
  .addCommand(new Command("resolve")
6251
6629
  .description("Resolve task-scoped workspace GTM context with readiness and revision provenance.")
6252
- .option("--purpose <purpose>", "general, lead_sourcing, qualification, outbound_copy, or workflow_design.")
6630
+ .option("--purpose <purpose>", "onboarding, general, lead_sourcing, qualification, outbound_copy, or workflow_design.")
6253
6631
  .option("--asset-type <csv>", "Comma-separated context asset types to include.")
6254
6632
  .option("--asset-status <status>", "draft, active, archived, or all. Defaults to active.")
6255
6633
  .option("--tags <csv>", "Comma-separated asset tags that must be present.")
@@ -6370,9 +6748,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6370
6748
  // command, not on `status` — the same collision the `commands` group above
6371
6749
  // handles. The named reference lets `status` read the parent's flag back.
6372
6750
  const knowledgeBootstrapCommand = new Command("bootstrap")
6373
- .description(`Research THIS workspace's own company from its domain and fill the typed company profile plus one cited wiki page — the empty-Knowledge-Graph fix, so a founder never has to type their own positioning back at us. Two authorization paths, and they are NOT the same: running this command manually is authorized by --live (plus --max-credits), like every other paid Oxygen action; the identical pass that runs ONCE automatically after signup is authorized by the consent recorded at signup instead, and is capped (<= ${KNOWLEDGE_BOOTSTRAP_MAX_CREDITS} credits), journalled (a marker, a system run, and immutable page sources), kill-switched fleet-wide, and reversible. A bare call previews: it resolves the domain, plans the reads, estimates the cost, and spends nothing. Once per workspace ever — any existing marker (completed, partial, skipped, failed) blocks a second pass until --force clears it.`)
6374
- .option("--domain <domain>", "Company apex domain to research. Defaults to the workspace domain resolved at signup from the org's Clerk metadata or the creator's email; a personal-email signup has none, and a preview says so instead of guessing.")
6375
- .option("--linkedin <url>", "Company LinkedIn URL, as a second identity signal alongside the domain.")
6751
+ .description(`Research this workspace's company through direct LinkedIn company enrichment and website reads. Save company facts, a cited wiki note, and separate inferred ICP, offer, and opportunity hypotheses. A bare call previews for free; manual execution is customer-funded and requires --live plus --max-credits (hard cap ${KNOWLEDGE_BOOTSTRAP_MAX_CREDITS}). The separate automatic signup pass is platform-funded, runs once under the consent recorded when the workspace was created (its required company website), and may also research the actual creator. Check bootstrap status before spending; an existing marker blocks a repeat unless --force re-arms it.`)
6752
+ .option("--domain <domain>", "Company domain to research. Defaults to the recorded workspace domain; if none is available, the preview asks for one.")
6753
+ .option("--linkedin <url>", "Optional company LinkedIn URL to use for the direct lookup. The returned company website must still match the domain.")
6376
6754
  .option("--live", "Authorize this manual run to spend and write. Requires --max-credits. Without it the command previews and nothing is billed.")
6377
6755
  .option("--approved", "Same as --live. Accepted because the global help footer names --approved as the way to authorize a credit-spending command.")
6378
6756
  .option("--max-credits <credits>", `Hard credit ceiling for the whole pass. Required with --live, and clamped server-side to ${KNOWLEDGE_BOOTSTRAP_MAX_CREDITS}.`)
@@ -6698,7 +7076,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6698
7076
  })))
6699
7077
  .addCommand(new Command("resolve")
6700
7078
  .description("Resolve task-scoped workspace GTM context with readiness and revision provenance.")
6701
- .option("--purpose <purpose>", "general, lead_sourcing, qualification, outbound_copy, or workflow_design.")
7079
+ .option("--purpose <purpose>", "onboarding, general, lead_sourcing, qualification, outbound_copy, or workflow_design.")
6702
7080
  .option("--asset-type <csv>", "Comma-separated context asset types to include.")
6703
7081
  .option("--asset-status <status>", "draft, active, archived, or all. Defaults to active.")
6704
7082
  .option("--tags <csv>", "Comma-separated asset tags that must be present.")
@@ -7452,7 +7830,15 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7452
7830
  });
7453
7831
  }))
7454
7832
  .addCommand(new Command("show")
7455
- .description("Show one recipe: the full playbook body plus prerequisites, credits, and approval gates.")
7833
+ // `get` is what a naive agent reaches for first, because `workflows`,
7834
+ // `knowledge page` and `table-ingestions` all use it and only `recipes`
7835
+ // uses `show` (while `sequences` is the mirror image and rejects
7836
+ // `show`). A blind user eval on 2026-09-13 burned two steps on
7837
+ // `recipes get inbound-led-outbound` -> "unknown command 'get'".
7838
+ // The catalog is the front door of the whole kit; it should not punish
7839
+ // the guess the rest of the CLI teaches.
7840
+ .alias("get")
7841
+ .description("Show one recipe: its kit, the install status of each stage here, prerequisites, credits, approval gates, and the playbook body. Alias: `get`.")
7456
7842
  .argument("<slug>", "Recipe slug, e.g. outbound-pilot-50.")
7457
7843
  .option("--json", "Print a JSON envelope.")
7458
7844
  .action(async (slug, options) => {
@@ -7480,6 +7866,40 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7480
7866
  body.force = true;
7481
7867
  return requestOxygen("/api/cli/recipes/install", { method: "POST", body });
7482
7868
  });
7869
+ }))
7870
+ .addCommand(new Command("apply")
7871
+ .description("Install a recipe's kit: every Blueprint stage in order under ONE approval — 0 credits, no provider calls, no external writes, every installed Workflow disabled until you arm it with its own credit cap. A stage whose Workflow is already active is left untouched. `--dry-run` preflights every stage into one combined forecast and writes nothing.")
7872
+ .argument("<slug>", "Recipe slug, e.g. inbound-led-outbound.")
7873
+ .option("--input-json <json>", "Stage inputs as a JSON object keyed by blueprint id, e.g. '{\"linkedin-profile-engager-monitor\":{\"profiles\":[\"https://www.linkedin.com/in/example\"],\"max_credits\":2200}}'.")
7874
+ .option("--table-ref <blueprint.ref=table...>", "Bind a stage's table ref to an existing table id or slug, e.g. --table-ref linkedin-engager-tier-router.engaged_people=linkedin-engaged-people (repeatable).", collectMultiple, [])
7875
+ .option("--dry-run", "Preflight every stage and print the combined forecast; installs nothing.")
7876
+ .option("--json", "Print a JSON envelope.")
7877
+ .action(async (slug, options) => {
7878
+ const command = options.dryRun ? "recipes preflight" : "recipes apply";
7879
+ await handleAsyncAction(command, options, () => {
7880
+ const body = { slug };
7881
+ const inputJson = readOption(options.inputJson);
7882
+ if (inputJson)
7883
+ body.inputs = parseJsonValue(inputJson, "--input-json");
7884
+ const tableRefs = {};
7885
+ for (const entry of options.tableRef ?? []) {
7886
+ const eq = entry.indexOf("=");
7887
+ const dot = entry.indexOf(".");
7888
+ if (eq <= 0 || dot <= 0 || dot > eq) {
7889
+ throw new Error(`--table-ref expects <blueprint>.<ref>=<table id or slug>, got "${entry}"`);
7890
+ }
7891
+ const blueprint = entry.slice(0, dot);
7892
+ const ref = entry.slice(dot + 1, eq);
7893
+ const table = entry.slice(eq + 1);
7894
+ tableRefs[blueprint] = { ...(tableRefs[blueprint] ?? {}), [ref]: table };
7895
+ }
7896
+ if (Object.keys(tableRefs).length > 0)
7897
+ body.table_refs = tableRefs;
7898
+ return requestOxygen(options.dryRun ? "/api/cli/recipes/preflight" : "/api/cli/recipes/apply", {
7899
+ method: "POST",
7900
+ body,
7901
+ });
7902
+ });
7483
7903
  }));
7484
7904
  program.addCommand(buildPromptTemplatesCommand("prompts", "Reusable prompt templates layered into AI columns at run time."));
7485
7905
  // The deprecated `templates` alias tree was removed at its registry sunset
@@ -7565,7 +7985,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7565
7985
  .option("--data-type <type>", "Column data type: text, numeric, boolean, jsonb, or timestamptz.")
7566
7986
  .option("--kind <kind>", "Column kind: manual, research, ai, formula, enrichment, tool, bind, or lookup. Defaults to manual. Use research for anything you would look up on the web. Enrichment columns always hold a jsonb cell, and --data-type is set for you \u2014 use --capability to seed a ready-to-run one. `lookup` reads a value out of another table; to LINK two tables row-to-row use `oxygen tables relate` instead \u2014 relation columns are two-sided and cannot be added here.")
7567
7987
  .option("--semantic-type <type>", "Optional semantic type such as company_domain.")
7568
- .option("--definition-json <json>", "Optional JSON object with column definition metadata.")
7988
+ .option("--definition-json <json>", "Optional JSON object with column definition metadata. Required for --kind formula as a flat object: {\"expression\":\"trim(company_name)\"}. Check the expression with `formulas validate` first.")
7569
7989
  .option("--prompt <text-or-file>", "AI or research column prompt, or a path to a prompt file — a value that resolves to a readable file is read as one, matching --prompt everywhere else in this CLI. On its own it sets kind=ai; pair it with --kind research to search the web per row instead. If the prompt names its output sections — a line reading 'Return the following sections:' followed by 'Score: ...', 'Reasoning: ...' — the column answers in exactly that shape and each section becomes a referenceable sub-column; otherwise it answers in plain text. Use --no-structured-output to keep it plain text either way. Reference other columns inline as {{column_key}} — no --input-mapping needed; unknown keys are rejected here instead of failing per row. Merges into --definition-json (the escape hatch for everything else); a `prompt` in both is an error.")
7570
7990
  .option("--research-query <template>", "Research columns: the per-row web search query, templated like the prompt (e.g. \"{{company_name}} pricing page\"). Omit to derive the query from the row's values and the prompt.")
7571
7991
  .option("--research-domains <csv>", "Research columns: comma-separated domains to search within, e.g. techcrunch.com,sec.gov. Omit to search the whole web.")
@@ -7788,7 +8208,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7788
8208
  .option("--all", "Run all rows. Requires --background, except with --dry-run, which previews the background run without queueing it.")
7789
8209
  .option("--filter-json <json>", "Row selector filter object or array for background runs. Do not combine with --all, --limit, or --row-id.")
7790
8210
  .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.")
7791
- .option("--force", "Run even when the target cell already has a value.")
8211
+ .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.")
7792
8212
  .option("--connection-id <connection_id>", "Optional provider integration connection id.")
7793
8213
  .option("--background", "Create a durable background run for a free deterministic column. Paid AI/tool/enrichment/custom-HTTP server runs are always backgrounded.")
7794
8214
  .option("--approved", "Confirm a paid durable run, or a bind create-mode run (onNoMatch=create), after inspecting a dry run or preview.")
@@ -8235,7 +8655,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8235
8655
  }));
8236
8656
  program
8237
8657
  .command("table-runs")
8238
- .description("Durable background table action run commands.")
8658
+ .description("Durable background table action run commands. `list --table <table> --status all` is a table's full run history, including Function runs launched from it; without --status, list shows only active runs.")
8239
8659
  .addCommand(new Command("create")
8240
8660
  .description("Create a durable background run for table tool-column actions. Large runs are accepted immediately and their rows are prepared in the background (execution_status 'planning'); follow with `table-runs wait <run_id>`.")
8241
8661
  .argument("<table>", "Table id or slug.")
@@ -8277,7 +8697,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8277
8697
  .addCommand(new Command("list")
8278
8698
  .description("List durable table action runs for one table.")
8279
8699
  .requiredOption("--table <table>", "Table id or slug.")
8280
- .option("--status <status>", "Filter by active, queued, running, paused, completed, completed_with_errors, failed, canceling, or canceled. Defaults to active.")
8700
+ .option("--status <status>", "Filter by all, active, queued, running, paused, completed, completed_with_errors, failed, canceling, or canceled. Defaults to active; pass all for the full history in one call, including Function runs launched from this table.")
8281
8701
  .option("--limit <n>", "Maximum runs to return. Defaults to 20.")
8282
8702
  .option("--format <format>", "Format run history as json, jsonl, csv, or table (same serializer as `tables export`). Omit to keep the default envelope output.")
8283
8703
  .option("--output <path>", "Write the formatted run history to a file instead of embedding it in the response.")
@@ -8286,14 +8706,14 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8286
8706
  await handleAsyncAction("table-runs list", options, () => listTableRuns(options));
8287
8707
  }))
8288
8708
  .addCommand(new Command("get")
8289
- .description("Get one durable table action run.")
8709
+ .description("Get one durable table action run. For a Function run, selection.rowIds are rows of the Function's execution table, not the calling table; list the calling table's rows it wrote with `tables query <caller> --filter-tree-json` on the Function column's \"action_run_id\" path.")
8290
8710
  .argument("<run_id>", "Table action run UUID or parent workspace run UUID.")
8291
8711
  .option("--json", "Print a JSON envelope.")
8292
8712
  .action(async (runId, options) => {
8293
8713
  await handleAsyncAction("table-runs get", options, () => requestOxygen(`/api/cli/table-action-runs/${encodeURIComponent(runId)}`));
8294
8714
  }))
8295
8715
  .addCommand(new Command("items")
8296
- .description("List row items for a durable table action run.")
8716
+ .description("List row items for a durable table action run. For a Function run, list the calling table's rows it wrote with `tables query <caller> --filter-tree-json` on the Function column's \"action_run_id\" path instead of joining items.")
8297
8717
  .argument("<run_id>", "Table action run UUID.")
8298
8718
  .option("--status <status>", "Filter by pending, leased, completed, failed, skipped, or canceled.")
8299
8719
  .option("--limit <n>", "Maximum items to return. Defaults to 100.")
@@ -8680,7 +9100,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8680
9100
  .addCommand(new Command("search")
8681
9101
  .description("Plan, dry-run, or queue provider-backed company search.")
8682
9102
  .addCommand(new Command("plan")
8683
- .description("Compile a company-search prompt into ordered provider routes without provider calls.")
9103
+ .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.")
8684
9104
  .argument("[prompt]", "Prompt text or @file (same as --prompt). The --prompt flag wins if both are given.")
8685
9105
  .option("--prompt <text-or-file>", "Company-search prompt, or a path to a prompt file.")
8686
9106
  .option("--target-count <n>", "Desired company count. Single-plan ceiling 50,000; larger requests return an explicit clamp warning and segmentation guidance.")
@@ -8748,7 +9168,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8748
9168
  .description("Inspect missing company fields, provider routing, and credit estimates without provider calls.")
8749
9169
  .argument("<table>", "Table id or slug.")
8750
9170
  .option("--missing-fields <fields>", "Comma-separated fields to fill: domain,linkedin_url,headcount,industry,funding,technologies,hiring_signals,company_profile.")
8751
- .option("--providers <providers>", "Comma-separated provider order pool. Defaults to blitzapi,crustdata,ai_ark,prospeo,leadmagic.")
9171
+ .option("--providers <providers>", "Comma-separated provider order pool. Defaults to scraper,blitzapi,crustdata,ai_ark,prospeo,leadmagic.")
8752
9172
  .option("--all", "Preview all rows.")
8753
9173
  .option("--limit <n>", "Preview a limited row scope.")
8754
9174
  .option("--row-ids <ids>", "Comma-separated row ids.")
@@ -9013,7 +9433,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9013
9433
  await handleAsyncAction("billing invoices", options, () => requestOxygen("/api/cli/billing/invoices"));
9014
9434
  }))
9015
9435
  .addCommand(new Command("allowance")
9016
- .description("Show this billing cycle's credit pool as one breakdown: fixed spend, flexible spend, credits committed to the next renewal, and what is free to spend — the numbers behind the bar on the credit-usage page. The parts always sum to the total, with ONE deliberate exception: `renewals_past_due` (and its region=past_due segment) reports renewals that FAILED to charge, which is money not taken and therefore outside total_credits. fixed_spent_credits is what has ALREADY been charged this cycle, NOT your monthly run-rate — for the forward figure billed at the next renewal see `billing commitments`, which is normally much larger. reserved_credits is in-flight spend already carved out of free_to_spend_credits, so treat free_to_spend as the ceiling and free_to_spend minus reserved as what is genuinely uncommitted. Every segment carries region=fixed|flexible. Read-only, 0 Oxygen credits.")
9436
+ .description("Show this billing cycle's credit pool as one breakdown: fixed spend, flexible spend, credits committed to the next renewal, and what is free to spend — the numbers behind the bar on the credit-usage page. The parts always sum to the total, with ONE deliberate exception: `renewals_past_due` (and its region=past_due segment) reports renewals that FAILED to charge, which is money not taken and therefore outside total_credits. fixed_spent_credits is what has ALREADY been charged this cycle, NOT your monthly run-rate — for the forward figure billed at the next renewal see `billing commitments`, which is normally much larger. For organization wallet scope, reserved_credits is in-flight spend already removed from the available balance: do not subtract it again from free_to_spend_credits. Reserved credits can exceed the remaining free balance without implying an overdraft. For workspace_cap scope, free_to_spend_credits is remaining allowance, not the billing owner's wallet; live execution still checks that wallet and spend policies. Every segment carries region=fixed|flexible. Read-only, 0 Oxygen credits.")
9017
9437
  .option("--json", "Print a JSON envelope.")
9018
9438
  .action(async (options) => {
9019
9439
  await handleAsyncAction("billing allowance", options, () => requestOxygen("/api/cli/billing/allowance"));
@@ -9538,9 +9958,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9538
9958
  }));
9539
9959
  }))
9540
9960
  .addCommand(new Command("halt")
9541
- .description("Force a breaker OPEN — the platform kill switch for managed provider spend.")
9542
- .option("--scope <kind>", "Scope kind to halt. Defaults to global.", "global")
9543
- .option("--key <key>", "Scope key. Defaults to * (everything in that scope).", "*")
9961
+ .description("Force a breaker OPEN — the platform kill switch for managed provider spend. --scope managed_credit --key <provider> benches Oxygen's managed account for one provider (the same bench a vendor 402 opens).")
9962
+ .option("--scope <kind>", "Scope kind to halt: global, category, provider, organization, signature, or managed_credit (key = provider id). Defaults to global.", "global")
9963
+ .option("--key <key>", "Scope key. Defaults to * (everything in that scope). For --scope managed_credit, the provider id (serper, bounceban, ...).", "*")
9544
9964
  .option("--reason <text>", "Why the halt was applied.")
9545
9965
  .option("--json", "Print a JSON envelope.")
9546
9966
  .action(async (options) => {
@@ -9555,9 +9975,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9555
9975
  }));
9556
9976
  }))
9557
9977
  .addCommand(new Command("resume")
9558
- .description("Close a breaker and clear its backoff, resuming managed provider spend.")
9559
- .option("--scope <kind>", "Scope kind to resume. Defaults to global.", "global")
9560
- .option("--key <key>", "Scope key. Defaults to *.", "*")
9978
+ .description("Close a breaker and clear its backoff, resuming managed provider spend. After funding a managed provider account, --scope managed_credit --key <provider> closes its credit bench fleet-wide instead of waiting out the window.")
9979
+ .option("--scope <kind>", "Scope kind to resume: global, category, provider, organization, signature, or managed_credit (key = provider id). Defaults to global.", "global")
9980
+ .option("--key <key>", "Scope key. Defaults to *. For --scope managed_credit, the provider id (serper, bounceban, ...).", "*")
9561
9981
  .option("--reason <text>", "Why the breaker was reset.")
9562
9982
  .option("--json", "Print a JSON envelope.")
9563
9983
  .action(async (options) => {
@@ -10075,7 +10495,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
10075
10495
  .option("--per-turn-ceiling <number>", "Legacy compatibility only: accepted and clamped, but does not cap an attended turn.")
10076
10496
  .option("--journey <slug>", "Optional journey slug to seed the session's goal and context.")
10077
10497
  .option("--title <text>", "Optional human title for the session.")
10078
- .option("--auto-approve", "Start with auto-approve ON for paid, workspace-internal actions (cards are still created and decided automatically within each action's credit cap). External sends, enrollments, publishes, DNS, and external CRM pushes always stay human-gated.")
10498
+ .option("--auto-approve", "Start with auto-approve ON for paid, workspace-internal actions (cards are still created and decided automatically within each action's credit cap). External sends, sequence starts, publishes, DNS, and external CRM pushes always stay human-gated.")
10079
10499
  .option("--no-follow", "Start with screen-follow OFF. On by default: while the session is open in the browser, your screen opens onto whatever the copilot creates or changes, with the live session docked beside it. Change it later with `copilot follow`.")
10080
10500
  .option("--json", "Print a JSON envelope.")
10081
10501
  .action(async (options) => {
@@ -10667,7 +11087,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
10667
11087
  .option("--email-pattern-validation <mode>", "Work-email pattern pre-step: leadmagic_valid_only or disabled.")
10668
11088
  .option("--phone-waterfall-profile <profile>", "Phone waterfall profile: auto (input-aware), linkedin_url, email, or name_domain. Auto picks the cheapest cost-ordered provider set for each row's inputs (mobile match rate ~30-60%).")
10669
11089
  .option("--verify-phone", "Validate found phone numbers with ClearoutPhone (adds phone_line_type + phone_carrier; filter phone_line_type=mobile for mobile-only).")
10670
- .option("--allow-premium-lanes", "Opt in to premium high-cost lanes (e.g. >25cr/row phone reveal). Off by default: premium lanes are skipped so an unattended run can't bill-shock.")
11090
+ .option("--allow-premium-lanes", "Opt in to managed lanes billing over 500 credits per call. Only linkedin_url has one (LeadMagic email_to_profile, 1225cr); it is off by default so an unattended run can't bill-shock. No effect on mobile_phone, work_email or verify_email.")
10671
11091
  .option("--phone-verification-credential-mode <mode>", "ClearoutPhone credential mode for phone verification: managed or user_api_key.")
10672
11092
  .option("--limit <n>", "Rows to estimate. Defaults to 10.")
10673
11093
  .option("--all", "Estimate all rows.")
@@ -10702,7 +11122,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
10702
11122
  .option("--email-pattern-validation <mode>", "Work-email pattern pre-step: leadmagic_valid_only or disabled.")
10703
11123
  .option("--phone-waterfall-profile <profile>", "Phone waterfall profile: auto (input-aware), linkedin_url, email, or name_domain. Auto picks the cheapest cost-ordered provider set for each row's inputs (mobile match rate ~30-60%).")
10704
11124
  .option("--verify-phone", "Validate found phone numbers with ClearoutPhone (adds phone_line_type + phone_carrier; filter phone_line_type=mobile for mobile-only).")
10705
- .option("--allow-premium-lanes", "Opt in to premium high-cost lanes (e.g. >25cr/row phone reveal). Off by default: premium lanes are skipped so an unattended run can't bill-shock.")
11125
+ .option("--allow-premium-lanes", "Opt in to managed lanes billing over 500 credits per call. Only linkedin_url has one (LeadMagic email_to_profile, 1225cr); it is off by default so an unattended run can't bill-shock. No effect on mobile_phone, work_email or verify_email.")
10706
11126
  .option("--phone-verification-credential-mode <mode>", "ClearoutPhone credential mode for phone verification: managed or user_api_key.")
10707
11127
  .option("--limit <n>", "Rows to queue.")
10708
11128
  .option("--all", "Queue all rows.")
@@ -10738,7 +11158,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
10738
11158
  .option("--company-domain <domain>", "Company apex domain, e.g. acme.com.")
10739
11159
  .option("--company-name <name>", "Company name.")
10740
11160
  .option("--mode <mode>", "dry_run (default) previews the plan and spends nothing; live spends credits and returns the answer.")
10741
- .option("--max-credits <credits>", "Spend ceiling. Required for --mode live.")
11161
+ .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. When the resolved chain runs the guessed-address pass (the name+domain and first/last+domain profiles, not the LinkedIn-URL one) the block also carries estimate.pattern_guess_verification_credits — the verification checks that pass bills before any named provider is dialled. 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.")
10742
11162
  .option("--json", "Print a JSON envelope.")
10743
11163
  .action(async (options) => {
10744
11164
  await handleAsyncAction("find email", options, () => requestOxygen("/api/cli/find/run", { method: "POST", body: buildFindBody("email", options) }));
@@ -10752,7 +11172,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
10752
11172
  .option("--company-name <name>", "Company name.")
10753
11173
  .option("--verify", "Verify the found number with ClearoutPhone — adds line_type/carrier and keeps the number on a non-verdict (only a genuine 'not valid' is discarded).")
10754
11174
  .option("--mode <mode>", "dry_run (default) previews the plan and spends nothing; live spends credits and returns the answer.")
10755
- .option("--max-credits <credits>", "Spend ceiling. Required for --mode live.")
11175
+ .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.")
10756
11176
  .option("--json", "Print a JSON envelope.")
10757
11177
  .action(async (options) => {
10758
11178
  await handleAsyncAction("find phone", options, () => requestOxygen("/api/cli/find/run", { method: "POST", body: buildFindBody("phone", options) }));
@@ -10765,7 +11185,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
10765
11185
  .option("--company-name <name>", "Company name.")
10766
11186
  .option("--company-linkedin-url <url>", "Company LinkedIn URL.")
10767
11187
  .option("--mode <mode>", "dry_run (default) previews the plan and spends nothing; live spends credits and returns the answer.")
10768
- .option("--max-credits <credits>", "Spend ceiling. Required for --mode live.")
11188
+ .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.")
10769
11189
  .option("--json", "Print a JSON envelope.")
10770
11190
  .action(async (options) => {
10771
11191
  await handleAsyncAction("find linkedin", options, () => requestOxygen("/api/cli/find/run", { method: "POST", body: buildFindBody("linkedin", options) }));
@@ -10777,7 +11197,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
10777
11197
  .option("--linkedin-url <url>", "Company LinkedIn URL.")
10778
11198
  .option("--fields <fields>", "Comma-separated company fields. Defaults to domain,linkedin_url,headcount,industry.")
10779
11199
  .option("--mode <mode>", "dry_run (default) previews the plan and spends nothing; live spends credits and returns the answer.")
10780
- .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).")
11200
+ .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.")
10781
11201
  .option("--json", "Print a JSON envelope.")
10782
11202
  .action(async (options) => {
10783
11203
  await handleAsyncAction("find company", options, () => requestOxygen("/api/cli/find/run", { method: "POST", body: buildFindBody("company", options) }));
@@ -10786,11 +11206,11 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
10786
11206
  .command("verify")
10787
11207
  .description("Check whether emails you already have are safe to send to. dry_run previews the provider chain for free; live spends and needs --max-credits.")
10788
11208
  .addCommand(new Command("email")
10789
- .description("Verify one or more email addresses. MillionVerifier answers first; addresses on catch-all (accept-all) domains, which it can only flag as risky, escalate automatically to BounceBan to get a real answer. Returns valid / invalid / catch_all / unknown per address. An address whose escalation could not run keeps catch_all — record it as unconfirmed, never verified — and its escalation_unavailable_reason plus next_action say why and what to do: provider_account_dry on the managed lane is Oxygen's BounceBan balance, not your workspace credits, and you were not charged for it.")
11209
+ .description("Verify one or more email addresses. MillionVerifier answers first; eligible catch-all (accept-all) addresses escalate to BounceBan when available. Returns valid / invalid / catch_all / unknown per address. If escalation cannot run, catch_all stays unconfirmed; escalation_unavailable_reason and next_action explain why. On the managed lane, Oxygen staff own dry provider balances, credential repair and hold release; workspace credits do not fix those blockers. A free preview reports an availability snapshot, not guaranteed live readiness. See https://oxygen-agent.com/docs/providers/email-verification and the oxygen-gtm MillionVerifier/BounceBan provider runbooks.")
10790
11210
  .argument("<emails...>", "One or more email addresses to verify.")
10791
11211
  .option("--mode <mode>", "dry_run (default) previews the plan and spends nothing; live spends credits and returns the answer.")
10792
11212
  .option("--approved", "Same as --mode live. Accepted because the global help footer names --approved as the way to authorize a credit-spending command.")
10793
- .option("--max-credits <credits>", "Spend ceiling for the whole run. Required for --mode live. Run the free dry_run first — it returns estimate.recommended_max_credits, the value that guarantees every catch-all address still gets escalated. Re-verifying the exact same address in this workspace replays MillionVerifier's first pass free for up to 30 days, so a repeat run can report credits_used 0 — the catch-all escalation is never cached, and its reservation is released in full when that call returns no verdict.")
11213
+ .option("--max-credits <credits>", "Spend ceiling for the whole run. Required for --mode live. The free dry_run returns estimate.recommended_max_credits, which covers the estimated maximum subject to live provider availability and eligibility; it is a ceiling, not a charge or a guaranteed verdict. Re-verifying the exact same address in this workspace replays MillionVerifier's first pass free for up to 30 days, so a repeat run can report credits_used 0 — catch-all escalation is never cached, and its reservation is released in full when that call returns no verdict.")
10794
11214
  .option("--json", "Print a JSON envelope.")
10795
11215
  .action(async (emails, options) => {
10796
11216
  await handleAsyncAction("verify email", options, () => requestOxygen("/api/cli/verify/run", {
@@ -10823,6 +11243,102 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
10823
11243
  },
10824
11244
  }));
10825
11245
  }));
11246
+ program
11247
+ .command("supabase")
11248
+ .description("Connect a read-only Supabase database and import selected base tables into durable Oxygen Tables. Catalog, preview, import, status, cancel, and retry all consume 0 credits; imports never write to Supabase.")
11249
+ .addCommand(new Command("connect")
11250
+ .description("Validate and encrypt a managed Supabase PostgreSQL URL. The database role must be read-only; Oxygen refuses writable credentials. Read the URL from stdin to keep it out of shell history and process arguments. Run `oxygen integrations connect supabase --json` first for copyable role setup SQL and the secure web path.")
11251
+ .requiredOption("--database-url-stdin", "Read the Supabase PostgreSQL URL from stdin.")
11252
+ .option("--schemas <schemas>", "Comma-separated user schemas (default: public). Managed/system schemas are refused.")
11253
+ .option("--json", "Print a JSON envelope.")
11254
+ .action(async (options) => {
11255
+ await handleAsyncAction("supabase connect", options, () => {
11256
+ if (options.databaseUrlStdin !== true)
11257
+ throw new Error("--database-url-stdin is required.");
11258
+ const databaseUrl = readFileSync(0, "utf8").trim();
11259
+ if (!databaseUrl)
11260
+ throw new Error("No Supabase database URL was provided on stdin.");
11261
+ return requestOxygen("/api/cli/integrations/connect", {
11262
+ method: "POST",
11263
+ body: {
11264
+ integration_id: "supabase",
11265
+ database_url: databaseUrl,
11266
+ ...(readOption(options.schemas) ? { schemas: readOption(options.schemas) } : {}),
11267
+ },
11268
+ });
11269
+ });
11270
+ }))
11271
+ .addCommand(new Command("catalog")
11272
+ .description("Inspect readable base tables, columns, PostgreSQL types, primary keys, and estimated rows through the saved read-only connection.")
11273
+ .option("--connection-id <id>", "Use a specific Supabase connection; defaults to the active default.")
11274
+ .option("--json", "Print a JSON envelope.")
11275
+ .action(async (options) => {
11276
+ await handleAsyncAction("supabase catalog", options, () => requestOxygen("/api/cli/supabase/catalog", {
11277
+ method: "POST",
11278
+ body: readOption(options.connectionId) ? { connection_id: readOption(options.connectionId) } : {},
11279
+ }));
11280
+ }))
11281
+ .addCommand(new Command("plan")
11282
+ .description("Preview exact Oxygen Table creates/refreshes, row-limit posture, deletion semantics, and the schema fingerprint required to start. No writes and 0 credits.")
11283
+ .option("--connection-id <id>", "Use a specific Supabase connection.")
11284
+ .option("--tables <schema.tables>", "Comma-separated schema.table names; at most 50 per import. Omit to preview every readable table.")
11285
+ .option("--json", "Print a JSON envelope.")
11286
+ .action(async (options) => {
11287
+ await handleAsyncAction("supabase imports plan", options, () => {
11288
+ const tables = parseSupabaseTableSelection(options.tables);
11289
+ return requestOxygen("/api/cli/supabase/imports/plan", {
11290
+ method: "POST",
11291
+ body: {
11292
+ ...(readOption(options.connectionId) ? { connection_id: readOption(options.connectionId) } : {}),
11293
+ ...(tables ? { tables } : {}),
11294
+ },
11295
+ });
11296
+ });
11297
+ }))
11298
+ .addCommand(new Command("import")
11299
+ .description("Start the previously previewed snapshot/refresh as durable Postgres-backed work. Creates one Oxygen Table per new source; refreshes primary-keyed sources in place and marks missing source rows instead of deleting them. Consumes 0 credits.")
11300
+ .requiredOption("--tables <schema.tables>", "Comma-separated schema.table names from the plan; at most 50 per import.")
11301
+ .requiredOption("--catalog-fingerprint <sha256>", "Fingerprint returned by the latest plan; prevents importing against a changed schema.")
11302
+ .option("--connection-id <id>", "Use a specific Supabase connection.")
11303
+ .option("--project <id-or-slug>", "Create new Oxygen Tables in this project/folder.")
11304
+ .option("--json", "Print a JSON envelope.")
11305
+ .action(async (options) => {
11306
+ await handleAsyncAction("supabase imports start", options, () => {
11307
+ const tables = parseSupabaseTableSelection(options.tables);
11308
+ if (!tables)
11309
+ throw new Error("--tables is required.");
11310
+ return requestOxygen("/api/cli/supabase/imports", {
11311
+ method: "POST",
11312
+ body: {
11313
+ tables,
11314
+ catalog_fingerprint: readOption(options.catalogFingerprint),
11315
+ ...(readOption(options.connectionId) ? { connection_id: readOption(options.connectionId) } : {}),
11316
+ ...(readOption(options.project) ? { project: readOption(options.project) } : {}),
11317
+ },
11318
+ });
11319
+ });
11320
+ }))
11321
+ .addCommand(new Command("get")
11322
+ .description("Get aggregate and per-table status, row counts, failures, and deep-links for a Supabase import.")
11323
+ .argument("<import_id>", "Supabase import id.")
11324
+ .option("--json", "Print a JSON envelope.")
11325
+ .action(async (importId, options) => {
11326
+ await handleAsyncAction("supabase imports get", options, () => requestOxygen(`/api/cli/supabase/imports/${encodeURIComponent(importId)}`));
11327
+ }))
11328
+ .addCommand(new Command("cancel")
11329
+ .description("Request cancellation of every still-active child table run in an import.")
11330
+ .argument("<import_id>", "Supabase import id.")
11331
+ .option("--json", "Print a JSON envelope.")
11332
+ .action(async (importId, options) => {
11333
+ await handleAsyncAction("supabase imports cancel", options, () => requestOxygen(`/api/cli/supabase/imports/${encodeURIComponent(importId)}/cancel`, { method: "POST", body: {} }));
11334
+ }))
11335
+ .addCommand(new Command("retry")
11336
+ .description("Retry failed child table items without duplicating already-upserted source rows.")
11337
+ .argument("<import_id>", "Supabase import id.")
11338
+ .option("--json", "Print a JSON envelope.")
11339
+ .action(async (importId, options) => {
11340
+ await handleAsyncAction("supabase imports retry", options, () => requestOxygen(`/api/cli/supabase/imports/${encodeURIComponent(importId)}/retry`, { method: "POST", body: {} }));
11341
+ }));
10826
11342
  program
10827
11343
  .command("integrations")
10828
11344
  .description("Connect third-party tools such as PostHog, Slack, and HubSpot, and manage raw provider-webhook subscriptions. Start with `integrations list` to confirm any provider is supported, see its auth mode, and distinguish free connection setup from per-action credit estimates. Trigger-ready workspace events live under `workflows events list`. Local mailbox exports use `mailboxes import`, not this group.")
@@ -10928,7 +11444,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
10928
11444
  await handleAsyncAction("integrations list", options, () => requestOxygen("/api/cli/integrations/composio/list"));
10929
11445
  }))
10930
11446
  .addCommand(new Command("connect")
10931
- .description("Connect an integration. OAuth toolkits return a redirect URL; API-key integrations accept --api-key. Run without credentials first for a 0-credit preview of the provider's exact fields (mode: needs_api_key): no credential is submitted, nothing is stored, and no provider call is made. Use repeatable --credential name=value when a provider also requires a host, subdomain, or other named value. To keep a secret out of shell history and process arguments, open the preview's web_url and submit it in Connections; --api-key is for controlled noninteractive use. Credential submission creates or replaces the connection immediately and needs no separate --approved flag. LinkedIn and WhatsApp return a Unipile hosted-auth URL instead (`oxygen senders connect` / `oxygen whatsapp connect` are the canonical paths).")
11447
+ .description("Connect an integration. OAuth toolkits return a redirect URL, which grants nothing until you approve access on the provider's page; API-key integrations accept --api-key. Run without credentials first for a 0-credit preview of the provider's exact fields (mode: needs_api_key): no credential is submitted, nothing is stored, and no provider call is made. Use repeatable --credential name=value when a provider also requires a host, subdomain, or other named value. To keep a secret out of shell history and process arguments, open the preview's web_url and submit it in Connections; --api-key is for controlled noninteractive use. Credential submission creates or replaces the connection immediately and needs no separate --approved flag. LinkedIn and WhatsApp return a Unipile hosted-auth URL instead (`oxygen senders connect` / `oxygen whatsapp connect` are the canonical paths).")
10932
11448
  .argument("<integration_id>", "Integration id, such as 'slack' or 'serpapi'.")
10933
11449
  .option("--api-key <value>", "API key for controlled noninteractive use. Literal values can appear in shell history and process arguments; prefer the preview web_url for manual entry.")
10934
11450
  .option("--credential <name=value>", "Named provider credential field. Repeat for multi-field integrations (for example, PostHog: --credential subdomain=us).", collectRepeatable, [])
@@ -11050,7 +11566,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
11050
11566
  });
11051
11567
  }));
11052
11568
  program.addCommand(new Command("senders")
11053
- .description("Manage the org's connected LinkedIn sender accounts for Sequencer: list, connect, sync, get details, disconnect, and tune rate limits.")
11569
+ .description("Manage the org's connected LinkedIn sender accounts for Sequencer: list, connect, sync, get details, disconnect, and tune rate limits. This group is LinkedIn/WhatsApp accounts only; the cross-channel identity (one person's name and photo) that owns inboxes as well is `oxygen senders profiles`.")
11054
11570
  .addCommand(new Command("list")
11055
11571
  .description("List connected LinkedIn sender accounts with health status, rate limits, and today's usage.")
11056
11572
  .option("--status <status>", "Filter by sender status: active, paused, disconnected, restricted, or credentials_required.")
@@ -11268,7 +11784,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
11268
11784
  });
11269
11785
  })))
11270
11786
  .addCommand(new Command("profiles")
11271
- .description("Sender profiles: group a person's LinkedIn + WhatsApp senders and email inboxes into one sending identity — one name, one first/last name, one photo. A sequence can then send every channel from the same persona, and `oxygen managed-inboxes subscribe/add-inboxes --sender <id>` orders new mailboxes under that identity instead of making you retype it per mailbox. Manage: list, get, create, update, set-photo, attach/detach accounts, delete.")
11787
+ .description("Sender profiles: group a person's LinkedIn + WhatsApp senders and email inboxes into one sending identity — one name, one first/last name, one photo. A sequence can then send every channel from the same persona, and `oxygen managed-inboxes subscribe/add-inboxes --sender <id>` orders new mailboxes under that identity instead of making you retype it per mailbox. Every email inbox always belongs to one of these profiles, so attaching moves an inbox and detaching one is refused. Manage: list, get, create, update, set-photo, attach/detach accounts, delete.")
11272
11788
  .addCommand(new Command("list")
11273
11789
  .description("List sender profiles with their per-channel account counts (LinkedIn / WhatsApp / inboxes).")
11274
11790
  .option("--status <status>", "Filter by status: active, paused, or archived.")
@@ -11306,14 +11822,14 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
11306
11822
  await handleAsyncAction("senders profiles get", options, () => requestOxygen(`/api/cli/senders/profiles/${encodeURIComponent(id)}`));
11307
11823
  }))
11308
11824
  .addCommand(new Command("create")
11309
- .description("Create a sender profile: one person's sending identity — their LinkedIn/WhatsApp senders and email inboxes under one name and photo. --from-linkedin seeds the name + avatar from a LinkedIn account and attaches it; then assign inboxes and WhatsApp with --mailboxes / --senders. Without --from-linkedin, --name is required. Give the profile --first-name and --last-name too: those are the exact names the inbox vendor stamps when you order mailboxes with `oxygen managed-inboxes subscribe --sender <id>`, and the vendor has no way to change a name after provisioning.")
11825
+ .description("Create a sender profile: one person's sending identity — their LinkedIn/WhatsApp senders and email inboxes under one name and photo. --from-linkedin seeds the name + avatar from a LinkedIn account and attaches it; then assign inboxes and WhatsApp with --mailboxes / --senders. Without --from-linkedin, --name is required. Give the profile --first-name and --last-name too: those are the exact names the inbox vendor stamps when you order mailboxes with `oxygen managed-inboxes subscribe --sender <id>`, and the vendor has no way to change a name after provisioning. Attaching a LinkedIn sender (--from-linkedin or --senders) gives the profile that account's LinkedIn photo, mirrored into Oxygen storage, unless you pass --avatar-url; a photo you upload later with set-photo --file always wins over the LinkedIn one.")
11310
11826
  .option("--name <name>", "Display name for the profile. Optional when --from-linkedin is given (derived from the LinkedIn account).")
11311
11827
  .option("--first-name <name>", "The person's first name, as it should appear on mailboxes ordered for this sender. Defaults to splitting --name / the LinkedIn name on the first space.")
11312
11828
  .option("--last-name <name>", "The person's last name, as it should appear on mailboxes ordered for this sender. Ordering managed inboxes for this sender needs both names; a one-word name leaves it blank and the order is refused rather than guessed.")
11313
11829
  .option("--from-linkedin <senderId>", "Seed the profile from this LinkedIn/WhatsApp sender account id: derives the name and MIRRORS the LinkedIn photo into Oxygen storage (LinkedIn's own URL expires, so a mirrored copy is what an inbox vendor can still fetch hours later) and attaches the account.")
11314
11830
  .option("--avatar-url <url>", "Use this image URL as the profile photo instead of the LinkedIn one. Must be a public https URL. An expiring link (e.g. media.licdn.com with e=<epoch>) is stored but marked non-durable and is NOT sent to an inbox vendor — use `set-photo --file` to host a permanent copy.")
11315
11831
  .option("--status <status>", "Initial status: active (default), paused, or archived.")
11316
- .option("--senders <ids>", "Comma-separated sender account ids (LinkedIn/WhatsApp) to attach.")
11832
+ .option("--senders <ids>", "Comma-separated sender account ids (LinkedIn/WhatsApp) to attach. A LinkedIn sender also supplies the profile's photo when it has none of its own.")
11317
11833
  .option("--mailboxes <ids>", "Comma-separated email mailbox ids to attach.")
11318
11834
  .option("--json", "Print a JSON envelope.")
11319
11835
  .action(async (options) => {
@@ -11353,12 +11869,12 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
11353
11869
  }));
11354
11870
  }))
11355
11871
  .addCommand(new Command("set-photo")
11356
- .description("Set the reusable sender identity's profile picture for FUTURE managed-inbox orders made with --sender <id>. Pick exactly one source: --file (upload your own image; Oxygen hosts it permanently), --url (a public https image), --from-linkedin (re-mirror the photo from the attached LinkedIn account), or --clear. Hosting matters: an inbox vendor fetches the photo HOURS after an order, so an expiring LinkedIn CDN URL can ship the mailbox faceless. This updates Oxygen's sender profile only: it does not change already-provisioned mailbox photos or call the inbox provider. For a one-off typed-name order using profile_picture_url in --mailboxes JSON, use `oxygen managed-inboxes upload-avatar <path>` instead. Free — no credits.")
11872
+ .description("Set the reusable sender identity's profile picture for FUTURE managed-inbox orders made with --sender <id>. Pick exactly one source: --file (upload your own image; Oxygen hosts it permanently), --url (a public https image), --from-linkedin (re-mirror the photo from the attached LinkedIn account), or --clear. Hosting matters: an inbox vendor fetches the photo HOURS after an order, so an expiring LinkedIn CDN URL can ship the mailbox faceless. This updates Oxygen's sender profile only: it does not change already-provisioned mailbox photos or call the inbox provider. For a one-off typed-name order using profile_picture_url in --mailboxes JSON, use `oxygen managed-inboxes upload-avatar <path>` instead. Precedence: a photo you upload (--file) or set with --url is yours and is never overwritten by Oxygen; a sender with a LinkedIn account attached and no photo of its own gets that LinkedIn photo automatically (existing senders are backfilled), so --clear on such a sender is temporary — upload your own photo to override LinkedIn's. If LinkedIn's CDN refuses the stored picture, Oxygen re-syncs the account and retries once; read back avatar_durable, where false means only an expiring link could be kept. Free — no credits.")
11357
11873
  .argument("<id>", "Sender profile id (from `oxygen senders profiles list`).")
11358
11874
  .option("--file <path>", "Upload a PNG, JPEG, or WebP from disk (max 8MB) and use it. Oxygen hosts the image permanently, so an inbox vendor can still fetch it at provisioning time.")
11359
11875
  .option("--url <url>", "Use an image already published at a public https URL. If the link expires (e.g. media.licdn.com with e=<epoch>) the photo is kept for Oxygen's own UI but marked non-durable and NEVER sent to an inbox vendor.")
11360
- .option("--from-linkedin", "Re-mirror the photo from this profile's attached LinkedIn account into Oxygen storage. Use it when the person changed their LinkedIn picture.")
11361
- .option("--clear", "Remove the photo. Mailboxes ordered afterwards ship without one.")
11876
+ .option("--from-linkedin", "Re-mirror the photo from this profile's attached LinkedIn account into Oxygen storage. Use it when the person changed their LinkedIn picture. If the CDN refuses the stored picture, the account is re-synced and the mirror retried once; when it still fails, the expiring link is kept, avatar_durable reads false, and the worker retries later.")
11877
+ .option("--clear", "Remove the photo. Mailboxes ordered afterwards ship without one. On a sender with a LinkedIn account attached this is temporary — the LinkedIn photo is adopted again on the next sync; upload your own photo to override it instead.")
11362
11878
  .option("--json", "Print a JSON envelope.")
11363
11879
  .action(async (id, options) => {
11364
11880
  await handleAsyncAction("senders profiles set-photo", options, async () => {
@@ -11398,9 +11914,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
11398
11914
  });
11399
11915
  }))
11400
11916
  .addCommand(new Command("attach")
11401
- .description("Attach senders and/or inboxes to a profile. An account belongs to one profile at a time — attaching moves it.")
11917
+ .description("Attach senders and/or inboxes to a profile. An account belongs to one profile at a time — attaching moves it. Attaching a LinkedIn sender to a profile without a photo of its own gives it that account's LinkedIn photo (mirrored into Oxygen storage); an uploaded or URL-set photo is never replaced. Read back has_photo and avatar_durable in the result.")
11402
11918
  .argument("<id>", "Sender profile id.")
11403
- .option("--senders <ids>", "Comma-separated sender account ids (LinkedIn/WhatsApp) to attach.")
11919
+ .option("--senders <ids>", "Comma-separated sender account ids (LinkedIn/WhatsApp) to attach. A LinkedIn sender also supplies the profile's photo when it has none of its own.")
11404
11920
  .option("--mailboxes <ids>", "Comma-separated email mailbox ids to attach.")
11405
11921
  .option("--json", "Print a JSON envelope.")
11406
11922
  .action(async (id, options) => {
@@ -11413,7 +11929,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
11413
11929
  }));
11414
11930
  }))
11415
11931
  .addCommand(new Command("detach")
11416
- .description("Detach senders and/or inboxes from a profile, returning them to the unassigned pool.")
11932
+ .description("Detach LinkedIn/WhatsApp senders from a profile, returning them to the unassigned pool. --mailboxes is refused: an email inbox always belongs to a sender, so there is no unassigned pool for it — move it with `senders profiles attach <other-profile> --mailboxes <id>`, which reassigns it.")
11417
11933
  .argument("<id>", "Sender profile id.")
11418
11934
  .option("--senders <ids>", "Comma-separated sender account ids to detach.")
11419
11935
  .option("--mailboxes <ids>", "Comma-separated email mailbox ids to detach.")
@@ -11428,7 +11944,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
11428
11944
  }));
11429
11945
  }))
11430
11946
  .addCommand(new Command("delete")
11431
- .description("Delete a sender profile. Its accounts detach (return to the pool); they are never deleted.")
11947
+ .description("Delete a sender profile. Refused while the profile still owns inboxes — move them to another profile first; LinkedIn/WhatsApp accounts detach (return to the pool) and are never deleted.")
11432
11948
  .argument("<id>", "Sender profile id.")
11433
11949
  .option("--json", "Print a JSON envelope.")
11434
11950
  .action(async (id, options) => {
@@ -11817,7 +12333,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
11817
12333
  });
11818
12334
  }))
11819
12335
  .addCommand(new Command("status")
11820
- .description("Show the canonical intent tables, their linked-table deep-links, and the capture feeds filling them.")
12336
+ .description("Show the canonical intent tables, their linked-table deep-links, and the capture feeds filling them. Workspace-wide: it lists every sender's captures and takes no --account; filter by the sender_account_id on each capture instead.")
11821
12337
  .option("--json", "Print a JSON envelope.")
11822
12338
  .action(async (options) => {
11823
12339
  await handleAsyncAction("linkedin intent status", options, () => requestOxygen("/api/cli/linkedin/intent"));
@@ -11828,7 +12344,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
11828
12344
  // cycle. One verb carrying both postures would make the safe default
11829
12345
  // ambiguous on the surface a founder reads first.
11830
12346
  .addCommand(new Command("autoenroll")
11831
- .description("Authorize the captures armed by `linkedin intent setup` to enroll the people they capture into a sequence — the step that turns captured rows into outreach. PREVIEWS BY DEFAULT and writes nothing: it prints the resolved sender and its effective daily caps, the sequence, per-capture create-vs-patch, the live audience split (already 1st-degree / needs an invite / suppressed / already owned by another sender), the resolved spend + enroll caps and where each came from, and a 7-day send forecast. Re-run with --approved to arm a STANDING grant: every later cycle enrolls newly captured people under those caps without asking again, including people you have never seen. This play is a drip, not a blast — a connections import walks LinkedIn on a metered budget (15 relations reads a day by default, ~50 people a read), so a real network lands over days, and a sender on the warm-up ramp is floored at 5 invites and 5 messages a day for its first three days no matter what caps you set. Revoke at any time with `--disable`.")
12347
+ .description("Authorize the captures armed by `linkedin intent setup` to enroll the people they capture into a sequence — the step that turns captured rows into outreach. PREVIEWS BY DEFAULT and writes nothing: it prints the resolved sender and its effective daily caps, the sequence, per-capture create-vs-patch, the live audience split (already 1st-degree / needs an invite / suppressed / already owned by another sender, and — while a capture still baselines — how many are recorded rather than contacted), anything that would make --approved fail before it writes, the resolved spend + enroll caps and where each came from, and a 7-day send forecast. Re-run with --approved to arm a STANDING grant: every later cycle enrolls newly captured people under those caps without asking again, including people you have never seen. This play is a drip, not a blast — a connections import walks LinkedIn on a metered budget (15 relations reads a day by default, ~50 people a read), so a real network lands over days, and a sender on the warm-up ramp is floored at 5 invites and 5 messages a day for its first three days no matter what caps you set. Revoke at any time with `--disable`.")
11832
12348
  .requiredOption("--account <ref>", "Connected LinkedIn sender whose captures are authorized (sender id, connection id, or Unipile account id).")
11833
12349
  .option("--sequence <ref>", "Sequence (id or slug) the captured people are enrolled into. Required to arm: a standing grant has to name the journey it puts people in.")
11834
12350
  .option("--kinds <csv>", "Captures to authorize: connections, followers, profile_viewers, own_posts, post. Defaults to every capture `linkedin intent setup` already provisioned for this sender; naming a kind it never provisioned creates that capture.")
@@ -12177,7 +12693,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12177
12693
  .option("--domain <domains>", "Email only: comma-separated counterpart domains to include.")
12178
12694
  .option("--exclude-domain <domains>", "Email only: comma-separated counterpart domains to exclude.")
12179
12695
  .option("--mailbox-id <ids>", "Email only: comma-separated mailbox ids.")
12180
- .option("--search <text>", "Filter by attendee name or last-message text. Cross-channel.")
12696
+ .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.")
12181
12697
  .option("--include-archived", "Include archived conversations.")
12182
12698
  .option("--limit <n>", "Maximum conversations to return (1-200). Defaults to 50.")
12183
12699
  .option("--cursor <cursor>", "channel=all only: the previous page's next_cursor — resumes the merged stream after that row.")
@@ -12306,7 +12822,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12306
12822
  .option("--sender-account-id <ids>", "DMs only: comma-separated LinkedIn/WhatsApp sender account ids.")
12307
12823
  .option("--since <iso>", "Only conversations whose last message is on/after this ISO date/timestamp. Cross-channel.")
12308
12824
  .option("--until <iso>", "Only conversations whose last message is on/before this ISO date/timestamp. Cross-channel.")
12309
- .option("--search <text>", "Filter by attendee name or last-message text. Cross-channel.")
12825
+ .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.")
12310
12826
  .option("--include-archived", "Also mark archived conversations read.")
12311
12827
  .option("--unanswered", "Only sweep conversations awaiting your reply (the last message is inbound) — the same filter as `inbox list --unanswered`, so the scope is exactly that list.")
12312
12828
  .option("--yes", "Apply the sweep. Without this flag, returns a preview of the unread count only.")
@@ -13086,6 +13602,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13086
13602
  .option("--max-new-enrollments-per-day <n>", "Daily drip cap on NEW first-touch leads the planner starts (positive integer).")
13087
13603
  .option("--sequence-prioritization <mode>", "Under a tight daily budget, serve 'followups' or 'new_leads' first.")
13088
13604
  .option("--esp-matching <mode>", "Native-email ESP matching: 'prefer' (DEFAULT) biases toward a mailbox on the recipient's own provider, falling back to any; 'off' rotates mailboxes freely; 'strict' requires a same-provider mailbox and defers the send when none exists.")
13605
+ .option("--esp-routing-file <path>", "Path to a JSON file holding the WHOLE native-email routing policy: { mode?, routes?, exclude? }. `routes` is keyed by the RECIPIENT's provider and its values are relative send shares per sending provider, so { \"google\": { \"microsoft\": 100 } } sends Google-hosted recipients from Microsoft inboxes. `exclude` bars a sending domain outright: [{ \"domain\": \"burned.example\", \"from_recipients\": [\"google\"] }]. Replaces --esp-matching and the file is the complete policy, not a patch.")
13089
13606
  .option("--sender-failover <mode>", "What happens when an enrollment's LinkedIn/WhatsApp sender goes unavailable: 'wait' (default) resumes when the sender recovers; 'rebind' moves UNTOUCHED enrollments (no thread, no pending invite, no lead binding) to the least-loaded healthy sender in the pool after ~5h of confirmed outage.")
13090
13607
  .option("--email-min-gap-minutes <n>", "Minimum minutes between two live emails from the SAME mailbox for this sequence (Instantly's 'time gap between emails'; integer 0-720, 0 = none). A humanization FLOOR only: Oxygen already spaces each mailbox's sends evenly across its send window (window length ÷ per-mailbox daily cap, e.g. 9h ÷ 15 = 36 min, tightening through the day to catch up on any lost slot), so it only binds when it exceeds the derived spacing — it also floors the late-day catch-up, so 12 keeps every gap ≥12 min. It never bypasses the daily caps or the send window. To send MORE per day, raise --max-emails-per-mailbox-per-day, add mailboxes, or widen the send window.")
13091
13608
  .option("--opportunity-value <usd>", "Estimated USD value of one positive-reply opportunity (>= 0). Analytics-only: sequence stats multiply it by the positive-reply count to report pipeline $ (stats.opportunities). Never gates a send or spends a credit.")
@@ -13188,6 +13705,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13188
13705
  .option("--max-new-enrollments-per-day <n>", "Daily drip cap on NEW first-touch leads the planner starts (positive integer).")
13189
13706
  .option("--sequence-prioritization <mode>", "Under a tight daily budget, serve 'followups' or 'new_leads' first.")
13190
13707
  .option("--esp-matching <mode>", "Native-email ESP matching: 'prefer' (DEFAULT) biases toward a mailbox on the recipient's own provider, falling back to any; 'off' rotates mailboxes freely; 'strict' requires a same-provider mailbox and defers the send when none exists.")
13708
+ .option("--esp-routing-file <path>", "Path to a JSON file holding the WHOLE native-email routing policy: { mode?, routes?, exclude? }. `routes` is keyed by the RECIPIENT's provider and its values are relative send shares per sending provider, so { \"google\": { \"microsoft\": 100 } } sends Google-hosted recipients from Microsoft inboxes. `exclude` bars a sending domain outright: [{ \"domain\": \"burned.example\", \"from_recipients\": [\"google\"] }]. Replaces --esp-matching and the file is the complete policy, not a patch.")
13191
13709
  .option("--sender-failover <mode>", "What happens when an enrollment's LinkedIn/WhatsApp sender goes unavailable: 'wait' (default) resumes when the sender recovers; 'rebind' moves UNTOUCHED enrollments (no thread, no pending invite, no lead binding) to the least-loaded healthy sender in the pool after ~5h of confirmed outage.")
13192
13710
  .option("--email-min-gap-minutes <n>", "Minimum minutes between two live emails from the SAME mailbox for this sequence (Instantly's 'time gap between emails'; integer 0-720, 0 = none). A humanization FLOOR only: Oxygen already spaces each mailbox's sends evenly across its send window (window length ÷ per-mailbox daily cap, e.g. 9h ÷ 15 = 36 min, tightening through the day to catch up on any lost slot), so it only binds when it exceeds the derived spacing — it also floors the late-day catch-up, so 12 keeps every gap ≥12 min. It never bypasses the daily caps or the send window. To send MORE per day, raise --max-emails-per-mailbox-per-day, add mailboxes, or widen the send window.")
13193
13711
  .option("--opportunity-value <usd>", "Estimated USD value of one positive-reply opportunity (>= 0). Analytics-only: sequence stats multiply it by the positive-reply count to report pipeline $ (stats.opportunities). Never gates a send or spends a credit.")
@@ -14529,24 +15047,63 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
14529
15047
  });
14530
15048
  })));
14531
15049
  program.addCommand(new Command("mailboxes")
14532
- .description("Native email sending pool: register/refresh Google/Microsoft mailboxes (including secure local credential-file transfer), pause/disable inboxes, connect EmailGuard monitoring, and inspect/control OXYGEN Warm-up. Fresh managed InboxKit Google/Microsoft/Azure orders activate exact-scope warm-up automatically after provisioning under their approved default-on add-on. Google reuses the managed credential just in time; Microsoft/Azure uses native export. Standalone warm-up plans and credit caps are for BYOK/imported mailboxes, managed opt-outs, or later separate enrollment. OXYGEN Warm-up never owns campaign dispatch. Safe import preflight: run `oxygen mailboxes compatibility --catalog-only --json`, then `oxygen mailboxes import --file <path> --validate-only --json` (add the credential source flags shown by import help when the file contains credentials).")
15050
+ .description("Native email sending pool: register/refresh Google/Microsoft mailboxes (including secure local credential-file transfer), pause/disable inboxes, connect EmailGuard monitoring, and inspect/control OXYGEN Warm-up. Fresh managed InboxKit Google/Microsoft/Azure orders activate exact-scope warm-up automatically after provisioning under their approved default-on add-on. Google reuses the managed credential just in time; Microsoft/Azure uses native export. Standalone warm-up plans and credit caps are for BYOK/imported mailboxes, managed opt-outs, or later separate enrollment. OXYGEN Warm-up never owns campaign dispatch. Every inbox here always belongs to a sender profile (the person it sends as) — list, create, and attach those with `oxygen senders profiles`. Safe import preflight: run `oxygen mailboxes compatibility --catalog-only --json`, then `oxygen mailboxes import --file <path> --validate-only --json` (add the credential source flags shown by import help when the file contains credentials).")
14533
15051
  .addCommand(new Command("list")
14534
- .description("List the org's sending mailboxes with provider, status, warmup state, source (managed = bought through Oxygen, byok = bring-your-own), worker-owned connectionRepair and warmupRepair state, and a pool overview (including counts by source). Read each mailbox's warmupTruth for warm-up (state, last_send, pause, dispatch, next_action with the exact command); warmupState is only the stored rail token and reads `error` for a provider-paused seat. mode=automatic means OXYGEN owns the next bounded retry — do not ask for browser consent or disable/re-enable an existing warm-up seat. To see only Google/Microsoft mailboxes that still need OAuth connection, including attempt budgets and manual remedies, use `oxygen mailboxes oauth-health --json`. Read the fleet from `summary` — `summary.by_warmup`, `by_status`, `by_provider`, `by_auth_mode`, `by_transport`, and `by_source` already aggregate every mailbox, so you never need to iterate the `mailboxes` array to count them.")
15052
+ .description("List the org's sending mailboxes with provider, status, warmup state, source (managed = bought through Oxygen, byok = bring-your-own), worker-owned connectionRepair and warmupRepair state, and a pool overview (including counts by source). Read each mailbox's warmupTruth for warm-up (state, last_send, pause, dispatch, next_action with the exact command); warmupState is only the stored rail token and reads `error` for a provider-paused seat. mode=automatic means OXYGEN owns the next bounded retry — do not ask for browser consent or disable/re-enable an existing warm-up seat. To see only Google/Microsoft mailboxes that still need OAuth connection, including attempt budgets and manual remedies, use `oxygen mailboxes oauth-health --json`. Read the fleet from `summary` — `summary.by_warmup`, `by_status`, `by_provider`, `by_auth_mode`, `by_transport`, and `by_source` already aggregate every mailbox, so you never need to iterate the `mailboxes` array to count them. `summary.warmup` adds the normalized pool counts — by_truth_state, by_next_action, enrolled, stalled (enrolled with a measured zero warm-up sends), and needs_action — and `--warmup <states>`, `--next-action <codes>`, and `--stalled` narrow the list to exactly those mailboxes, so \"which of my inboxes are not sending\" is one command rather than a local transform over every row. Without `--json` this prints a readable pool: the counts, then every mailbox that owes an action with its cause and the exact command to run.")
14535
15053
  .option("--status <status>", "Filter by status: active, paused, disabled, or provisioning (ordered, still being set up).")
14536
15054
  .option("--tag <tags>", "Comma-separated workspace tags — matches mailboxes carrying ANY of these tags (see `oxygen tags list`).")
14537
- .option("--json", "Print a JSON envelope.")
15055
+ .option("--warmup <states>", "Show only mailboxes whose warm-up is in one of these states (comma-separated): warming, active, paused, error, disabled, pending, not_enrolled, unknown.")
15056
+ .option("--next-action <codes>", "Show only mailboxes whose warm-up needs one of these next steps (comma-separated): none, resume_warmup, reconnect_warmup, enable_warmup, connect_oauth, fix_dns, wait, contact_support.")
15057
+ .option("--stalled", "Show only mailboxes enrolled in warm-up with zero recorded sends (warmupTruth.dispatch.sent null or 0) — the seats you are paying for that are silent. In practice these are the `pending` truth state; an `error` seat has usually sent some mail before it stopped, so it is not stalled.")
15058
+ .option("--json", "Print a JSON envelope. The full payload carries per-mailbox daily warm-up metrics, so a large fleet is megabytes — narrow it with --stalled / --warmup / --next-action, or read the default human view, which already aggregates the pool.")
14538
15059
  .action(async (options) => {
14539
- await handleAsyncAction("mailboxes list", options, () => {
14540
- const params = new URLSearchParams();
14541
- const status = readOption(options.status);
14542
- if (status)
14543
- params.set("status", status);
14544
- const tags = splitCommaList(options.tag);
14545
- if (tags.length > 0)
14546
- params.set("tag", tags.join(","));
14547
- const suffix = params.toString();
14548
- return requestOxygen(`/api/cli/mailboxes${suffix ? `?${suffix}` : ""}`);
15060
+ const params = new URLSearchParams();
15061
+ const status = readOption(options.status);
15062
+ if (status)
15063
+ params.set("status", status);
15064
+ const tags = splitCommaList(options.tag);
15065
+ if (tags.length > 0)
15066
+ params.set("tag", tags.join(","));
15067
+ const warmupStates = splitCommaList(options.warmup);
15068
+ if (warmupStates.length > 0)
15069
+ params.set("warmup", warmupStates.join(","));
15070
+ const nextActions = splitCommaList(options.nextAction);
15071
+ if (nextActions.length > 0)
15072
+ params.set("next_action", nextActions.join(","));
15073
+ // Only ever sent as `true`. Omitted when the flag is absent so the
15074
+ // server never has to decide what an unasked-for `stalled=false` meant.
15075
+ if (options.stalled)
15076
+ params.set("stalled", "true");
15077
+ const suffix = params.toString();
15078
+ // What this CLI asked for, kept as flags in query order: a server too
15079
+ // old to echo `filters` back still must not let a match count read as
15080
+ // the whole pool. Every facet narrows the same counts, so every facet
15081
+ // is here — status and tag included.
15082
+ const filterFlags = [];
15083
+ if (status)
15084
+ filterFlags.push(`--status ${status}`);
15085
+ if (tags.length > 0)
15086
+ filterFlags.push(`--tag ${tags.join(",")}`);
15087
+ if (warmupStates.length > 0)
15088
+ filterFlags.push(`--warmup ${warmupStates.join(",")}`);
15089
+ if (nextActions.length > 0)
15090
+ filterFlags.push(`--next-action ${nextActions.join(",")}`);
15091
+ if (options.stalled)
15092
+ filterFlags.push("--stalled");
15093
+ // Bypasses handleAsyncAction the way `tables link` does: --json stays
15094
+ // the byte-identical envelope, and a terminal gets a pool a person can
15095
+ // read instead of the raw blob it used to print.
15096
+ const result = await requestOxygen(`/api/cli/mailboxes${suffix ? `?${suffix}` : ""}`).catch((error) => {
15097
+ emitCliFailure("mailboxes list", error);
15098
+ return null;
14549
15099
  });
15100
+ if (!result)
15101
+ return;
15102
+ if (options.json) {
15103
+ emitSuccess("mailboxes list", result, options);
15104
+ return;
15105
+ }
15106
+ writeMailboxPoolOverview(result, { filtered: suffix.length > 0, filterFlags });
14550
15107
  }))
14551
15108
  .addCommand(new Command("get")
14552
15109
  .description("Get one sending mailbox's configuration/readiness detail (provider, status, daily cap, warmup state, auth mode, and source — managed vs bring-your-own) plus a one-row pool summary. This is not sent/replied/bounced performance; use `oxygen sequences analytics` for native Sequence attribution by mailbox/domain. An ineligible native-send transport includes transport_reason + transport_hint; it does not by itself block OXYGEN Warm-up or EmailGuard monitoring. <mailbox> accepts a mailbox id or email address.")
@@ -14591,7 +15148,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
14591
15148
  await handleAsyncAction("mailboxes health", options, () => requestOxygen("/api/cli/mailboxes/health"));
14592
15149
  }))
14593
15150
  .addCommand(new Command("compatibility")
14594
- .description("Read-only compatibility report for every selected mailbox: generic origin class, real infrastructure tier (including InboxKit Azure), OXYGEN native-send connection quality, OXYGEN Warm-up path, EmailGuard monitoring path, and exact next actions. Read automatic_repair for native send and warmup_automatic_repair for an existing warm-up seat: mode=automatic means OXYGEN owns the next bounded retry. Native send distinguishes destination-bound OAuth, provider-managed API transport, admin delegation, and authorization_required. Use --catalog-only for the compact workspace-independent import contract. JSON data.compatibility contains checked rows; data.import_methods and data.import_fields describe accepted transfer inputs; data.provider_matrix is the current public product matrix; data.non_transferable_auth names credentials that must be reconnected; data.summary rolls up states; data.web_url opens the pool. Downstream states distinguish credential_required, consent_required, and vendor_blocked. Never decrypts a credential or calls a downstream provider.")
15151
+ .description("Read-only compatibility report per mailbox — select with --mailboxes <address,…> (omit for the whole pool; filter here, not in a script): generic origin class, real infrastructure tier (including InboxKit Azure), OXYGEN native-send connection quality, OXYGEN Warm-up path, EmailGuard monitoring path, and exact next actions. Read automatic_repair for native send and warmup_automatic_repair for an existing warm-up seat: mode=automatic means OXYGEN owns the next bounded retry. Native send distinguishes destination-bound OAuth, provider-managed API transport, admin delegation, and authorization_required. Use --catalog-only for the compact workspace-independent import contract. JSON data.compatibility contains checked rows; data.import_methods and data.import_fields describe accepted transfer inputs; data.provider_matrix is the current public product matrix; data.non_transferable_auth names credentials that must be reconnected; data.summary rolls up states; data.web_url opens the pool. Downstream states distinguish credential_required, consent_required, and vendor_blocked. Never decrypts a credential or calls a downstream provider.")
14595
15152
  .option("--mailboxes <list>", "Comma-separated mailbox ids or addresses. Omit for the whole pool.")
14596
15153
  .option("--catalog-only", "Return only the bounded import-method, field, auth-boundary, and public provider catalogs; do not read or return workspace mailbox rows.")
14597
15154
  .option("--json", "Print a JSON envelope.")
@@ -14613,11 +15170,11 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
14613
15170
  });
14614
15171
  }))
14615
15172
  .addCommand(new Command("import")
14616
- .description("Register (or refresh) sending mailboxes in bulk. Use --validate-only with a local file for a non-mutating, no-network preflight; without it, import is a 0-credit state mutation whose upsert is idempotent by mailbox address. CSV/JSON/JSONL/XLSX identity files from any vendor use --file plus optional --vendor. Compatible Google app-password exports use --from credentials --vendor <source>. Connected Zapmail inventories use --from zapmail. In credential files, app_password is Google-only; Microsoft password fields are discarded locally. OAuth grants, MFA/TOTP seeds, and delegation keys never transfer from customer files: fresh managed InboxKit Google orders reuse the vendor-held credential just in time for automatic warmup, while fresh managed Microsoft/Azure orders use native InboxKit Sequencer export; opted-out/separate managed enrollment uses standalone `warmup enable`; eligible non-InboxKit Microsoft warmup uses the Outlook OAuth fallback (`mailboxes warmup microsoft`); EmailGuard remains vendor_blocked for Microsoft. Google app passwords supplied through an authorized credential import are sent only in the request body, encrypted server-side, and never returned.")
15173
+ .description("Register (or refresh) sending mailboxes in bulk. Use --validate-only with a local file for a non-mutating, no-network preflight; without it, import is a 0-credit state mutation whose upsert is idempotent by mailbox address. CSV/JSON/JSONL/XLSX identity files from any vendor use --file plus optional --vendor. Compatible Google app-password exports use --from credentials --vendor <source>. Connected Zapmail inventories use --from zapmail. In credential files, app_password is Google-only; Microsoft password fields are discarded locally. OAuth grants, MFA/TOTP seeds, and delegation keys never transfer from customer files: fresh managed InboxKit Google orders reuse the vendor-held credential just in time for automatic warmup, while fresh managed Microsoft/Azure orders use native InboxKit Sequencer export; opted-out/separate managed enrollment uses standalone `warmup enable`; eligible non-InboxKit Microsoft warmup uses the Outlook OAuth fallback (`mailboxes warmup microsoft`); EmailGuard remains vendor_blocked for Microsoft. Google app passwords supplied through an authorized credential import are sent only in the request body, encrypted server-side, and never returned. Every imported inbox is attached to a sender profile: --sender <id> names the person, a row's first/last or display name finds or creates them, otherwise OXYGEN creates a profile named from the address for you to correct.")
14617
15174
  .addHelpText("after", [
14618
15175
  "",
14619
15176
  "Ordinary identity file contract (CSV / JSON / JSONL / XLSX):",
14620
- " Canonical fields: email_address, provider, workspace_external_id?, infrastructure_platform?, tenant_id?. Common vendor aliases such as Email, From Email, ESP, Mailbox ID, and Entra Tenant ID are mapped locally. Credentials are rejected.",
15177
+ " Canonical fields: email_address, provider, workspace_external_id?, infrastructure_platform?, tenant_id?, first_name?, last_name?, display_name?, sender_profile_id?. Common vendor aliases such as Email, From Email, ESP, Mailbox ID, Entra Tenant ID, Given Name, Surname, and From Name are mapped locally. Credentials are rejected.",
14621
15178
  "",
14622
15179
  "All local file imports:",
14623
15180
  " Limits: 500 rows / 5 MB for identity and credential files.",
@@ -14635,6 +15192,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
14635
15192
  .option("--vendor <slug>", "Non-secret source provenance (for example instantly, mailforge, or smartlead). Required with --from credentials; optional for identity files.")
14636
15193
  .option("--connection <id>", "Zapmail connection id (--from zapmail). Defaults to the org's active Zapmail connection.")
14637
15194
  .option("--provider <provider>", "Zapmail pool to pull (--from zapmail): google or microsoft. Zapmail's mailbox list is provider-scoped, so the Microsoft pool is only reachable with --provider microsoft; Microsoft mailboxes get their Entra tenant id stamped on import.")
15195
+ .option("--sender <profileId>", "Sender profile every imported inbox belongs to (`oxygen senders profiles list` for ids, `create` for a new person). A row's own sender_profile_id or first/last name wins over it; without either, OXYGEN creates a profile named from the address.")
14638
15196
  .option("--validate-only", "Parse, normalize, and policy-check a local file, then return non-secret aggregate counts without authentication, network access, provider calls, or workspace writes.")
14639
15197
  .option("--json", "Print a JSON envelope.")
14640
15198
  .action(async (options) => {
@@ -14644,6 +15202,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
14644
15202
  const vendor = readOption(options.vendor);
14645
15203
  const connection = readOption(options.connection);
14646
15204
  const provider = readOption(options.provider);
15205
+ const sender = readOption(options.sender);
14647
15206
  const validateOnly = options.validateOnly === true;
14648
15207
  if (from &&
14649
15208
  from !== "zapmail" &&
@@ -14667,6 +15226,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
14667
15226
  source: "zapmail",
14668
15227
  ...(connection ? { connection_id: connection } : {}),
14669
15228
  ...(provider ? { service_provider: provider } : {}),
15229
+ ...(sender ? { sender_profile_id: sender } : {}),
14670
15230
  },
14671
15231
  });
14672
15232
  }
@@ -14702,6 +15262,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
14702
15262
  ...(sourceProvider
14703
15263
  ? { source_provider: sourceProvider }
14704
15264
  : {}),
15265
+ ...(sender ? { sender_profile_id: sender } : {}),
14705
15266
  mailboxes,
14706
15267
  },
14707
15268
  });
@@ -14951,7 +15512,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
14951
15512
  .option("--arm", "Arm the standing permission. Requires --max-credits; previews unless --approved.")
14952
15513
  .option("--disarm", "Turn it off. Existing subscriptions are untouched. 0 credits, no approval needed.")
14953
15514
  .option("--run", "Enrol one bounded batch now.")
14954
- .option("--dry-run", "With --run: report what would be enrolled without connecting or charging.")
15515
+ .option("--dry-run", "With --run: report what would be enrolled without connecting or charging. Mailboxes held back by OXYGEN's own managed capacity are reported once under platform_condition (owner: oxygen) rather than as per-mailbox skips; skip_summary counts the rest by reason.")
14955
15516
  .option("--max-credits <n>", "Per-billing-cycle credit ceiling this permission may spend (required with --arm).")
14956
15517
  .option("--approved", "Actually arm (otherwise --arm returns a preview).")
14957
15518
  .option("--json", "Print a JSON envelope.")
@@ -15213,7 +15774,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
15213
15774
  });
15214
15775
  }))
15215
15776
  .addCommand(new Command("resume")
15216
- .description("Resume paused warmup at each mailbox's recorded rail (OXYGEN Warm-up, or TrulyInbox for inboxes still warming there). A mailbox on a rail Oxygen no longer drives reports unsupported instead of being retargeted. Targets the whole pool unless --mailboxes is given.")
15777
+ .description("Resume paused warmup at each mailbox's recorded rail (OXYGEN Warm-up, or TrulyInbox for inboxes still warming there). 0 Oxygen credits; the existing subscription is unchanged. This changes warmup state and uses the last stored analytics snapshot; run warmup status afterward for fresh provider data. Running status alone does not prove a new successful send. A mailbox on a rail Oxygen no longer drives reports unsupported instead of being retargeted. Targets the whole pool unless --mailboxes is given.")
15217
15778
  .option("--mailboxes <list>", "Comma-separated mailbox ids or addresses. Omit for the whole pool.")
15218
15779
  .option("--json", "Print a JSON envelope.")
15219
15780
  .action(async (options) => {
@@ -16673,6 +17234,7 @@ Full trigger schema: oxygen workflows schema --subject trigger --json
16673
17234
  registerVisualCommands(program, handleAsyncAction);
16674
17235
  registerFunctionsCommands(program, handleAsyncAction);
16675
17236
  registerUgcCommands(program, handleAsyncAction);
17237
+ registerKnowledgeRepositoryCommands(program, handleAsyncAction);
16676
17238
  return program;
16677
17239
  }
16678
17240
  /**
@@ -19260,6 +19822,7 @@ async function importRows(table, options) {
19260
19822
  let lastResult = null;
19261
19823
  let writeRequestCount = 0;
19262
19824
  let requestTooLargeRetries = 0;
19825
+ let byteSplits = 0;
19263
19826
  let minimumBatchSizeUsed = null;
19264
19827
  for (const [batchIndex, batch] of chunk(target.rows, effectiveBatchSize).entries()) {
19265
19828
  const result = await writeImportBatchWithAutoSplit({
@@ -19281,6 +19844,7 @@ async function importRows(table, options) {
19281
19844
  warningsTruncated = warningsTruncated || result.warningsTruncated || warningCount > warnings.length;
19282
19845
  writeRequestCount += result.writeRequestCount;
19283
19846
  requestTooLargeRetries += result.requestTooLargeRetries;
19847
+ byteSplits += result.byteSplits;
19284
19848
  minimumBatchSizeUsed = minimumBatchSizeUsed === null
19285
19849
  ? result.minimumBatchSizeUsed
19286
19850
  : Math.min(minimumBatchSizeUsed, result.minimumBatchSizeUsed);
@@ -19300,10 +19864,11 @@ async function importRows(table, options) {
19300
19864
  batchCount: writeRequestCount,
19301
19865
  batchSize: effectiveBatchSize,
19302
19866
  ...(effectiveBatchSize !== batchSize ? { requestedBatchSize: batchSize } : {}),
19303
- ...(requestTooLargeRetries > 0 ? {
19304
- requestTooLargeRetries,
19305
- minimumBatchSizeUsed,
19306
- } : {}),
19867
+ // Two separate counters: a byte pre-split cost nothing, a server 413 cost a
19868
+ // wasted upload — reporting them as one number would hide which happened.
19869
+ ...(requestTooLargeRetries > 0 ? { requestTooLargeRetries } : {}),
19870
+ ...(byteSplits > 0 ? { byteSplits } : {}),
19871
+ ...(requestTooLargeRetries > 0 || byteSplits > 0 ? { minimumBatchSizeUsed } : {}),
19307
19872
  ...(target.tableWebUrl ? { table_web_url: target.tableWebUrl } : {}),
19308
19873
  ...(readRecordString(lastResult, "web_url") ? { web_url: readRecordString(lastResult, "web_url") } : {}),
19309
19874
  };
@@ -19432,38 +19997,58 @@ function remapImportRows(rows, sourceKeyMap) {
19432
19997
  });
19433
19998
  }
19434
19999
  async function writeImportBatchWithAutoSplit(input) {
20000
+ const body = {
20001
+ table: input.tableRef,
20002
+ rows: input.rows,
20003
+ ...(input.upsertKey ? { key: input.upsertKey } : {}),
20004
+ ...(!input.upsertKey && input.requestId ? { request_id: input.requestId } : {}),
20005
+ };
20006
+ // The server checks content-length against MAX_CLI_JSON_BODY_BYTES and the
20007
+ // client sends exactly JSON.stringify(body), so measuring it here is the same
20008
+ // test — a wide batch (large text cells) splits before it costs an upload and a
20009
+ // 413. Same halving and request_id suffixes as the reactive path below, so a
20010
+ // re-run replays the identical batch ids either way.
20011
+ const bodyBytes = Buffer.byteLength(JSON.stringify(body));
20012
+ if (bodyBytes > MAX_CLI_JSON_BODY_BYTES && input.rows.length > 1) {
20013
+ const midpoint = Math.ceil(input.rows.length / 2);
20014
+ process.stderr.write(`note: import batch of ${input.rows.length} rows serializes to ${bodyBytes} bytes, `
20015
+ + `over the ${MAX_CLI_JSON_BODY_BYTES} byte request limit; `
20016
+ + `sending as ${midpoint} and ${input.rows.length - midpoint} row batches.\n`);
20017
+ const [first, second] = await writeImportBatchHalves(input, midpoint);
20018
+ return combineImportBatchWriteSummaries(first, second, { byteSplits: 1 });
20019
+ }
19435
20020
  try {
19436
20021
  const result = await requestOxygen(input.upsertKey ? "/api/cli/tables/rows/upsert" : "/api/cli/tables/rows", {
19437
20022
  method: "POST",
19438
20023
  timeoutMs: 300_000,
19439
- body: {
19440
- table: input.tableRef,
19441
- rows: input.rows,
19442
- ...(input.upsertKey ? { key: input.upsertKey } : {}),
19443
- ...(!input.upsertKey && input.requestId ? { request_id: input.requestId } : {}),
19444
- },
20024
+ body,
19445
20025
  });
19446
20026
  return summarizeImportBatchWrite(result, input.rows.length);
19447
20027
  }
19448
20028
  catch (error) {
19449
20029
  if (!isRequestTooLargeError(error) || input.rows.length <= 1)
19450
20030
  throw error;
20031
+ // Fallback for a server whose ceiling is tighter than the shared constant.
19451
20032
  const midpoint = Math.ceil(input.rows.length / 2);
19452
20033
  process.stderr.write(`note: import batch of ${input.rows.length} rows exceeded the request limit; `
19453
20034
  + `retrying as ${midpoint} and ${input.rows.length - midpoint} row batches.\n`);
19454
- const first = await writeImportBatchWithAutoSplit({
19455
- ...input,
19456
- rows: input.rows.slice(0, midpoint),
19457
- ...(input.requestId ? { requestId: `${input.requestId}:0` } : {}),
19458
- });
19459
- const second = await writeImportBatchWithAutoSplit({
19460
- ...input,
19461
- rows: input.rows.slice(midpoint),
19462
- ...(input.requestId ? { requestId: `${input.requestId}:1` } : {}),
19463
- });
19464
- return combineImportBatchWriteSummaries(first, second, 1);
20035
+ const [first, second] = await writeImportBatchHalves(input, midpoint);
20036
+ return combineImportBatchWriteSummaries(first, second, { requestTooLargeRetries: 1 });
19465
20037
  }
19466
20038
  }
20039
+ async function writeImportBatchHalves(input, midpoint) {
20040
+ const first = await writeImportBatchWithAutoSplit({
20041
+ ...input,
20042
+ rows: input.rows.slice(0, midpoint),
20043
+ ...(input.requestId ? { requestId: `${input.requestId}:0` } : {}),
20044
+ });
20045
+ const second = await writeImportBatchWithAutoSplit({
20046
+ ...input,
20047
+ rows: input.rows.slice(midpoint),
20048
+ ...(input.requestId ? { requestId: `${input.requestId}:1` } : {}),
20049
+ });
20050
+ return [first, second];
20051
+ }
19467
20052
  function summarizeImportBatchWrite(result, batchSize) {
19468
20053
  const warningCount = readCount(result.warningCount);
19469
20054
  const batchWarnings = Array.isArray(result.warnings) ? result.warnings : [];
@@ -19476,11 +20061,12 @@ function summarizeImportBatchWrite(result, batchSize) {
19476
20061
  warningsTruncated: result.warningsTruncated === true || warningCount > batchWarnings.length,
19477
20062
  writeRequestCount: 1,
19478
20063
  requestTooLargeRetries: 0,
20064
+ byteSplits: 0,
19479
20065
  minimumBatchSizeUsed: batchSize,
19480
20066
  lastResult: result,
19481
20067
  };
19482
20068
  }
19483
- function combineImportBatchWriteSummaries(first, second, extraRequestTooLargeRetries) {
20069
+ function combineImportBatchWriteSummaries(first, second, extra) {
19484
20070
  const warnings = [...first.warnings, ...second.warnings].slice(0, 20);
19485
20071
  const warningCount = first.warningCount + second.warningCount;
19486
20072
  return {
@@ -19491,7 +20077,8 @@ function combineImportBatchWriteSummaries(first, second, extraRequestTooLargeRet
19491
20077
  warnings,
19492
20078
  warningsTruncated: first.warningsTruncated || second.warningsTruncated || warningCount > warnings.length,
19493
20079
  writeRequestCount: first.writeRequestCount + second.writeRequestCount,
19494
- requestTooLargeRetries: first.requestTooLargeRetries + second.requestTooLargeRetries + extraRequestTooLargeRetries,
20080
+ requestTooLargeRetries: first.requestTooLargeRetries + second.requestTooLargeRetries + (extra.requestTooLargeRetries ?? 0),
20081
+ byteSplits: first.byteSplits + second.byteSplits + (extra.byteSplits ?? 0),
19495
20082
  minimumBatchSizeUsed: Math.min(first.minimumBatchSizeUsed, second.minimumBatchSizeUsed),
19496
20083
  lastResult: second.lastResult ?? first.lastResult,
19497
20084
  };
@@ -20045,12 +20632,33 @@ async function listTableRuns(options) {
20045
20632
  runCount: runs.length,
20046
20633
  output: outputPath ?? null,
20047
20634
  ...(outputPath ? {} : { content: formatted.content }),
20635
+ // An empty default listing says which finished runs it hid; keep that
20636
+ // pointer when the history is formatted instead of returned as an envelope.
20637
+ ...(typeof result.hint === "string"
20638
+ ? { hidden_run_count: result.hidden_run_count ?? null, hint: result.hint, next_actions: result.next_actions ?? [] }
20639
+ : {}),
20048
20640
  ...(typeof result.web_url === "string" ? { web_url: result.web_url } : {}),
20049
20641
  };
20050
20642
  }
20051
- const TABLE_BUNDLE_SCHEMA_VERSION = 1;
20643
+ // Bundle formats. Version 2 is line-delimited: a header line carrying the
20644
+ // schema, then one `{"row":…}` line per row, then a `{"totals":…}` trailer.
20645
+ // It exists because the version-1 single-document bundle is assembled in memory
20646
+ // and serialized once, and a 52k-row table carrying LinkedIn profile text pushed
20647
+ // that one string past V8's limit ("Invalid string length") on 2026-09-14: the
20648
+ // export died after twelve minutes with nothing on disk, and a 96-column table
20649
+ // on the other side of the same copy could never have been read back into
20650
+ // memory by the importer either. `--output` now streams version 2; import reads
20651
+ // both versions, streaming version 2 so file size no longer matters.
20652
+ const TABLE_BUNDLE_SCHEMA_VERSION = 2;
20653
+ const TABLE_BUNDLE_LEGACY_SCHEMA_VERSION = 1;
20654
+ const TABLE_BUNDLE_SUPPORTED_SCHEMA_VERSIONS = [TABLE_BUNDLE_LEGACY_SCHEMA_VERSION, TABLE_BUNDLE_SCHEMA_VERSION];
20052
20655
  const TABLE_BUNDLE_MAX_PAGE_SIZE = 1000;
20053
20656
  const TABLE_BUNDLE_DEFAULT_PAGE_SIZE = 500;
20657
+ // How much of a bundle file is read to find its header line and totals trailer
20658
+ // without loading the whole file: 8 MiB comfortably holds a header for a
20659
+ // 100-column table with long AI prompts, and the trailer is one short line.
20660
+ const TABLE_BUNDLE_HEADER_SCAN_BYTES = 8 * 1024 * 1024;
20661
+ const TABLE_BUNDLE_TRAILER_SCAN_BYTES = 1024 * 1024;
20054
20662
  // skipcq: JS-R1005 — intentional branching across describe/query pagination, column-definition normalization, and output sink (stdout/file)
20055
20663
  async function exportTableBundle(table, options) {
20056
20664
  const pageSize = Math.min(readPositiveInt(options.pageSize) ?? TABLE_BUNDLE_DEFAULT_PAGE_SIZE, TABLE_BUNDLE_MAX_PAGE_SIZE);
@@ -20060,96 +20668,198 @@ async function exportTableBundle(table, options) {
20060
20668
  const describe = await requestOxygen("/api/cli/tables/describe", { method: "POST", body: { table } });
20061
20669
  const tableMeta = describe.table ?? null;
20062
20670
  const columns = (describe.columns ?? []).map(toBundleColumn);
20671
+ const tableId = readRecordString(tableMeta, "id");
20672
+ const tableSlug = readRecordString(tableMeta, "slug");
20673
+ const projectSlug = readRecordString(tableMeta, "projectSlug");
20674
+ const tableSummary = {
20675
+ id: tableId ?? null,
20676
+ slug: tableSlug ?? null,
20677
+ name: readRecordString(tableMeta, "displayName") ?? readRecordString(tableMeta, "name") ?? null,
20678
+ projectSlug: projectSlug ?? null,
20679
+ };
20680
+ const exportedAt = new Date().toISOString();
20681
+ // With --output the bundle streams to disk one row per line, so memory stays
20682
+ // flat however many rows the table has. The file is written under a .partial
20683
+ // name and renamed only after the totals trailer lands, so an interrupted
20684
+ // export can never be mistaken for a complete bundle.
20685
+ const outputPath = options.output ? resolve(options.output) : null;
20686
+ const partialPath = outputPath ? `${outputPath}.partial` : null;
20687
+ const stream = partialPath ? createWriteStream(partialPath, { encoding: "utf8" }) : null;
20063
20688
  const rows = [];
20064
20689
  let cursor = null;
20065
20690
  let expectedTotal = null;
20066
20691
  let pageCount = 0;
20692
+ let rowCount = 0;
20067
20693
  let hasMoreFlag = false;
20068
- do {
20069
- const requestBody = { table, limit: pageSize };
20070
- if (cursor)
20071
- requestBody.cursor = cursor;
20072
- const page = await requestOxygen("/api/cli/tables/query", { method: "POST", body: requestBody });
20073
- pageCount += 1;
20074
- if (expectedTotal === null) {
20075
- expectedTotal = readBundleNumber(page.totalCount) ?? readBundleNumber(page.total_count) ?? null;
20694
+ try {
20695
+ if (stream) {
20696
+ await writeBundleLine(stream, JSON.stringify({
20697
+ schemaVersion: TABLE_BUNDLE_SCHEMA_VERSION,
20698
+ exportedAt,
20699
+ table: tableSummary,
20700
+ columns,
20701
+ }));
20076
20702
  }
20077
- for (const row of page.rows ?? [])
20078
- rows.push(row);
20079
- cursor = typeof page.nextCursor === "string" && page.nextCursor.length > 0 ? page.nextCursor : null;
20080
- hasMoreFlag = Boolean(page.hasMore);
20081
- // Defensive: hasMore=false should always mean cursor=null. If a future
20082
- // server change ever sets one without the other, stop iterating on
20083
- // hasMore=false so we don't loop forever.
20084
- if (!hasMoreFlag)
20085
- cursor = null;
20086
- } while (cursor);
20087
- if (expectedTotal !== null && rows.length !== expectedTotal) {
20088
- throw new OxygenError("bundle_export_incomplete", "Cursor pagination ended before every row was written. The bundle would be a silent under-export.", {
20089
- details: {
20090
- table,
20091
- totalCount: expectedTotal,
20092
- exportedCount: rows.length,
20093
- missing: expectedTotal - rows.length,
20094
- pages: pageCount,
20095
- },
20096
- exitCode: 1,
20097
- });
20098
- }
20099
- const tableId = readRecordString(tableMeta, "id");
20100
- const tableSlug = readRecordString(tableMeta, "slug");
20101
- const projectSlug = readRecordString(tableMeta, "projectSlug");
20102
- const bundle = {
20103
- schemaVersion: TABLE_BUNDLE_SCHEMA_VERSION,
20104
- exportedAt: new Date().toISOString(),
20105
- table: {
20106
- id: tableId ?? null,
20107
- slug: tableSlug ?? null,
20108
- name: readRecordString(tableMeta, "displayName") ?? readRecordString(tableMeta, "name") ?? null,
20109
- projectSlug: projectSlug ?? null,
20110
- },
20111
- columns,
20112
- rows,
20113
- totals: {
20114
- rowCount: rows.length,
20703
+ do {
20704
+ const requestBody = { table, limit: pageSize };
20705
+ if (cursor)
20706
+ requestBody.cursor = cursor;
20707
+ const page = await requestOxygen("/api/cli/tables/query", { method: "POST", body: requestBody });
20708
+ pageCount += 1;
20709
+ if (expectedTotal === null) {
20710
+ expectedTotal = readBundleNumber(page.totalCount) ?? readBundleNumber(page.total_count) ?? null;
20711
+ }
20712
+ for (const row of page.rows ?? []) {
20713
+ rowCount += 1;
20714
+ if (stream)
20715
+ await writeBundleLine(stream, JSON.stringify({ row }));
20716
+ else
20717
+ rows.push(row);
20718
+ }
20719
+ cursor = typeof page.nextCursor === "string" && page.nextCursor.length > 0 ? page.nextCursor : null;
20720
+ hasMoreFlag = Boolean(page.hasMore);
20721
+ // Defensive: hasMore=false should always mean cursor=null. If a future
20722
+ // server change ever sets one without the other, stop iterating on
20723
+ // hasMore=false so we don't loop forever.
20724
+ if (!hasMoreFlag)
20725
+ cursor = null;
20726
+ } while (cursor);
20727
+ if (expectedTotal !== null && rowCount !== expectedTotal) {
20728
+ throw new OxygenError("bundle_export_incomplete", "Cursor pagination ended before every row was written. The bundle would be a silent under-export.", {
20729
+ details: {
20730
+ table,
20731
+ totalCount: expectedTotal,
20732
+ exportedCount: rowCount,
20733
+ missing: expectedTotal - rowCount,
20734
+ pages: pageCount,
20735
+ },
20736
+ exitCode: 1,
20737
+ });
20738
+ }
20739
+ const totals = {
20740
+ rowCount,
20115
20741
  ...(expectedTotal !== null ? { sourceTotalCount: expectedTotal } : {}),
20116
20742
  pages: pageCount,
20117
20743
  pageSize,
20118
- },
20119
- };
20120
- if (options.output) {
20121
- writeFileSync(options.output, `${JSON.stringify(bundle, null, 2)}\n`);
20744
+ };
20745
+ if (stream && partialPath && outputPath) {
20746
+ await writeBundleLine(stream, JSON.stringify({ totals }));
20747
+ await finishBundleStream(stream);
20748
+ renameSync(partialPath, outputPath);
20749
+ }
20750
+ const summary = {
20751
+ table_id: tableId ?? null,
20752
+ table_slug: tableSlug ?? null,
20753
+ schema_version: stream ? TABLE_BUNDLE_SCHEMA_VERSION : TABLE_BUNDLE_LEGACY_SCHEMA_VERSION,
20754
+ format: stream ? "jsonl" : "json",
20755
+ column_count: columns.length,
20756
+ row_count: rowCount,
20757
+ pages: pageCount,
20758
+ page_size: pageSize,
20759
+ output: options.output ?? null,
20760
+ ...(tableId ? { web_url: tableWebUrl(tableId) } : tableSlug ? { web_url: tableWebUrl(tableSlug) } : {}),
20761
+ };
20762
+ // With --output, the file is the source of truth; keep the stdout response
20763
+ // a small summary so it's readable.
20764
+ if (stream)
20765
+ return summary;
20766
+ // When piping to stdout, emit the full single-document bundle so it can be
20767
+ // redirected into a file. That document has to fit in one string, so say
20768
+ // so — with the fix — instead of dying on a bare RangeError.
20769
+ const bundle = {
20770
+ schemaVersion: TABLE_BUNDLE_LEGACY_SCHEMA_VERSION,
20771
+ exportedAt,
20772
+ table: tableSummary,
20773
+ columns,
20774
+ rows,
20775
+ totals,
20776
+ };
20777
+ assertBundleSerializable(bundle, rowCount);
20778
+ return { ...summary, bundle };
20779
+ }
20780
+ catch (error) {
20781
+ if (stream) {
20782
+ stream.destroy();
20783
+ if (partialPath) {
20784
+ try {
20785
+ unlinkSync(partialPath);
20786
+ }
20787
+ catch {
20788
+ // The partial file may never have been created; nothing to clean.
20789
+ }
20790
+ }
20791
+ }
20792
+ throw error;
20793
+ }
20794
+ }
20795
+ // Writes one bundle line and honors the stream's backpressure, so a 50k-row
20796
+ // export never accumulates the file in memory while the disk catches up.
20797
+ function writeBundleLine(stream, line) {
20798
+ return new Promise((resolveLine, rejectLine) => {
20799
+ const onError = (error) => rejectLine(error);
20800
+ if (stream.write(`${line}\n`, "utf8")) {
20801
+ resolveLine();
20802
+ return;
20803
+ }
20804
+ stream.once("error", onError);
20805
+ stream.once("drain", () => {
20806
+ stream.off("error", onError);
20807
+ resolveLine();
20808
+ });
20809
+ });
20810
+ }
20811
+ function finishBundleStream(stream) {
20812
+ return new Promise((resolveStream, rejectStream) => {
20813
+ stream.once("error", rejectStream);
20814
+ stream.end(() => resolveStream());
20815
+ });
20816
+ }
20817
+ function assertBundleSerializable(bundle, rowCount) {
20818
+ try {
20819
+ JSON.stringify(bundle);
20820
+ }
20821
+ catch (error) {
20822
+ if (error instanceof RangeError) {
20823
+ throw new OxygenError("bundle_too_large", `This table's ${rowCount} rows do not fit in one JSON document. Re-run with --output <path>: the bundle then streams to the file one row per line, with no size limit.`, { details: { row_count: rowCount }, exitCode: 1 });
20824
+ }
20825
+ throw error;
20122
20826
  }
20123
- const summary = {
20124
- table_id: tableId ?? null,
20125
- table_slug: tableSlug ?? null,
20126
- schema_version: TABLE_BUNDLE_SCHEMA_VERSION,
20127
- column_count: columns.length,
20128
- row_count: rows.length,
20129
- pages: pageCount,
20130
- page_size: pageSize,
20131
- output: options.output ?? null,
20132
- ...(tableId ? { web_url: tableWebUrl(tableId) } : tableSlug ? { web_url: tableWebUrl(tableSlug) } : {}),
20133
- };
20134
- // When piping to stdout, also emit the full bundle so it can be redirected
20135
- // into a file. With --output, the file is the source of truth; keep the
20136
- // stdout response a small summary so it's readable.
20137
- return options.output ? summary : { ...summary, bundle };
20138
20827
  }
20139
20828
  async function importTableBundle(// skipcq: JS-R1005
20140
- options) {
20829
+ options, binaryName) {
20141
20830
  const path = options.file;
20142
20831
  if (!path || !path.trim()) {
20143
20832
  throw new OxygenError("invalid_input", "--file is required.", { exitCode: 1 });
20144
20833
  }
20145
20834
  const batchSize = normalizeImportBatchSize(options.batchSize);
20146
20835
  const effectiveBatchSize = Math.min(batchSize, SAFE_IMPORT_WRITE_BATCH_SIZE);
20147
- const raw = readFileSync(resolve(path), "utf8");
20148
- const bundle = parseBundleFile(raw);
20149
- const columns = bundle.columns.map(bundleColumnToCreateInput);
20150
- if (columns.length === 0) {
20836
+ const bundle = await openTableBundle(resolve(path));
20837
+ // A native `relation` column is only half of a relation: the other half is a
20838
+ // row in ox_tables.relation_definitions that ONLY `tables relate` writes. A
20839
+ // bundle carries the column but not the definition, and its `target_table` is
20840
+ // the SOURCE workspace's table id, which does not exist here. Importing it
20841
+ // produced a column that pointed at a foreign workspace and that no surface
20842
+ // could repair, rename, archive or delete — every column lifecycle command
20843
+ // refuses a relation-shaped column, and the relation commands refuse a
20844
+ // definition that was never written (OXY-4360). Skip them and say so;
20845
+ // `tables relate` rebuilds a real relation on this side once both tables
20846
+ // exist. Keyed on the NATIVE shape, not `kind === "relation"`: CRM
20847
+ // relationship columns (`target_object`, no `storage`) are a different
20848
+ // mechanism with their own lifecycle and must still import.
20849
+ const isNativeRelationColumn = (column) => column.kind === "relation"
20850
+ && (column.definition?.storage === "tables"
20851
+ || typeof column.definition?.archived_relation_definition_id === "string");
20852
+ const bundleColumns = bundle.columns.map(bundleColumnToCreateInput);
20853
+ const skippedRelationColumns = bundleColumns
20854
+ .filter(isNativeRelationColumn)
20855
+ .map((column) => column.key ?? column.label);
20856
+ const columns = bundleColumns.filter((column) => !isNativeRelationColumn(column));
20857
+ if (bundleColumns.length === 0) {
20151
20858
  throw new OxygenError("invalid_bundle", "Bundle has no columns; nothing to import.", { exitCode: 1 });
20152
20859
  }
20860
+ if (columns.length === 0) {
20861
+ throw new OxygenError("invalid_bundle", "Bundle contains only native relation columns, which cannot be imported directly. Import the tables they point at, then run `tables relate`.", { details: { skipped_relation_columns: skippedRelationColumns }, exitCode: 1 });
20862
+ }
20153
20863
  const validColumnKeys = new Set(columns.map((column) => column.key).filter((key) => Boolean(key)));
20154
20864
  const into = readOption(options.into);
20155
20865
  const upsertKey = readOption(options.key);
@@ -20207,13 +20917,14 @@ options) {
20207
20917
  ?? (created ? readRecordString(created, "slug") : null);
20208
20918
  createdTable = true;
20209
20919
  }
20210
- const stagedRows = bundle.rows.map((row) => stripRowForImport(row, validColumnKeys));
20211
20920
  // In upsert mode every row must carry the key value; otherwise the upsert
20212
- // route rejects the whole batch midway. Fail fast with a clear message before
20213
- // writing anything so we never leave a half-imported table behind for a
20214
- // predictable data problem.
20215
- if (upsertKey) {
20216
- const missingIndex = stagedRows.findIndex((row) => row[upsertKey] === undefined || row[upsertKey] === null);
20921
+ // route rejects the whole batch midway. A single-document bundle has every
20922
+ // row in memory, so it is checked before anything is written and never
20923
+ // leaves a half-imported table behind for a predictable data problem. A
20924
+ // streamed bundle is checked row by row as it is read; a miss stops the
20925
+ // import with the row number, and --key makes the rerun idempotent.
20926
+ if (upsertKey && bundle.preloadedRows) {
20927
+ const missingIndex = bundle.preloadedRows.findIndex((row) => row[upsertKey] === undefined || row[upsertKey] === null);
20217
20928
  if (missingIndex >= 0) {
20218
20929
  throw new OxygenError("invalid_bundle", `Row ${missingIndex + 1} is missing a value for the upsert key "${upsertKey}".`, { details: { key: upsertKey, row_number: missingIndex + 1 }, exitCode: 1 });
20219
20930
  }
@@ -20223,28 +20934,34 @@ options) {
20223
20934
  tableId: newTableId,
20224
20935
  key: upsertKey ?? null,
20225
20936
  batchSize: effectiveBatchSize,
20937
+ binaryName,
20226
20938
  });
20227
20939
  let processed = 0;
20228
20940
  let insertedCount = 0;
20229
20941
  let updatedCount = 0;
20230
- for (let offset = 0; offset < stagedRows.length; offset += effectiveBatchSize) {
20231
- const batch = stagedRows.slice(offset, offset + effectiveBatchSize);
20232
- if (batch.length === 0)
20233
- continue;
20942
+ const batches = readBundleBatches({
20943
+ bundle,
20944
+ validColumnKeys,
20945
+ upsertKey: upsertKey ?? null,
20946
+ batchSize: effectiveBatchSize,
20947
+ tableId: newTableId,
20948
+ resumeCommand,
20949
+ });
20950
+ for await (const batch of batches) {
20234
20951
  try {
20952
+ // Same byte-aware writer as `tables import`: a wide batch pre-splits (and a
20953
+ // server 413 halves) instead of dead-ending the bundle at an unchanged
20954
+ // resume batch size. No request_id — bundle resumes match on --key.
20955
+ const result = await writeImportBatchWithAutoSplit({
20956
+ tableRef: newTableId,
20957
+ upsertKey: upsertKey ?? undefined,
20958
+ rows: batch,
20959
+ });
20235
20960
  if (upsertKey) {
20236
- const response = await requestOxygen("/api/cli/tables/rows/upsert", {
20237
- method: "POST",
20238
- body: { table: newTableId, key: upsertKey, rows: batch, return: "summary" },
20239
- });
20240
- insertedCount += readCount(response.insertedCount);
20241
- updatedCount += readCount(response.updatedCount);
20961
+ insertedCount += result.insertedCount;
20962
+ updatedCount += result.updatedCount;
20242
20963
  }
20243
20964
  else {
20244
- await requestOxygen("/api/cli/tables/rows", {
20245
- method: "POST",
20246
- body: { table: newTableId, rows: batch },
20247
- });
20248
20965
  insertedCount += batch.length;
20249
20966
  }
20250
20967
  processed += batch.length;
@@ -20261,13 +20978,13 @@ options) {
20261
20978
  const recovery = upsertKey
20262
20979
  ? `Re-run with --into ${newTableId} --key ${upsertKey} to resume — already-imported rows are matched by "${upsertKey}", not duplicated.`
20263
20980
  : `Resume with: ${resumeCommand}`;
20264
- throw new OxygenError("bundle_import_incomplete", `Bundle import stopped after ${processed}/${stagedRows.length} rows. ${recovery}`, {
20981
+ throw new OxygenError("bundle_import_incomplete", `Bundle import stopped after ${processed}/${bundle.rowCount} rows. ${recovery}`, {
20265
20982
  details: {
20266
20983
  table_id: newTableId,
20267
20984
  table_slug: newTableSlug,
20268
- rows_total: stagedRows.length,
20985
+ rows_total: bundle.rowCount,
20269
20986
  rows_processed: processed,
20270
- failed_batch_offset: offset,
20987
+ failed_batch_offset: processed,
20271
20988
  failed_batch_size: batch.length,
20272
20989
  mode: upsertKey ? "upsert" : "insert",
20273
20990
  ...(upsertKey ? { key: upsertKey } : {}),
@@ -20286,6 +21003,12 @@ options) {
20286
21003
  column_count: columns.length,
20287
21004
  row_count: processed,
20288
21005
  mode: upsertKey ? "upsert" : "insert",
21006
+ ...(skippedRelationColumns.length > 0
21007
+ ? {
21008
+ skipped_relation_columns: skippedRelationColumns,
21009
+ note: `${skippedRelationColumns.length} native relation column(s) were not imported: a relation must be created on this side with \`tables relate\` once both tables exist.`,
21010
+ }
21011
+ : {}),
20289
21012
  ...(upsertKey
20290
21013
  ? { upsert_key: upsertKey, inserted_count: insertedCount, updated_count: updatedCount }
20291
21014
  : {}),
@@ -20301,7 +21024,10 @@ options) {
20301
21024
  function buildBundleResumeCommand(input) {
20302
21025
  const fileArg = /\s/.test(input.file) ? `"${input.file}"` : input.file;
20303
21026
  const parts = [
20304
- "oxygen tables import-bundle",
21027
+ // Must be the binary the user actually invoked. Hardcoding "oxygen" meant an
21028
+ // `oxygen-dev` import printed a resume command that, pasted verbatim, re-ran
21029
+ // a DEV bundle against PRODUCTION (OXY-4360).
21030
+ `${input.binaryName} tables import-bundle`,
20305
21031
  `--file ${fileArg}`,
20306
21032
  `--into ${input.tableId}`,
20307
21033
  `--key ${input.key ?? "<uniqueColumn>"}`,
@@ -20350,6 +21076,183 @@ function bundleColumnToCreateInput(column) {
20350
21076
  : {}),
20351
21077
  };
20352
21078
  }
21079
+ // Opens either bundle format. A streamed (version 2) bundle is recognised by
21080
+ // its header line and read line by line, so a multi-GB file never has to fit
21081
+ // in memory; a single-document (version 1) bundle is parsed whole, as before.
21082
+ async function openTableBundle(filePath) {
21083
+ if (!existsSync(filePath)) {
21084
+ throw new OxygenError("invalid_input", `Bundle file not found: ${filePath}`, {
21085
+ details: { file: filePath },
21086
+ exitCode: 1,
21087
+ });
21088
+ }
21089
+ const firstLine = readFirstBundleLine(filePath);
21090
+ const header = firstLine === null ? null : parseJsonObjectOrNull(firstLine);
21091
+ if (header && readBundleNumber(header.schemaVersion) === TABLE_BUNDLE_SCHEMA_VERSION) {
21092
+ return openStreamedTableBundle(filePath, header);
21093
+ }
21094
+ let raw;
21095
+ try {
21096
+ raw = readFileSync(filePath, "utf8");
21097
+ }
21098
+ catch (error) {
21099
+ if (error instanceof RangeError || error.code === "ERR_STRING_TOO_LONG") {
21100
+ throw new OxygenError("bundle_too_large", "This bundle is one JSON document too large to load into memory. Re-export it with the current CLI (`oxygen tables export-bundle <table> --output <path>`), which writes one row per line and imports at any size.", { details: { file: filePath }, exitCode: 1 });
21101
+ }
21102
+ throw error;
21103
+ }
21104
+ const parsed = parseBundleFile(raw);
21105
+ return {
21106
+ schemaVersion: parsed.schemaVersion,
21107
+ tableName: parsed.tableName,
21108
+ tableSummary: parsed.tableSummary,
21109
+ columns: parsed.columns,
21110
+ rowCount: parsed.rows.length,
21111
+ preloadedRows: parsed.rows,
21112
+ rows: () => iterateArrayRows(parsed.rows),
21113
+ };
21114
+ }
21115
+ async function* iterateArrayRows(rows) {
21116
+ for (const row of rows)
21117
+ yield row;
21118
+ }
21119
+ function openStreamedTableBundle(filePath, header) {
21120
+ const rawColumns = header.columns;
21121
+ if (!Array.isArray(rawColumns)) {
21122
+ throw new OxygenError("invalid_bundle", "Streamed bundle header is missing a columns array.", {
21123
+ details: { file: filePath },
21124
+ exitCode: 1,
21125
+ });
21126
+ }
21127
+ const tableSummary = readRecord(header, "table");
21128
+ // The totals trailer is the last thing the exporter writes, so its absence
21129
+ // means the export was interrupted: refuse before creating anything rather
21130
+ // than importing a silently truncated table.
21131
+ const trailer = parseJsonObjectOrNull(readLastBundleLine(filePath));
21132
+ const totals = trailer ? readRecord(trailer, "totals") : null;
21133
+ const rowCount = totals ? readBundleNumber(totals.rowCount) : null;
21134
+ if (rowCount === null) {
21135
+ throw new OxygenError("invalid_bundle", "This streamed bundle has no totals trailer, so the export that produced it was interrupted before it finished. Re-run `oxygen tables export-bundle` and import the complete file.", { details: { file: filePath }, exitCode: 1 });
21136
+ }
21137
+ return {
21138
+ schemaVersion: TABLE_BUNDLE_SCHEMA_VERSION,
21139
+ tableName: tableSummary
21140
+ ? (readRecordString(tableSummary, "name") ?? readRecordString(tableSummary, "displayName"))
21141
+ : null,
21142
+ tableSummary,
21143
+ columns: rawColumns.filter((entry) => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry)),
21144
+ rowCount,
21145
+ preloadedRows: null,
21146
+ rows: () => iterateStreamedBundleRows(filePath),
21147
+ };
21148
+ }
21149
+ async function* iterateStreamedBundleRows(filePath) {
21150
+ const fileStream = createReadStream(filePath, { encoding: "utf8" });
21151
+ const reader = createInterface({ input: fileStream, crlfDelay: Infinity });
21152
+ let lineNumber = 0;
21153
+ try {
21154
+ for await (const line of reader) {
21155
+ lineNumber += 1;
21156
+ if (lineNumber === 1 || !line.trim())
21157
+ continue;
21158
+ const entry = parseJsonObjectOrNull(line);
21159
+ if (!entry) {
21160
+ throw new OxygenError("invalid_bundle", `Line ${lineNumber} of the bundle is not a JSON object.`, {
21161
+ details: { file: filePath, line: lineNumber },
21162
+ exitCode: 1,
21163
+ });
21164
+ }
21165
+ if ("totals" in entry)
21166
+ return;
21167
+ const row = readRecord(entry, "row");
21168
+ if (!row) {
21169
+ throw new OxygenError("invalid_bundle", `Line ${lineNumber} of the bundle is neither a row nor the totals trailer.`, { details: { file: filePath, line: lineNumber }, exitCode: 1 });
21170
+ }
21171
+ yield row;
21172
+ }
21173
+ }
21174
+ finally {
21175
+ reader.close();
21176
+ fileStream.destroy();
21177
+ }
21178
+ }
21179
+ // Batches the bundle's rows for the shared import writer, stripping tenant-local
21180
+ // fields and enforcing the upsert key on the way through.
21181
+ async function* readBundleBatches(input) {
21182
+ let batch = [];
21183
+ let rowNumber = 0;
21184
+ for await (const rawRow of input.bundle.rows()) {
21185
+ rowNumber += 1;
21186
+ const row = stripRowForImport(rawRow, input.validColumnKeys);
21187
+ if (input.upsertKey && (row[input.upsertKey] === undefined || row[input.upsertKey] === null)) {
21188
+ throw new OxygenError("invalid_bundle", `Row ${rowNumber} is missing a value for the upsert key "${input.upsertKey}".`, {
21189
+ details: {
21190
+ key: input.upsertKey,
21191
+ row_number: rowNumber,
21192
+ table_id: input.tableId,
21193
+ resume_command: input.resumeCommand,
21194
+ },
21195
+ exitCode: 1,
21196
+ });
21197
+ }
21198
+ batch.push(row);
21199
+ if (batch.length >= input.batchSize) {
21200
+ yield batch;
21201
+ batch = [];
21202
+ }
21203
+ }
21204
+ if (batch.length > 0)
21205
+ yield batch;
21206
+ }
21207
+ // Reads up to the first newline without loading the file. Returns null when no
21208
+ // newline appears inside the scan window and the file is larger than it, which
21209
+ // is what a huge single-document bundle looks like.
21210
+ function readFirstBundleLine(filePath) {
21211
+ const size = statSync(filePath).size;
21212
+ const span = Math.min(size, TABLE_BUNDLE_HEADER_SCAN_BYTES);
21213
+ if (span === 0)
21214
+ return "";
21215
+ const fd = openSync(filePath, "r");
21216
+ try {
21217
+ const buffer = Buffer.alloc(span);
21218
+ const read = readSync(fd, buffer, 0, span, 0);
21219
+ const text = buffer.subarray(0, read).toString("utf8");
21220
+ const newline = text.indexOf("\n");
21221
+ if (newline >= 0)
21222
+ return text.slice(0, newline);
21223
+ return read < size ? null : text;
21224
+ }
21225
+ finally {
21226
+ closeSync(fd);
21227
+ }
21228
+ }
21229
+ function readLastBundleLine(filePath) {
21230
+ const size = statSync(filePath).size;
21231
+ const span = Math.min(size, TABLE_BUNDLE_TRAILER_SCAN_BYTES);
21232
+ if (span === 0)
21233
+ return "";
21234
+ const fd = openSync(filePath, "r");
21235
+ try {
21236
+ const buffer = Buffer.alloc(span);
21237
+ const read = readSync(fd, buffer, 0, span, size - span);
21238
+ const lines = buffer.subarray(0, read).toString("utf8").split("\n").map((line) => line.trim()).filter(Boolean);
21239
+ return lines.at(-1) ?? "";
21240
+ }
21241
+ finally {
21242
+ closeSync(fd);
21243
+ }
21244
+ }
21245
+ function parseJsonObjectOrNull(text) {
21246
+ try {
21247
+ const parsed = JSON.parse(text);
21248
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
21249
+ ? parsed
21250
+ : null;
21251
+ }
21252
+ catch {
21253
+ return null;
21254
+ }
21255
+ }
20353
21256
  function parseBundleFile(text) {
20354
21257
  let parsed;
20355
21258
  try {
@@ -20366,9 +21269,12 @@ function parseBundleFile(text) {
20366
21269
  }
20367
21270
  const record = parsed;
20368
21271
  const schemaVersion = typeof record.schemaVersion === "number" ? record.schemaVersion : 1;
20369
- if (schemaVersion !== TABLE_BUNDLE_SCHEMA_VERSION) {
21272
+ // A version-2 bundle never reaches this parser (openTableBundle streams it),
21273
+ // so a single document claiming any other version is from a CLI this one
21274
+ // does not know.
21275
+ if (schemaVersion !== TABLE_BUNDLE_LEGACY_SCHEMA_VERSION) {
20370
21276
  throw new OxygenError("unsupported_bundle_version", `Bundle schema version ${schemaVersion} is not supported by this CLI.`, {
20371
- details: { supported: TABLE_BUNDLE_SCHEMA_VERSION, got: schemaVersion },
21277
+ details: { supported: TABLE_BUNDLE_SUPPORTED_SCHEMA_VERSIONS, got: schemaVersion },
20372
21278
  exitCode: 1,
20373
21279
  });
20374
21280
  }
@@ -20592,14 +21498,24 @@ function readRecord(value, key) {
20592
21498
  const entry = value[key];
20593
21499
  return isRecord(entry) ? entry : null;
20594
21500
  }
21501
+ // Deep-links must name the host the command actually talked to. This hardcoded
21502
+ // https://oxygen-agent.com, so every `oxygen-dev` bundle export/import handed
21503
+ // back a PRODUCTION url for a table that only exists on dev (OXY-4360).
21504
+ // `defaultApiUrl()` is the same resolver the request layer uses, so the link and
21505
+ // the call can no longer disagree.
20595
21506
  function tableWebUrl(tableIdOrSlug) {
20596
- return `https://oxygen-agent.com/tables/${encodeURIComponent(tableIdOrSlug)}`;
21507
+ return `${defaultApiUrl().replace(/\/+$/, "")}/tables/${encodeURIComponent(tableIdOrSlug)}`;
20597
21508
  }
20598
21509
  function formatImportFileSizeLimit(tier) {
20599
21510
  const limits = PLAN_LIMITS[tier].import;
20600
21511
  const megabytes = Math.round(limits.maxFileBytes / (1024 * 1024));
20601
21512
  return `${megabytes} MB`;
20602
21513
  }
21514
+ // Help copy reads the shared ceiling so the number can never drift from the
21515
+ // one the server enforces and the import writer splits against.
21516
+ function formatJsonBodyLimit() {
21517
+ return `${Math.round(MAX_CLI_JSON_BODY_BYTES / 1_000_000)} MB`;
21518
+ }
20603
21519
  // A backgrounded import returns as soon as the file is staged, with counts.rows
20604
21520
  // still 0 while the worker loads. With no follow-up command in the envelope that
20605
21521
  // reads as a truncated import - which is how a 500-row `--batch-size` default
@@ -23626,6 +24542,28 @@ function readCsvOption(value) {
23626
24542
  .map((entry) => entry.trim())
23627
24543
  .filter(Boolean);
23628
24544
  }
24545
+ function parseSupabaseTableSelection(value) {
24546
+ const entries = readCsvOption(value);
24547
+ if (entries.length === 0)
24548
+ return undefined;
24549
+ const seen = new Set();
24550
+ return entries.map((entry) => {
24551
+ const separator = entry.indexOf(".");
24552
+ const schema = separator > 0 ? entry.slice(0, separator).trim() : "";
24553
+ const table = separator > 0 ? entry.slice(separator + 1).trim() : "";
24554
+ if (!schema || !table) {
24555
+ throw new OxygenError("invalid_request", `Expected schema.table, received ${entry}.`, {
24556
+ details: { value: entry }, exitCode: 1,
24557
+ });
24558
+ }
24559
+ const key = `${schema}\0${table}`;
24560
+ if (seen.has(key)) {
24561
+ throw new OxygenError("invalid_request", `Duplicate Supabase table: ${entry}.`, { exitCode: 1 });
24562
+ }
24563
+ seen.add(key);
24564
+ return { schema, table };
24565
+ });
24566
+ }
23629
24567
  // Assemble the directory listing profile payload shared by `directory update`
23630
24568
  // and `admin directory enable`. --profile-json provides the base object (and
23631
24569
  // the only way to null-clear fields); explicit flags override it.
@@ -24680,19 +25618,6 @@ function readNonNegativeNumber(value) {
24680
25618
  }
24681
25619
  return parsed;
24682
25620
  }
24683
- function readNonNegativeInt(value) {
24684
- const trimmed = value?.trim();
24685
- if (!trimmed)
24686
- return undefined;
24687
- const parsed = Number(trimmed);
24688
- if (!Number.isSafeInteger(parsed) || parsed < 0) {
24689
- throw new OxygenError("invalid_number", "Expected a non-negative integer.", {
24690
- details: { value },
24691
- exitCode: 1,
24692
- });
24693
- }
24694
- return parsed;
24695
- }
24696
25621
  // Folds the sequence-level email send controls (--max-emails-per-mailbox-per-day,
24697
25622
  // --send-window-file) into a partial `settings` object. Returns undefined when
24698
25623
  // neither flag is set so the field is omitted from the request body entirely.
@@ -24753,6 +25678,12 @@ function readSequenceSettings(options) {
24753
25678
  const espMatching = readOption(options.espMatching);
24754
25679
  if (espMatching)
24755
25680
  settings.esp_matching = espMatching;
25681
+ // The file is the whole policy: settings merge by top-level key, so a partial
25682
+ // document would silently drop the routes or exclusions it omits.
25683
+ const espRoutingPath = readOption(options.espRoutingFile);
25684
+ if (espRoutingPath) {
25685
+ settings.esp_matching = readJsonFileValue(resolve(espRoutingPath), "--esp-routing-file");
25686
+ }
24756
25687
  const senderFailover = readOption(options.senderFailover);
24757
25688
  if (senderFailover)
24758
25689
  settings.sender_failover = senderFailover;