@oxygen-agent/cli 1.936.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.
- package/README.md +1 -1
- package/dist/admin-primary-providers-render.js +9 -1
- package/dist/cli-values.d.ts +14 -0
- package/dist/cli-values.js +26 -0
- package/dist/command-manifest.js +30 -2
- package/dist/functions-commands.js +13 -5
- package/dist/help.js +2 -0
- package/dist/index.js +1509 -290
- package/dist/knowledge-repository-commands.d.ts +6 -0
- package/dist/knowledge-repository-commands.js +198 -0
- package/dist/skills.js +20 -0
- package/dist/ugc-commands.js +470 -15
- package/node_modules/@oxygen/recipe-sdk/dist/index.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/byok-connect.d.ts +11 -6
- package/node_modules/@oxygen/shared/dist/byok-connect.js +14 -6
- package/node_modules/@oxygen/shared/dist/capability-discovery.d.ts +8 -0
- package/node_modules/@oxygen/shared/dist/capability-discovery.js +152 -20
- package/node_modules/@oxygen/shared/dist/copilot-errors.js +3 -0
- package/node_modules/@oxygen/shared/dist/copilot-journeys.d.ts +19 -1
- package/node_modules/@oxygen/shared/dist/copilot-journeys.generated.d.ts +19 -0
- package/node_modules/@oxygen/shared/dist/copilot-journeys.generated.js +26 -0
- package/node_modules/@oxygen/shared/dist/copilot-journeys.js +8 -41
- package/node_modules/@oxygen/shared/dist/email-dsn.d.ts +60 -0
- package/node_modules/@oxygen/shared/dist/email-dsn.js +120 -0
- package/node_modules/@oxygen/shared/dist/email-warmup-readiness.d.ts +64 -0
- package/node_modules/@oxygen/shared/dist/email-warmup-readiness.js +90 -0
- package/node_modules/@oxygen/shared/dist/inbox-avatar-url.d.ts +28 -0
- package/node_modules/@oxygen/shared/dist/inbox-avatar-url.js +57 -0
- package/node_modules/@oxygen/shared/dist/index.d.ts +10 -0
- package/node_modules/@oxygen/shared/dist/index.js +10 -0
- package/node_modules/@oxygen/shared/dist/knowledge-bases.d.ts +74 -0
- package/node_modules/@oxygen/shared/dist/knowledge-bases.js +456 -0
- package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.d.ts +56 -48
- package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.js +50 -49
- package/node_modules/@oxygen/shared/dist/knowledge-repository.d.ts +22 -0
- package/node_modules/@oxygen/shared/dist/knowledge-repository.js +121 -0
- package/node_modules/@oxygen/shared/dist/knowledge-vault-markdown.d.ts +20 -0
- package/node_modules/@oxygen/shared/dist/knowledge-vault-markdown.js +155 -0
- package/node_modules/@oxygen/shared/dist/langfuse.d.ts +8 -3
- package/node_modules/@oxygen/shared/dist/langfuse.js +177 -130
- package/node_modules/@oxygen/shared/dist/llm-payload.d.ts +10 -0
- package/node_modules/@oxygen/shared/dist/llm-payload.js +54 -0
- package/node_modules/@oxygen/shared/dist/llm-usage.d.ts +11 -0
- package/node_modules/@oxygen/shared/dist/llm-usage.js +30 -0
- package/node_modules/@oxygen/shared/dist/mailbox-import.d.ts +10 -0
- package/node_modules/@oxygen/shared/dist/mailbox-import.js +53 -0
- package/node_modules/@oxygen/shared/dist/plan-limits.d.ts +8 -0
- package/node_modules/@oxygen/shared/dist/plan-limits.js +8 -0
- package/node_modules/@oxygen/shared/dist/pricing-sheet.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/pricing-sheet.js +1 -1
- package/node_modules/@oxygen/shared/dist/product-analytics-core.d.ts +98 -0
- package/node_modules/@oxygen/shared/dist/product-analytics-core.js +159 -0
- package/node_modules/@oxygen/shared/dist/product-analytics-environment.d.ts +18 -0
- package/node_modules/@oxygen/shared/dist/product-analytics-environment.js +46 -0
- package/node_modules/@oxygen/shared/dist/product-analytics-events.d.ts +116 -0
- package/node_modules/@oxygen/shared/dist/product-analytics-events.js +120 -0
- package/node_modules/@oxygen/shared/dist/recipes.d.ts +6 -0
- package/node_modules/@oxygen/shared/dist/recipes.js +23 -0
- package/node_modules/@oxygen/shared/dist/sequences.d.ts +126 -2
- package/node_modules/@oxygen/shared/dist/sequences.js +280 -4
- package/node_modules/@oxygen/shared/dist/ugc-amplification-identity.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/ugc-amplification-identity.js +24 -0
- package/node_modules/@oxygen/shared/dist/ugc.d.ts +29 -1
- package/node_modules/@oxygen/shared/dist/user-capability-routing.js +8 -1
- package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.js +3 -1
- package/node_modules/@oxygen/shared/dist/workspace-file-storage.d.ts +6 -2
- package/node_modules/@oxygen/shared/dist/workspace-file-storage.js +15 -4
- package/node_modules/@oxygen/shared/package.json +15 -0
- package/node_modules/@oxygen/workflows/dist/graph/lint.js +22 -0
- 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";
|
|
@@ -200,8 +201,13 @@ function buildFindBody(capability, options) {
|
|
|
200
201
|
}
|
|
201
202
|
if (options.mode)
|
|
202
203
|
body.mode = options.mode;
|
|
203
|
-
|
|
204
|
-
|
|
204
|
+
// Company only: 0 is a real ceiling meaning "run only the zero-credit lanes".
|
|
205
|
+
// The person capabilities are paid on every lane, so 0 stays a usage error
|
|
206
|
+
// there. `!== undefined` because 0 is falsy and would otherwise be dropped.
|
|
207
|
+
const maxCredits = capability === "company"
|
|
208
|
+
? readCreditCeilingOrZero(options.maxCredits)
|
|
209
|
+
: readPositiveNumber(options.maxCredits);
|
|
210
|
+
if (maxCredits !== undefined)
|
|
205
211
|
body.max_credits = maxCredits;
|
|
206
212
|
// Phone-only opt-in; the route ignores it for other capabilities.
|
|
207
213
|
if (options.verify)
|
|
@@ -410,6 +416,335 @@ function writeTableLinkPreview(table, data, options) {
|
|
|
410
416
|
if (data.web_url)
|
|
411
417
|
out(` ${data.web_url}`);
|
|
412
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
|
+
}
|
|
413
748
|
function emitSuccess(command, data, options) {
|
|
414
749
|
if (options.json) {
|
|
415
750
|
writeJson(success(command, data));
|
|
@@ -427,6 +762,7 @@ async function handleAsyncAction(command, options, action) {
|
|
|
427
762
|
writeBillingNotices(command, data);
|
|
428
763
|
emitSuccess(command, data, options);
|
|
429
764
|
writeDryRunNotice(data);
|
|
765
|
+
writeManagedProviderAvailabilityNotice(data);
|
|
430
766
|
writeAvatarWarning(data);
|
|
431
767
|
writeCreditsReceipt(data);
|
|
432
768
|
}
|
|
@@ -434,6 +770,37 @@ async function handleAsyncAction(command, options, action) {
|
|
|
434
770
|
emitCliFailure(command, error);
|
|
435
771
|
}
|
|
436
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
|
+
}
|
|
437
804
|
// Single-source the CLI failure-emit contract: write the machine-readable
|
|
438
805
|
// failure envelope to stdout, surface any spend-gate hint on stderr, and set the
|
|
439
806
|
// process exit code from the error. Command handlers that don't route through
|
|
@@ -443,6 +810,33 @@ function emitCliFailure(command, error) {
|
|
|
443
810
|
writeMaxCreditsHint(error);
|
|
444
811
|
process.exitCode = error instanceof OxygenError ? exitCodeForOxygenError(error) : 1;
|
|
445
812
|
}
|
|
813
|
+
// A post's engagers list is the one payload in this tree that is routinely
|
|
814
|
+
// thousands of lines long, and the field that decides whether you may act on it
|
|
815
|
+
// — `truncated` — is one boolean inside it. Printed raw, a partial audience
|
|
816
|
+
// looks exactly like a whole one until you scroll past every person in it. So
|
|
817
|
+
// lead with the receipt on stderr (the same stdout/stderr split the dry-run and
|
|
818
|
+
// credits notices use, leaving stdout a clean envelope) and say plainly what to
|
|
819
|
+
// do about a short read. `--json` callers are untouched: they read the fields.
|
|
820
|
+
function writeEngagersReceipt(data) {
|
|
821
|
+
if (!data || typeof data !== "object" || Array.isArray(data))
|
|
822
|
+
return;
|
|
823
|
+
const record = data;
|
|
824
|
+
const pages = isRecord(record.pages_read) ? record.pages_read : {};
|
|
825
|
+
const line = (text) => process.stderr.write(`${text}\n`);
|
|
826
|
+
line(`Post ${String(record.post ?? "?")}`);
|
|
827
|
+
line(` ${String(record.total_engagers ?? 0)} engagers`
|
|
828
|
+
+ ` (${String(record.reactors_count ?? 0)} reactions, ${String(record.commenters_count ?? 0)} comments)`
|
|
829
|
+
+ ` from ${String(pages.reactions ?? 0)}+${String(pages.comments ?? 0)} pages`);
|
|
830
|
+
if (record.truncated === true) {
|
|
831
|
+
const reason = typeof record.partial_reason === "string" ? record.partial_reason : "unknown";
|
|
832
|
+
line(` PARTIAL (${reason}) — this post has more engagers than were read.`);
|
|
833
|
+
line(reason === "max_pages"
|
|
834
|
+
? " Raise --max-pages (max 20), or run `oxygen engagement harvest` to walk the whole post into a table."
|
|
835
|
+
: " Retry, or run `oxygen engagement harvest` to walk the whole post into a table.");
|
|
836
|
+
}
|
|
837
|
+
if (typeof record.web_url === "string")
|
|
838
|
+
line(` ${record.web_url}`);
|
|
839
|
+
}
|
|
446
840
|
// A dry run's stdout envelope looks like a successful result — same shape, same
|
|
447
841
|
// `ok: true` — so in a terminal the only tell that nothing was fetched was
|
|
448
842
|
// `meta.mode` buried inside the payload. That is how a working provider key gets
|
|
@@ -1728,6 +2122,7 @@ function buildPublishingPostCreateBody(options) {
|
|
|
1728
2122
|
const providerConnection = readOption(options.providerConnection);
|
|
1729
2123
|
const title = readOption(options.title);
|
|
1730
2124
|
const timezone = readOption(options.timezone);
|
|
2125
|
+
const ugcParticipationId = readOption(options.ugcParticipationId);
|
|
1731
2126
|
const content = buildPublishingContent(options);
|
|
1732
2127
|
if (provider)
|
|
1733
2128
|
body.provider = provider;
|
|
@@ -1741,6 +2136,12 @@ function buildPublishingPostCreateBody(options) {
|
|
|
1741
2136
|
body.title = title;
|
|
1742
2137
|
if (timezone)
|
|
1743
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
|
+
}
|
|
1744
2145
|
if (content)
|
|
1745
2146
|
body.content = content;
|
|
1746
2147
|
return body;
|
|
@@ -1825,6 +2226,12 @@ function buildPublishingContent(options) {
|
|
|
1825
2226
|
}
|
|
1826
2227
|
function resolvePublishingCreateStatus(options) {
|
|
1827
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
|
+
}
|
|
1828
2235
|
if (options.draft === true && status && status !== "draft") {
|
|
1829
2236
|
throw new OxygenError("conflicting_flags", "Pass either --draft or --status, not both.", { exitCode: 1 });
|
|
1830
2237
|
}
|
|
@@ -2658,7 +3065,7 @@ export function createProgram() {
|
|
|
2658
3065
|
const directoryDocsUrl = `${defaultApiUrl()}/docs/agencies`;
|
|
2659
3066
|
program
|
|
2660
3067
|
.name(binaryName)
|
|
2661
|
-
.description("Revenue infrastructure for B2B startups — agent-operated GTM: tables, enrichment, sequences, workflows, CRM, knowledge.
|
|
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.")
|
|
2662
3069
|
.version(OXYGEN_VERSION)
|
|
2663
3070
|
.option("--profile <name>", "Use a stored CLI profile for this command.")
|
|
2664
3071
|
.option("--org <organization>", "Use an organization id, Clerk org id, or slug for this command.");
|
|
@@ -3059,7 +3466,7 @@ export function createProgram() {
|
|
|
3059
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.")
|
|
3060
3467
|
.addCommand(new Command("chat")
|
|
3061
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.")
|
|
3062
|
-
.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.")
|
|
3063
3470
|
.requiredOption("-m, --message <message>", "Your message to OXYGEN support.")
|
|
3064
3471
|
.option("--severity <severity>", "New conversation only: low | normal | high. Defaults to normal.")
|
|
3065
3472
|
.option("--category <category>", "New conversation only: question, bug, feature_request, billing, security_data, configuration, agency_directory, or other. Defaults to question.")
|
|
@@ -3120,14 +3527,14 @@ Examples:
|
|
|
3120
3527
|
}))
|
|
3121
3528
|
.addCommand(new Command("get")
|
|
3122
3529
|
.description("Show one Plain support Thread and its customer-visible timeline.")
|
|
3123
|
-
.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.")
|
|
3124
3531
|
.option("--json", "Print a JSON envelope.")
|
|
3125
3532
|
.action(async (ticketId, options) => {
|
|
3126
3533
|
await handleAsyncAction("support ticket get", options, () => requestOxygen(`/api/cli/support/tickets/${encodeURIComponent(ticketId)}`));
|
|
3127
3534
|
}))
|
|
3128
3535
|
.addCommand(new Command("reply")
|
|
3129
3536
|
.description("Reply to a Plain Chat thread; migrated or native-channel history continues in one linked Chat thread.")
|
|
3130
|
-
.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.")
|
|
3131
3538
|
.requiredOption("--body <body>", "Your reply.")
|
|
3132
3539
|
.option("--json", "Print a JSON envelope.")
|
|
3133
3540
|
.action(async (ticketId, options) => {
|
|
@@ -3158,14 +3565,14 @@ Examples:
|
|
|
3158
3565
|
}))
|
|
3159
3566
|
.addCommand(new Command("get")
|
|
3160
3567
|
.description("Show one live Plain Thread, its routing metadata, and customer-visible messages (staff only).")
|
|
3161
|
-
.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`.")
|
|
3162
3569
|
.option("--json", "Print a JSON envelope.")
|
|
3163
3570
|
.action(async (ticketId, options) => {
|
|
3164
3571
|
await handleAsyncAction("support admin get", options, () => requestOxygen(`/api/cli/admin/support/tickets/${encodeURIComponent(ticketId)}`));
|
|
3165
3572
|
}))
|
|
3166
3573
|
.addCommand(new Command("open")
|
|
3167
3574
|
.description("Return the exact Plain Inbox URL for one live Thread (staff only).")
|
|
3168
|
-
.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`.")
|
|
3169
3576
|
.option("--json", "Print a JSON envelope.")
|
|
3170
3577
|
.action(async (ticketId, options) => {
|
|
3171
3578
|
await handleAsyncAction("support admin open", options, () => requestOxygen(`/api/cli/admin/support/tickets/${encodeURIComponent(ticketId)}`)
|
|
@@ -3173,7 +3580,7 @@ Examples:
|
|
|
3173
3580
|
}))
|
|
3174
3581
|
.addCommand(new Command("claim")
|
|
3175
3582
|
.description("Claim a live Plain Thread and move it to In progress without overwriting another assignee (staff only).")
|
|
3176
|
-
.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`.")
|
|
3177
3584
|
.option("--agent", "Claim as the authenticated Oxygen Support machine user instead of the signed-in human.")
|
|
3178
3585
|
.option("--json", "Print a JSON envelope.")
|
|
3179
3586
|
.action(async (ticketId, options) => {
|
|
@@ -3184,7 +3591,7 @@ Examples:
|
|
|
3184
3591
|
}))
|
|
3185
3592
|
.addCommand(new Command("start")
|
|
3186
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).")
|
|
3187
|
-
.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`.")
|
|
3188
3595
|
.requiredOption("--agent", "Required safety declaration: reconcile this Thread's status as the Oxygen Support machine user.")
|
|
3189
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.")
|
|
3190
3597
|
.option("--json", "Print a JSON envelope.")
|
|
@@ -3211,7 +3618,7 @@ The final readback must show ticket.status triaging, plain_status TODO, the In p
|
|
|
3211
3618
|
}))
|
|
3212
3619
|
.addCommand(new Command("priority")
|
|
3213
3620
|
.description("Set the native Plain priority for a live Thread (staff only).")
|
|
3214
|
-
.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`.")
|
|
3215
3622
|
.requiredOption("--priority <priority>", "urgent | high | normal | low")
|
|
3216
3623
|
.option("--json", "Print a JSON envelope.")
|
|
3217
3624
|
.action(async (ticketId, options) => {
|
|
@@ -3221,7 +3628,7 @@ The final readback must show ticket.status triaging, plain_status TODO, the In p
|
|
|
3221
3628
|
}))
|
|
3222
3629
|
.addCommand(new Command("snooze")
|
|
3223
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).")
|
|
3224
|
-
.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`.")
|
|
3225
3632
|
.option("--days <n>", "Snooze for this many days.")
|
|
3226
3633
|
.option("--hours <n>", "Snooze for this many hours. Combined with --days when both are given.")
|
|
3227
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.")
|
|
@@ -3234,14 +3641,14 @@ The final readback must show ticket.status triaging, plain_status TODO, the In p
|
|
|
3234
3641
|
}))
|
|
3235
3642
|
.addCommand(new Command("todo")
|
|
3236
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.")
|
|
3237
|
-
.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`.")
|
|
3238
3645
|
.option("--json", "Print a JSON envelope.")
|
|
3239
3646
|
.action(async (ticketId, options) => {
|
|
3240
3647
|
await handleSupportAdminUpdateRequest("todo", ticketId, options, {});
|
|
3241
3648
|
}))
|
|
3242
3649
|
.addCommand(new Command("assign")
|
|
3243
3650
|
.description("Hand a live Thread to a named human, for escalation past an agent's authority or judgment boundary (staff only).")
|
|
3244
|
-
.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`.")
|
|
3245
3652
|
.requiredOption("--user <email>", "The staff email to assign the Thread to.")
|
|
3246
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.")
|
|
3247
3654
|
.option("--json", "Print a JSON envelope.")
|
|
@@ -3253,7 +3660,7 @@ The final readback must show ticket.status triaging, plain_status TODO, the In p
|
|
|
3253
3660
|
}))
|
|
3254
3661
|
.addCommand(new Command("field-set")
|
|
3255
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).")
|
|
3256
|
-
.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`.")
|
|
3257
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.")
|
|
3258
3665
|
.requiredOption("--value <value>", "The value to record.")
|
|
3259
3666
|
.option("--json", "Print a JSON envelope.")
|
|
@@ -3265,7 +3672,7 @@ The final readback must show ticket.status triaging, plain_status TODO, the In p
|
|
|
3265
3672
|
}))
|
|
3266
3673
|
.addCommand(new Command("label-add")
|
|
3267
3674
|
.description("Add an active Plain label by external ID (staff only).")
|
|
3268
|
-
.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`.")
|
|
3269
3676
|
.requiredOption("--label <externalId>", "Plain label external ID, for example oxygen_request_bug.")
|
|
3270
3677
|
.option("--json", "Print a JSON envelope.")
|
|
3271
3678
|
.action(async (ticketId, options) => {
|
|
@@ -3275,7 +3682,7 @@ The final readback must show ticket.status triaging, plain_status TODO, the In p
|
|
|
3275
3682
|
}))
|
|
3276
3683
|
.addCommand(new Command("label-remove")
|
|
3277
3684
|
.description("Remove a Plain label by external ID (staff only).")
|
|
3278
|
-
.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`.")
|
|
3279
3686
|
.requiredOption("--label <externalId>", "Plain label external ID.")
|
|
3280
3687
|
.option("--json", "Print a JSON envelope.")
|
|
3281
3688
|
.action(async (ticketId, options) => {
|
|
@@ -3285,7 +3692,7 @@ The final readback must show ticket.status triaging, plain_status TODO, the In p
|
|
|
3285
3692
|
}))
|
|
3286
3693
|
.addCommand(new Command("note")
|
|
3287
3694
|
.description("Add a team-only internal note to a live Plain Thread (staff only).")
|
|
3288
|
-
.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`.")
|
|
3289
3696
|
.requiredOption("--body <body>", "Internal note body.")
|
|
3290
3697
|
.option("--json", "Print a JSON envelope.")
|
|
3291
3698
|
.action(async (ticketId, options) => {
|
|
@@ -3295,7 +3702,7 @@ The final readback must show ticket.status triaging, plain_status TODO, the In p
|
|
|
3295
3702
|
}))
|
|
3296
3703
|
.addCommand(new Command("draft")
|
|
3297
3704
|
.description("Stage a reply as a team-only Plain note; this never sends to the customer (staff and agents).")
|
|
3298
|
-
.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`.")
|
|
3299
3706
|
.requiredOption("--body <body>", "Proposed customer reply.")
|
|
3300
3707
|
.option("--json", "Print a JSON envelope.")
|
|
3301
3708
|
.action(async (ticketId, options) => {
|
|
@@ -3305,7 +3712,7 @@ The final readback must show ticket.status triaging, plain_status TODO, the In p
|
|
|
3305
3712
|
}))
|
|
3306
3713
|
.addCommand(new Command("reply")
|
|
3307
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.")
|
|
3308
|
-
.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`.")
|
|
3309
3716
|
.requiredOption("--body <body>", "Customer-visible reply body.")
|
|
3310
3717
|
.requiredOption("--agent", "Required safety declaration: reply as the authenticated Oxygen Support machine user after it owns the Thread.")
|
|
3311
3718
|
.option("--confirm-ref <ref>", "Send only when this exactly matches the previewed Plain ref (for example T-10). Omit to preview without sending.")
|
|
@@ -3332,7 +3739,7 @@ The final readback must show ticket.status triaging, plain_status TODO, the In p
|
|
|
3332
3739
|
}))
|
|
3333
3740
|
.addCommand(new Command("done")
|
|
3334
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.")
|
|
3335
|
-
.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`.")
|
|
3336
3743
|
.requiredOption("--agent", "Required safety declaration: mark Done as the authenticated Oxygen Support machine user while it still owns the Thread.")
|
|
3337
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.")
|
|
3338
3745
|
.option("--confirm-ref <ref>", "Complete only when this exactly matches the previewed Plain ref.")
|
|
@@ -3770,7 +4177,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
3770
4177
|
}));
|
|
3771
4178
|
program
|
|
3772
4179
|
.command("publishing")
|
|
3773
|
-
.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")
|
|
3774
4181
|
.addCommand(new Command("mentions")
|
|
3775
4182
|
.description("Resolve LinkedIn identities for a publish-faithful post preview.")
|
|
3776
4183
|
.addCommand(new Command("resolve")
|
|
@@ -3807,21 +4214,22 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
3807
4214
|
await handleAsyncAction("publishing posts list", options, () => requestOxygen(buildPublishingPostsListPath(options)));
|
|
3808
4215
|
}))
|
|
3809
4216
|
.addCommand(new Command("create")
|
|
3810
|
-
.description("Create a
|
|
3811
|
-
.requiredOption("--publish-at <iso>", "ISO date-time when the post should publish.")
|
|
3812
|
-
.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.")
|
|
3813
4220
|
.option("--channel <channel>", "Publishing channel override. Defaults to provider.")
|
|
3814
4221
|
.option("--sender <sender_account_id>", "LinkedIn sender account id. Required for LinkedIn before the worker can publish.")
|
|
3815
|
-
.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).")
|
|
3816
4223
|
.option("--title <title>", "Internal title for the queue.")
|
|
3817
4224
|
.option("--text <text>", "Post text. For LinkedIn mentions, use @<public-identifier> directly.")
|
|
3818
4225
|
.option("--text-file <path>", "Read post text from a local file.")
|
|
3819
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.")
|
|
3820
4227
|
.option("--composio-action <slug>", "Override the Composio action slug for this scheduled post.")
|
|
3821
|
-
.option("--timezone <tz>", "Display timezone for the scheduled date. Defaults to UTC.")
|
|
3822
|
-
.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.")
|
|
3823
4230
|
.option("--draft", "Create as a draft instead of scheduled.")
|
|
3824
|
-
.option("--
|
|
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.")
|
|
3825
4233
|
.option("--json", "Print a JSON envelope.")
|
|
3826
4234
|
.action(async (options) => {
|
|
3827
4235
|
await handleAsyncAction("publishing posts create", options, () => requestOxygen("/api/cli/publishing/posts", {
|
|
@@ -5013,7 +5421,8 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
5013
5421
|
.description("Source EXTERNAL market signals into a table: hiring, technology adoption, funding, acquisitions, and news. This is the sourcing half of Signals — `signals list` reads the events already captured for your workspace.")
|
|
5014
5422
|
.addCommand(new Command("plan")
|
|
5015
5423
|
.description("Compile a signal-sourcing request into ordered provider routes without provider calls: the chain, per-route applied/dropped filters, credit estimate, table blueprint, and whether the route can be kept LIVE on a cadence. Free.")
|
|
5016
|
-
.
|
|
5424
|
+
.argument("[prompt]", "Prompt text or @file (same as --prompt). The --prompt flag wins if both are given.")
|
|
5425
|
+
.option("--prompt <text-or-file>", "Signal-sourcing prompt, or a path to a prompt file.")
|
|
5017
5426
|
.requiredOption("--family <family>", "Signal family: hiring, tech, funding, acquisition, news, or job_change.")
|
|
5018
5427
|
.option("--scope <scope>", "market (discover new companies) or watch_list (track companies you name via --domains). Defaults per family.")
|
|
5019
5428
|
.option("--target-count <n>", "Desired row count for routing and estimates.")
|
|
@@ -5027,14 +5436,15 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
5027
5436
|
.option("--filters-json <json-or-file>", "Filters JSON inline or a path to a JSON file; wins over individual flags per top-level filter path.")
|
|
5028
5437
|
.option("--estimate", "Run the free server-side preflight pass: resolves provider enum values and, where a provider publishes one, a free match count (zero credits).")
|
|
5029
5438
|
.option("--json", "Print a JSON envelope.")
|
|
5030
|
-
.action(async (options) => {
|
|
5439
|
+
.action(async (promptArg, options) => {
|
|
5031
5440
|
await handleAsyncAction("signals search plan", options, () => requestOxygen("/api/cli/signals/search/plan", {
|
|
5032
5441
|
method: "POST",
|
|
5033
|
-
body: readSignalsSearchPlanBody(options),
|
|
5442
|
+
body: readSignalsSearchPlanBody(options, promptArg),
|
|
5034
5443
|
}));
|
|
5035
5444
|
}))
|
|
5036
5445
|
.addCommand(new Command("run")
|
|
5037
5446
|
.description("Return a dry-run request or queue a live signal-search ingestion run. Live requires --approved and --max-credits. Add --bind-feed --every to also bind a pull feed to the same table in the same call, so the table keeps refilling on a cadence.")
|
|
5447
|
+
.argument("[prompt]", "Prompt text or @file (same as --prompt); requires --family. The --prompt flag wins if both are given.")
|
|
5038
5448
|
.option("--prompt <text-or-file>", "Signal-sourcing prompt, or a path to a prompt file. Requires --family.")
|
|
5039
5449
|
.option("--plan-json <json-or-file>", "Plan JSON returned by signals search plan, or a path to a JSON file.")
|
|
5040
5450
|
.option("--family <family>", "Signal family when planning from --prompt: hiring, tech, funding, acquisition, news, or job_change.")
|
|
@@ -5062,10 +5472,10 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
5062
5472
|
.option("--max-credits-per-cycle <n>", "Credit ceiling PER sync cycle for the bound feed.")
|
|
5063
5473
|
.option("--max-rows-per-cycle <n>", "Advisory row ceiling per sync cycle for the bound feed.")
|
|
5064
5474
|
.option("--json", "Print a JSON envelope.")
|
|
5065
|
-
.action(async (options) => {
|
|
5475
|
+
.action(async (promptArg, options) => {
|
|
5066
5476
|
await handleAsyncAction("signals search run", options, () => requestOxygen("/api/cli/signals/search/run", {
|
|
5067
5477
|
method: "POST",
|
|
5068
|
-
body: readSignalsSearchRunBody(options),
|
|
5478
|
+
body: readSignalsSearchRunBody(options, promptArg),
|
|
5069
5479
|
}));
|
|
5070
5480
|
})));
|
|
5071
5481
|
const tablesCommand = program
|
|
@@ -5199,7 +5609,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
5199
5609
|
.option("--create <name>", "Create a new table with columns inferred from the file before importing.")
|
|
5200
5610
|
.option("--project <project>", "Project id or slug for --create. Defaults to General.")
|
|
5201
5611
|
.option("--upsert-key <key>", "Column key used to upsert instead of inserting.")
|
|
5202
|
-
.option("--batch-size <n>",
|
|
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.`)
|
|
5203
5613
|
.option("--background", "Enqueue durable import chunks for the background worker.")
|
|
5204
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.`)
|
|
5205
5615
|
.option("--max-concurrency <n>", "Maximum concurrent import chunks for background mode. Defaults to 5.")
|
|
@@ -5208,7 +5618,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
5208
5618
|
await handleAsyncAction("tables import", options, () => importRows(table, options));
|
|
5209
5619
|
}))
|
|
5210
5620
|
.addCommand(new Command("export")
|
|
5211
|
-
.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`.")
|
|
5212
5622
|
.argument("<table>", "Table id or slug.")
|
|
5213
5623
|
.option("--format <format>", "json, jsonl, csv, or table. Defaults to json. Use table for a typed, human-readable rendering with thousands grouping.")
|
|
5214
5624
|
.option("--output <path>", "Write export content to a file.")
|
|
@@ -5218,16 +5628,16 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
5218
5628
|
await handleAsyncAction("tables export", options, () => exportRows(table, options));
|
|
5219
5629
|
}))
|
|
5220
5630
|
.addCommand(new Command("export-bundle")
|
|
5221
|
-
.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.")
|
|
5222
5632
|
.argument("<table>", "Table id or slug to export.")
|
|
5223
|
-
.option("--output <path>", "Write the bundle JSON
|
|
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.")
|
|
5224
5634
|
.option("--page-size <n>", "Rows per cursor-paginated request. Defaults to 500; hard cap is 1000.")
|
|
5225
5635
|
.option("--json", "Print a JSON envelope (omit row payload — use --output to keep the rows).")
|
|
5226
5636
|
.action(async (table, options) => {
|
|
5227
5637
|
await handleAsyncAction("tables export-bundle", options, () => exportTableBundle(table, options));
|
|
5228
5638
|
}))
|
|
5229
5639
|
.addCommand(new Command("import-bundle")
|
|
5230
|
-
.description("Recreate a workspace table from an export-bundle file in this org. Restores columns (incl. enrichment/tool definitions)
|
|
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.")
|
|
5231
5641
|
.requiredOption("--file <path>", "Bundle JSON file produced by `tables export-bundle`.")
|
|
5232
5642
|
.option("--name <name>", "Override the table display name. Defaults to the bundle's table name. Ignored with --into.")
|
|
5233
5643
|
.option("--project <project>", "Project id or slug for the new table. Defaults to General. Ignored with --into.")
|
|
@@ -5236,7 +5646,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
5236
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.")
|
|
5237
5647
|
.option("--json", "Print a JSON envelope.")
|
|
5238
5648
|
.action(async (options) => {
|
|
5239
|
-
await handleAsyncAction("tables import-bundle", options, () => importTableBundle(options));
|
|
5649
|
+
await handleAsyncAction("tables import-bundle", options, () => importTableBundle(options, binaryName));
|
|
5240
5650
|
}))
|
|
5241
5651
|
.addCommand(new Command("list")
|
|
5242
5652
|
.description("List workspace tables in the current tenant database.")
|
|
@@ -5278,7 +5688,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
5278
5688
|
.option("--offset <n>", "Skip this many rows and start there — jump straight to a row without paging to it. Mutually exclusive with --cursor.")
|
|
5279
5689
|
.option("--fields <columns>", "Comma-separated column keys or ids to include.")
|
|
5280
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.")
|
|
5281
|
-
.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.")
|
|
5282
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.")
|
|
5283
5693
|
.option("--sort-json <json>", "Ordered sort rules, e.g. '[{\"columnKey\":\"_created_at\",\"direction\":\"desc\"}]'. Earlier rules dominate. Mutually exclusive with --filter-json.")
|
|
5284
5694
|
.option("--no-system-fields", "Omit _row_id, _created_at, and _updated_at from returned rows (included by default).")
|
|
@@ -5290,14 +5700,14 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
5290
5700
|
const filterTree = readJsonObjectOption(options.filterTreeJson);
|
|
5291
5701
|
const formulaValues = readFormulaValuesOption(options.formulaValues);
|
|
5292
5702
|
const sorts = readSortJsonOption(options.sortJson);
|
|
5293
|
-
const offset =
|
|
5703
|
+
const offset = readNonNegativeInt(options.offset);
|
|
5294
5704
|
if (filters && (filterTree || sorts)) {
|
|
5295
5705
|
throw new OxygenError("invalid_filter", "Pass either --filter-json (legacy) or --filter-tree-json/--sort-json, not both.", { exitCode: 1 });
|
|
5296
5706
|
}
|
|
5297
5707
|
if (formulaValues && !filters && !filterTree) {
|
|
5298
5708
|
throw new OxygenError("invalid_filter", "--formula-values requires --filter-json or --filter-tree-json.", { exitCode: 1 });
|
|
5299
5709
|
}
|
|
5300
|
-
if (readOption(options.cursor) && offset) {
|
|
5710
|
+
if (readOption(options.cursor) && offset !== undefined) {
|
|
5301
5711
|
throw new OxygenError("invalid_request", "Pass either --cursor or --offset, not both.", { exitCode: 1 });
|
|
5302
5712
|
}
|
|
5303
5713
|
return requestOxygen("/api/cli/tables/query", {
|
|
@@ -5306,7 +5716,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
5306
5716
|
table,
|
|
5307
5717
|
...(limit ? { limit } : {}),
|
|
5308
5718
|
...(readOption(options.cursor) ? { cursor: readOption(options.cursor) } : {}),
|
|
5309
|
-
...(offset ? { offset } : {}),
|
|
5719
|
+
...(offset !== undefined ? { offset } : {}),
|
|
5310
5720
|
...(readOption(options.fields) ? { fields: readCsvOption(options.fields) } : {}),
|
|
5311
5721
|
...(filters ? { filters } : {}),
|
|
5312
5722
|
...(filterTree ? { filterTree } : {}),
|
|
@@ -5324,7 +5734,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
5324
5734
|
.option("--fields <columns>", "Comma-separated column keys or ids to include.")
|
|
5325
5735
|
.option("--include-system-fields", "Include _row_id, _created_at, and _updated_at in preview rows.")
|
|
5326
5736
|
.option("--include-cell-states", "Include per-cell run state in the preview response.")
|
|
5327
|
-
.option("--summary-only", "Return table metadata
|
|
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.")
|
|
5328
5738
|
.option("--json", "Print a JSON envelope.")
|
|
5329
5739
|
.action(async (table, options) => {
|
|
5330
5740
|
await handleAsyncAction("tables preview", options, () => {
|
|
@@ -5343,9 +5753,10 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
5343
5753
|
});
|
|
5344
5754
|
}))
|
|
5345
5755
|
.addCommand(new Command("describe")
|
|
5346
|
-
.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).")
|
|
5347
5757
|
.argument("<table>", "Table id or slug.")
|
|
5348
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.")
|
|
5349
5760
|
.option("--json", "Print a JSON envelope.")
|
|
5350
5761
|
.action(async (table, options) => {
|
|
5351
5762
|
await handleAsyncAction("tables describe", options, () => requestOxygen("/api/cli/tables/describe", {
|
|
@@ -5353,6 +5764,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
5353
5764
|
body: {
|
|
5354
5765
|
table,
|
|
5355
5766
|
...(options.includeArchived ? { include_archived: true } : {}),
|
|
5767
|
+
...(options.stats ? { include_stats: true } : {}),
|
|
5356
5768
|
},
|
|
5357
5769
|
}));
|
|
5358
5770
|
}))
|
|
@@ -5478,6 +5890,63 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
5478
5890
|
});
|
|
5479
5891
|
});
|
|
5480
5892
|
}));
|
|
5893
|
+
const watcherCommand = tablesCommand.command("watcher")
|
|
5894
|
+
.description("LinkedIn Profile Watcher: one editable table of daily engagers, source attribution, and current employers from posts in the past 7 days. Start with preview (free); create/resume activate paid daily monitoring after exact approval.");
|
|
5895
|
+
watcherCommand.command("get")
|
|
5896
|
+
.description("Read a table's LinkedIn Profile Watcher configuration and status. Free.")
|
|
5897
|
+
.argument("<table>", "Watcher table id or slug.")
|
|
5898
|
+
.option("--json", "Print a JSON envelope.")
|
|
5899
|
+
.action(async (table, options) => {
|
|
5900
|
+
await handleAsyncAction("tables watcher get", options, () => requestOxygen(`/api/cli/tables/linkedin-profile-watcher?table=${encodeURIComponent(table)}`, { method: "GET" }));
|
|
5901
|
+
});
|
|
5902
|
+
for (const action of ["preview", "create", "update", "pause", "resume"]) {
|
|
5903
|
+
const command = watcherCommand.command(action)
|
|
5904
|
+
.description({
|
|
5905
|
+
preview: "Free configuration and credit preview: no provider calls, writes, or scheduling. Omit --max-credits for a recommendation and preview hash. Ask for real profile URLs; never invent them.",
|
|
5906
|
+
create: "Create and activate a LinkedIn Profile Watcher after approval of its exact preview. Starts collection now and daily at 07:00 UTC under the approved per-cycle cap.",
|
|
5907
|
+
update: "Edit watched profiles or the per-cycle cap from the table. Preview the changes first; approval binds the exact new configuration.",
|
|
5908
|
+
pause: "Pause daily monitoring while retaining the table and collected rows.",
|
|
5909
|
+
resume: "Resume paid daily monitoring after reviewing a fresh preview and approving its per-cycle credit cap.",
|
|
5910
|
+
}[action])
|
|
5911
|
+
.option("--table <table>", "Existing watcher table id or slug; required for get/update/pause/resume.")
|
|
5912
|
+
.option("--json", "Print a JSON envelope.");
|
|
5913
|
+
if (action !== "pause") {
|
|
5914
|
+
command
|
|
5915
|
+
.option("--name <name>", "Watcher table display name.")
|
|
5916
|
+
.option("--project <project>", "Project id or slug; defaults to General.")
|
|
5917
|
+
.option("--profiles-json <json>", "JSON array of 1–10 real public LinkedIn profile URLs; replaces the watched list.")
|
|
5918
|
+
.option("--max-credits <credits>", "Hard credit ceiling for each daily cycle; never a monthly ceiling.");
|
|
5919
|
+
if (action !== "preview")
|
|
5920
|
+
command
|
|
5921
|
+
.option("--preview-hash <hash>", "Exact configuration hash returned by preview; re-preview after any change.")
|
|
5922
|
+
.option("--approved", "Approve this exact configuration and recurring per-cycle spending.");
|
|
5923
|
+
}
|
|
5924
|
+
if (action === "create") {
|
|
5925
|
+
command.requiredOption("--request-id <uuid>", "One stable UUID for this new watcher. Reuse it and the identical configuration after a timeout; never generate a new retry key.");
|
|
5926
|
+
}
|
|
5927
|
+
command.action(async (options) => {
|
|
5928
|
+
await handleAsyncAction(`tables watcher ${action}`, options, () => {
|
|
5929
|
+
const profiles = options.profilesJson === undefined ? undefined : parseJsonArray(options.profilesJson);
|
|
5930
|
+
if (profiles !== undefined && profiles.some((profile) => typeof profile !== "string")) {
|
|
5931
|
+
throw new OxygenError("invalid_input", "--profiles-json must be an array of LinkedIn profile URL strings.");
|
|
5932
|
+
}
|
|
5933
|
+
return requestOxygen("/api/cli/tables/linkedin-profile-watcher", {
|
|
5934
|
+
method: "POST",
|
|
5935
|
+
body: {
|
|
5936
|
+
action,
|
|
5937
|
+
...(readOption(options.table) ? { table: readOption(options.table) } : {}),
|
|
5938
|
+
...(readOption(options.name) ? { name: readOption(options.name) } : {}),
|
|
5939
|
+
...(readOption(options.project) ? { project: readOption(options.project) } : {}),
|
|
5940
|
+
...(profiles !== undefined ? { profiles } : {}),
|
|
5941
|
+
...(options.maxCredits !== undefined ? { max_credits_per_cycle: readPositiveNumber(options.maxCredits) } : {}),
|
|
5942
|
+
...(readOption(options.requestId) ? { request_id: readOption(options.requestId) } : {}),
|
|
5943
|
+
...(readOption(options.previewHash) ? { preview_hash: readOption(options.previewHash) } : {}),
|
|
5944
|
+
...(options.approved ? { approved: true } : {}),
|
|
5945
|
+
},
|
|
5946
|
+
});
|
|
5947
|
+
});
|
|
5948
|
+
});
|
|
5949
|
+
}
|
|
5481
5950
|
tablesCommand.addCommand(new Command("relate")
|
|
5482
5951
|
.description("Relate two tables: define empty Tables-owned relation columns on the source and target. Then use `oxygen tables link` to populate row-to-row edges. Works on any workspace table; plain tables stay plain and are never registered as CRM objects. Defaults to dry-run.")
|
|
5483
5952
|
.argument("<table>", "Source table id or slug.")
|
|
@@ -6158,7 +6627,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
6158
6627
|
.description("Workspace-level GTM context commands.")
|
|
6159
6628
|
.addCommand(new Command("resolve")
|
|
6160
6629
|
.description("Resolve task-scoped workspace GTM context with readiness and revision provenance.")
|
|
6161
|
-
.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.")
|
|
6162
6631
|
.option("--asset-type <csv>", "Comma-separated context asset types to include.")
|
|
6163
6632
|
.option("--asset-status <status>", "draft, active, archived, or all. Defaults to active.")
|
|
6164
6633
|
.option("--tags <csv>", "Comma-separated asset tags that must be present.")
|
|
@@ -6279,9 +6748,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
6279
6748
|
// command, not on `status` — the same collision the `commands` group above
|
|
6280
6749
|
// handles. The named reference lets `status` read the parent's flag back.
|
|
6281
6750
|
const knowledgeBootstrapCommand = new Command("bootstrap")
|
|
6282
|
-
.description(`Research
|
|
6283
|
-
.option("--domain <domain>", "Company
|
|
6284
|
-
.option("--linkedin <url>", "
|
|
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.")
|
|
6285
6754
|
.option("--live", "Authorize this manual run to spend and write. Requires --max-credits. Without it the command previews and nothing is billed.")
|
|
6286
6755
|
.option("--approved", "Same as --live. Accepted because the global help footer names --approved as the way to authorize a credit-spending command.")
|
|
6287
6756
|
.option("--max-credits <credits>", `Hard credit ceiling for the whole pass. Required with --live, and clamped server-side to ${KNOWLEDGE_BOOTSTRAP_MAX_CREDITS}.`)
|
|
@@ -6607,7 +7076,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
6607
7076
|
})))
|
|
6608
7077
|
.addCommand(new Command("resolve")
|
|
6609
7078
|
.description("Resolve task-scoped workspace GTM context with readiness and revision provenance.")
|
|
6610
|
-
.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.")
|
|
6611
7080
|
.option("--asset-type <csv>", "Comma-separated context asset types to include.")
|
|
6612
7081
|
.option("--asset-status <status>", "draft, active, archived, or all. Defaults to active.")
|
|
6613
7082
|
.option("--tags <csv>", "Comma-separated asset tags that must be present.")
|
|
@@ -7361,7 +7830,15 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
7361
7830
|
});
|
|
7362
7831
|
}))
|
|
7363
7832
|
.addCommand(new Command("show")
|
|
7364
|
-
|
|
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`.")
|
|
7365
7842
|
.argument("<slug>", "Recipe slug, e.g. outbound-pilot-50.")
|
|
7366
7843
|
.option("--json", "Print a JSON envelope.")
|
|
7367
7844
|
.action(async (slug, options) => {
|
|
@@ -7389,6 +7866,40 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
7389
7866
|
body.force = true;
|
|
7390
7867
|
return requestOxygen("/api/cli/recipes/install", { method: "POST", body });
|
|
7391
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
|
+
});
|
|
7392
7903
|
}));
|
|
7393
7904
|
program.addCommand(buildPromptTemplatesCommand("prompts", "Reusable prompt templates layered into AI columns at run time."));
|
|
7394
7905
|
// The deprecated `templates` alias tree was removed at its registry sunset
|
|
@@ -7468,12 +7979,13 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
7468
7979
|
.argument("<table>", "Table id or slug.")
|
|
7469
7980
|
.option("--preset <preset>", "Add a pre-built enrichment bundle instead of one column: `person_enrich` (one LinkedIn profile lookup, then headline, bio, location and followers for free) or `company_enrich` (domain, LinkedIn page, headcount, industry and full profile, cascading across providers until one answers). Oxygen finds the identity column itself — override with --input. Creating the columns is free; run them afterwards, --dry-run first.")
|
|
7470
7981
|
.option("--input <slot=column...>", "Bind a preset input to an exact column, e.g. --input url=linkedin_url, or --input company_name=account --input domain=website. Repeatable. Only needed when the automatic match is wrong or missing.", collectRepeatable, [])
|
|
7471
|
-
.option("--
|
|
7982
|
+
.option("--capability <capability>", "Seed a ready-to-run enrichment column WITHOUT running it (0 credits): verify_email grades the address a row already holds \u2014 MillionVerifier first, catch-all domains escalate to BounceBan; work_email, mobile_phone and linkedin_url find a value the row is missing through the managed waterfall. Sets kind=enrichment and jsonb; label and key default from the capability. Preview cost with `enrich-column preview --capability <same>` and run later with `enrich-column run --approved --max-credits <n>`.")
|
|
7983
|
+
.option("--label <label>", "Display label for the new column. Required unless --prompt-key or --capability supplies a default title.")
|
|
7472
7984
|
.option("--key <key>", "Optional stable column key. Defaults to a normalized label.")
|
|
7473
7985
|
.option("--data-type <type>", "Column data type: text, numeric, boolean, jsonb, or timestamptz.")
|
|
7474
|
-
.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. `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.")
|
|
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.")
|
|
7475
7987
|
.option("--semantic-type <type>", "Optional semantic type such as company_domain.")
|
|
7476
|
-
.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.")
|
|
7477
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.")
|
|
7478
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.")
|
|
7479
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.")
|
|
@@ -7505,6 +8017,36 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
7505
8017
|
// skipcq: JS-R1005 — intentional per-option branching to assemble the columns-add request body
|
|
7506
8018
|
.action(async (table, options) => {
|
|
7507
8019
|
await handleAsyncAction("columns add", options, async () => {
|
|
8020
|
+
// --capability seeds a COMPLETE enrichment column server-side (kind,
|
|
8021
|
+
// jsonb data type, intent, the default provider order, label and
|
|
8022
|
+
// key), so any flag that authors a different column is a conflict,
|
|
8023
|
+
// not a modifier. Caught here rather than server-side so the user
|
|
8024
|
+
// never gets back a column they did not ask for.
|
|
8025
|
+
const capability = readOption(options.capability);
|
|
8026
|
+
if (capability) {
|
|
8027
|
+
const conflicting = [
|
|
8028
|
+
readOption(options.preset) ? "--preset" : null,
|
|
8029
|
+
readOption(options.prompt) ? "--prompt" : null,
|
|
8030
|
+
readOption(options.promptKey) ? "--prompt-key" : null,
|
|
8031
|
+
readOption(options.bindObject) ? "--bind-object" : null,
|
|
8032
|
+
readOption(options.bindMap) ? "--bind-map" : null,
|
|
8033
|
+
options.bindCreate ? "--bind-create" : null,
|
|
8034
|
+
readOption(options.lookupTable) ? "--lookup-table" : null,
|
|
8035
|
+
readOption(options.lookupMatch) ? "--lookup-match" : null,
|
|
8036
|
+
readOption(options.lookupMode) ? "--lookup-mode" : null,
|
|
8037
|
+
readOption(options.lookupReturn) ? "--lookup-return" : null,
|
|
8038
|
+
readOption(options.lookupOrder) ? "--lookup-order" : null,
|
|
8039
|
+
readOption(options.lookupAggregate) ? "--lookup-aggregate" : null,
|
|
8040
|
+
readOption(options.lookupNormalize) ? "--lookup-normalize" : null,
|
|
8041
|
+
].filter((flag) => flag !== null);
|
|
8042
|
+
if (conflicting.length > 0) {
|
|
8043
|
+
throw new OxygenError("invalid_request", `--capability ${capability} seeds a complete enrichment column, so it cannot be combined with ${conflicting.join(", ")}. Drop one of the two.`, { exitCode: 1 });
|
|
8044
|
+
}
|
|
8045
|
+
const capabilityKind = readOption(options.kind)?.toLowerCase() ?? null;
|
|
8046
|
+
if (capabilityKind && capabilityKind !== "enrichment") {
|
|
8047
|
+
throw new OxygenError("invalid_request", `--capability authors an enrichment column, but --kind ${capabilityKind} was requested. Drop --kind, or drop --capability.`, { exitCode: 1 });
|
|
8048
|
+
}
|
|
8049
|
+
}
|
|
7508
8050
|
// A preset names its own columns, so --label does not apply to it.
|
|
7509
8051
|
const preset = readOption(options.preset);
|
|
7510
8052
|
if (preset) {
|
|
@@ -7522,7 +8064,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
7522
8064
|
body: presetBody,
|
|
7523
8065
|
});
|
|
7524
8066
|
}
|
|
7525
|
-
|
|
8067
|
+
// --capability supplies its own label ("Email Verification", ...)
|
|
8068
|
+
// server-side, the same one the web picker writes.
|
|
8069
|
+
if (!options.promptKey && !capability && !options.label) {
|
|
7526
8070
|
throw new OxygenError("invalid_request", "--label is required.", { exitCode: 1 });
|
|
7527
8071
|
}
|
|
7528
8072
|
if (options.promptKey && !options.inputMapping) {
|
|
@@ -7547,6 +8091,15 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
7547
8091
|
column.definition = parseJsonObject(options.definitionJson);
|
|
7548
8092
|
const requestedKind = readOption(options.kind)?.toLowerCase() ?? null;
|
|
7549
8093
|
const isResearch = requestedKind === "research";
|
|
8094
|
+
if (capability)
|
|
8095
|
+
column.capability = capability;
|
|
8096
|
+
// An enrichment cell is always the provider envelope, so the server
|
|
8097
|
+
// rejects any other data type ("Enrichment columns must use jsonb
|
|
8098
|
+
// data type."). Default it, the way --bind-object already does,
|
|
8099
|
+
// instead of making the caller bolt on --data-type jsonb.
|
|
8100
|
+
if (requestedKind === "enrichment" && !options.dataType) {
|
|
8101
|
+
column.data_type = "jsonb";
|
|
8102
|
+
}
|
|
7550
8103
|
if (prompt !== null) {
|
|
7551
8104
|
if (requestedKind && requestedKind !== "ai" && !isResearch) {
|
|
7552
8105
|
throw new OxygenError("invalid_request", `--prompt authors an AI or research column, but --kind ${requestedKind} was requested. Drop --kind, or drop --prompt.`, { exitCode: 1 });
|
|
@@ -7652,10 +8205,10 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
7652
8205
|
.argument("<column>", "Column id or key.")
|
|
7653
8206
|
.option("--row-id <row_id>", "Workspace row id to run. Get one from `oxygen tables query <table> --limit 1 --json` — the field is `_row_id`, not `id`.")
|
|
7654
8207
|
.option("--limit <n>", "Run the next N rows whose target cell is still empty (--force runs the first N regardless). Repeat until rowCount is 0 to page through a table. Defaults to 10; inline deterministic runs have a hard cap of 25.")
|
|
7655
|
-
.option("--all", "Run all rows. Requires --background.")
|
|
8208
|
+
.option("--all", "Run all rows. Requires --background, except with --dry-run, which previews the background run without queueing it.")
|
|
7656
8209
|
.option("--filter-json <json>", "Row selector filter object or array for background runs. Do not combine with --all, --limit, or --row-id.")
|
|
7657
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.")
|
|
7658
|
-
.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.")
|
|
7659
8212
|
.option("--connection-id <connection_id>", "Optional provider integration connection id.")
|
|
7660
8213
|
.option("--background", "Create a durable background run for a free deterministic column. Paid AI/tool/enrichment/custom-HTTP server runs are always backgrounded.")
|
|
7661
8214
|
.option("--approved", "Confirm a paid durable run, or a bind create-mode run (onNoMatch=create), after inspecting a dry run or preview.")
|
|
@@ -7685,18 +8238,27 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
7685
8238
|
Boolean(readOption(options.rowId)),
|
|
7686
8239
|
Boolean(effectiveFilterSelection),
|
|
7687
8240
|
].filter(Boolean).length;
|
|
7688
|
-
if (selectedModes > 1) {
|
|
7689
|
-
throw new OxygenError("invalid_selection", "Pass only one of --all, --limit, --row-id, or --filter-json.", {
|
|
7690
|
-
exitCode: 1,
|
|
7691
|
-
});
|
|
7692
|
-
}
|
|
7693
|
-
if (options.all && !options.background) {
|
|
7694
|
-
throw new OxygenError("invalid_column_run", "--all requires --background.", {
|
|
7695
|
-
exitCode: 1,
|
|
7696
|
-
});
|
|
7697
|
-
}
|
|
7698
8241
|
// skipcq: JS-R1005 — intentional branching for local/background/filter column-run modes
|
|
7699
8242
|
await handleAsyncAction("columns run", options, async () => {
|
|
8243
|
+
// Selection-shape errors are raised inside the action so --json
|
|
8244
|
+
// callers get the failure envelope, not a bare stack trace.
|
|
8245
|
+
if (selectedModes > 1) {
|
|
8246
|
+
throw new OxygenError("invalid_selection", "Pass only one of --all, --limit, --row-id, or --filter-json.", {
|
|
8247
|
+
exitCode: 1,
|
|
8248
|
+
});
|
|
8249
|
+
}
|
|
8250
|
+
// A dry run of an all-rows run previews the background run the live
|
|
8251
|
+
// command would create and queues nothing, so demanding --background
|
|
8252
|
+
// for it only teaches a flag with no behavioural basis (blind eval
|
|
8253
|
+
// 2026-09-10: the agent tripped the error, then re-ran with the flag).
|
|
8254
|
+
if (options.all && options.dryRun && !options.background && !options.local) {
|
|
8255
|
+
options.background = true;
|
|
8256
|
+
}
|
|
8257
|
+
if (options.all && !options.background) {
|
|
8258
|
+
throw new OxygenError("invalid_column_run", "--all requires --background.", {
|
|
8259
|
+
exitCode: 1,
|
|
8260
|
+
});
|
|
8261
|
+
}
|
|
7700
8262
|
if (options.local) {
|
|
7701
8263
|
if (options.background) {
|
|
7702
8264
|
throw new OxygenError("invalid_column_run", "Pass either --local or --background, not both.", {
|
|
@@ -8093,7 +8655,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
8093
8655
|
}));
|
|
8094
8656
|
program
|
|
8095
8657
|
.command("table-runs")
|
|
8096
|
-
.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.")
|
|
8097
8659
|
.addCommand(new Command("create")
|
|
8098
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>`.")
|
|
8099
8661
|
.argument("<table>", "Table id or slug.")
|
|
@@ -8135,7 +8697,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
8135
8697
|
.addCommand(new Command("list")
|
|
8136
8698
|
.description("List durable table action runs for one table.")
|
|
8137
8699
|
.requiredOption("--table <table>", "Table id or slug.")
|
|
8138
|
-
.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.")
|
|
8139
8701
|
.option("--limit <n>", "Maximum runs to return. Defaults to 20.")
|
|
8140
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.")
|
|
8141
8703
|
.option("--output <path>", "Write the formatted run history to a file instead of embedding it in the response.")
|
|
@@ -8144,14 +8706,14 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
8144
8706
|
await handleAsyncAction("table-runs list", options, () => listTableRuns(options));
|
|
8145
8707
|
}))
|
|
8146
8708
|
.addCommand(new Command("get")
|
|
8147
|
-
.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.")
|
|
8148
8710
|
.argument("<run_id>", "Table action run UUID or parent workspace run UUID.")
|
|
8149
8711
|
.option("--json", "Print a JSON envelope.")
|
|
8150
8712
|
.action(async (runId, options) => {
|
|
8151
8713
|
await handleAsyncAction("table-runs get", options, () => requestOxygen(`/api/cli/table-action-runs/${encodeURIComponent(runId)}`));
|
|
8152
8714
|
}))
|
|
8153
8715
|
.addCommand(new Command("items")
|
|
8154
|
-
.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.")
|
|
8155
8717
|
.argument("<run_id>", "Table action run UUID.")
|
|
8156
8718
|
.option("--status <status>", "Filter by pending, leased, completed, failed, skipped, or canceled.")
|
|
8157
8719
|
.option("--limit <n>", "Maximum items to return. Defaults to 100.")
|
|
@@ -8538,8 +9100,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
8538
9100
|
.addCommand(new Command("search")
|
|
8539
9101
|
.description("Plan, dry-run, or queue provider-backed company search.")
|
|
8540
9102
|
.addCommand(new Command("plan")
|
|
8541
|
-
.description("Compile a company-search prompt into ordered provider routes without provider calls.")
|
|
8542
|
-
.
|
|
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.")
|
|
9104
|
+
.argument("[prompt]", "Prompt text or @file (same as --prompt). The --prompt flag wins if both are given.")
|
|
9105
|
+
.option("--prompt <text-or-file>", "Company-search prompt, or a path to a prompt file.")
|
|
8543
9106
|
.option("--target-count <n>", "Desired company count. Single-plan ceiling 50,000; larger requests return an explicit clamp warning and segmentation guidance.")
|
|
8544
9107
|
.option("--source-intent <intent>", "Override detected intent: sizing, structured, lookalike, technology, hiring, local, known_source, concept, web, url, or fallback.")
|
|
8545
9108
|
.option("--filters-json <json-or-file>", "CompanySearchFilters JSON inline or a @file/path; wins over individual flags per top-level filter path.")
|
|
@@ -8557,14 +9120,15 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
8557
9120
|
.option("--estimate", "Run a free server-side preflight pass: resolves provider enums and a free count probe for an estimated match count (zero credits).")
|
|
8558
9121
|
.option("--materialize-preview", "Create a preview table with route rows.")
|
|
8559
9122
|
.option("--json", "Print a JSON envelope.")
|
|
8560
|
-
.action(async (options) => {
|
|
9123
|
+
.action(async (promptArg, options) => {
|
|
8561
9124
|
await handleAsyncAction("companies search plan", options, () => requestOxygen("/api/cli/companies/search/plan", {
|
|
8562
9125
|
method: "POST",
|
|
8563
|
-
body: readCompaniesSearchPlanBody(options),
|
|
9126
|
+
body: readCompaniesSearchPlanBody(options, promptArg),
|
|
8564
9127
|
}));
|
|
8565
9128
|
}))
|
|
8566
9129
|
.addCommand(new Command("run")
|
|
8567
9130
|
.description("Return a dry-run request or queue a live company-search ingestion run.")
|
|
9131
|
+
.argument("[prompt]", "Prompt text or @file (same as --prompt). The --prompt flag wins if both are given.")
|
|
8568
9132
|
.option("--prompt <text-or-file>", "Company-search prompt, or a path to a prompt file.")
|
|
8569
9133
|
.option("--plan-json <json-or-file>", "Plan JSON returned by companies search plan, or a path to a JSON file.")
|
|
8570
9134
|
.option("--route-id <id>", "Route id from the plan to execute.")
|
|
@@ -8592,10 +9156,10 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
8592
9156
|
.option("--estimate", "Run a free server-side preflight pass when planning from --prompt: resolves provider enums and a free count probe (zero credits).")
|
|
8593
9157
|
.option("--approved", "Required for live runs after inspecting dry-run output.")
|
|
8594
9158
|
.option("--json", "Print a JSON envelope.")
|
|
8595
|
-
.action(async (options) => {
|
|
9159
|
+
.action(async (promptArg, options) => {
|
|
8596
9160
|
await handleAsyncAction("companies search run", options, () => requestOxygen("/api/cli/companies/search/run", {
|
|
8597
9161
|
method: "POST",
|
|
8598
|
-
body: readCompaniesSearchRunBody(options),
|
|
9162
|
+
body: readCompaniesSearchRunBody(options, promptArg),
|
|
8599
9163
|
}));
|
|
8600
9164
|
})))
|
|
8601
9165
|
.addCommand(new Command("enrich")
|
|
@@ -8604,7 +9168,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
8604
9168
|
.description("Inspect missing company fields, provider routing, and credit estimates without provider calls.")
|
|
8605
9169
|
.argument("<table>", "Table id or slug.")
|
|
8606
9170
|
.option("--missing-fields <fields>", "Comma-separated fields to fill: domain,linkedin_url,headcount,industry,funding,technologies,hiring_signals,company_profile.")
|
|
8607
|
-
.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.")
|
|
8608
9172
|
.option("--all", "Preview all rows.")
|
|
8609
9173
|
.option("--limit <n>", "Preview a limited row scope.")
|
|
8610
9174
|
.option("--row-ids <ids>", "Comma-separated row ids.")
|
|
@@ -8869,7 +9433,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
8869
9433
|
await handleAsyncAction("billing invoices", options, () => requestOxygen("/api/cli/billing/invoices"));
|
|
8870
9434
|
}))
|
|
8871
9435
|
.addCommand(new Command("allowance")
|
|
8872
|
-
.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
|
|
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.")
|
|
8873
9437
|
.option("--json", "Print a JSON envelope.")
|
|
8874
9438
|
.action(async (options) => {
|
|
8875
9439
|
await handleAsyncAction("billing allowance", options, () => requestOxygen("/api/cli/billing/allowance"));
|
|
@@ -9311,6 +9875,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
9311
9875
|
.description("Board of the PRIMARY managed external data providers (enrichment, people/company search, web search, scraping, signals, AI-column web grounding, LLM inference): per provider health probe, 7d traffic, balance, 30d/7d COGS, rate policy, spend ceilings, breaker, and posture, plus the platform runaway guards and how old each snapshot is. Reads the snapshots the crons write; --refresh re-runs those same zero-credit snapshots first. Staff only.")
|
|
9312
9876
|
.option("--refresh", "Re-run the zero-credit snapshot work the three crons run (provider health probes, managed balances, cost snapshot) before reading the board. No paid provider call and no credit spend; takes up to a few minutes.")
|
|
9313
9877
|
.option("--stages <csv>", "Limit --refresh to these stages: health, balances, costs. Defaults to all three.")
|
|
9878
|
+
.option("--force", "Refresh even inside the server's cooldown on re-probing every managed vendor. Only with --refresh.")
|
|
9314
9879
|
.option("--json", "Print a JSON envelope.")
|
|
9315
9880
|
.action(async (options) => {
|
|
9316
9881
|
const stages = readCsvOption(options.stages);
|
|
@@ -9320,6 +9885,13 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
9320
9885
|
emitCliFailure("admin primary-providers", new OxygenError("invalid_request", "--stages only applies to --refresh. Add --refresh to re-run those snapshots.", { exitCode: 2 }));
|
|
9321
9886
|
return;
|
|
9322
9887
|
}
|
|
9888
|
+
if (options.force && !options.refresh) {
|
|
9889
|
+
// Same reason as --stages: --force only relaxes the refresh
|
|
9890
|
+
// cooldown, so on its own it reads the very board the caller
|
|
9891
|
+
// believes it just forced a re-probe of.
|
|
9892
|
+
emitCliFailure("admin primary-providers", new OxygenError("invalid_request", "--force only applies to --refresh. Add --refresh to re-run those snapshots.", { exitCode: 2 }));
|
|
9893
|
+
return;
|
|
9894
|
+
}
|
|
9323
9895
|
let failed = false;
|
|
9324
9896
|
const board = await requestOxygen("/api/cli/admin/primary-providers", options.refresh
|
|
9325
9897
|
? {
|
|
@@ -9330,7 +9902,11 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
9330
9902
|
// finishes — the operator reads a failure for work that
|
|
9331
9903
|
// succeeded and re-runs the outbound probes.
|
|
9332
9904
|
timeoutMs: 300_000,
|
|
9333
|
-
body: {
|
|
9905
|
+
body: {
|
|
9906
|
+
refresh: true,
|
|
9907
|
+
...(stages.length > 0 ? { stages } : {}),
|
|
9908
|
+
...(options.force ? { force: true } : {}),
|
|
9909
|
+
},
|
|
9334
9910
|
}
|
|
9335
9911
|
: undefined).catch((error) => {
|
|
9336
9912
|
emitCliFailure("admin primary-providers", error);
|
|
@@ -9382,9 +9958,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
9382
9958
|
}));
|
|
9383
9959
|
}))
|
|
9384
9960
|
.addCommand(new Command("halt")
|
|
9385
|
-
.description("Force a breaker OPEN — the platform kill switch for managed provider spend.")
|
|
9386
|
-
.option("--scope <kind>", "Scope kind to halt. Defaults to global.", "global")
|
|
9387
|
-
.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, ...).", "*")
|
|
9388
9964
|
.option("--reason <text>", "Why the halt was applied.")
|
|
9389
9965
|
.option("--json", "Print a JSON envelope.")
|
|
9390
9966
|
.action(async (options) => {
|
|
@@ -9399,9 +9975,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
9399
9975
|
}));
|
|
9400
9976
|
}))
|
|
9401
9977
|
.addCommand(new Command("resume")
|
|
9402
|
-
.description("Close a breaker and clear its backoff, resuming managed provider spend.")
|
|
9403
|
-
.option("--scope <kind>", "Scope kind to resume. Defaults to global.", "global")
|
|
9404
|
-
.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, ...).", "*")
|
|
9405
9981
|
.option("--reason <text>", "Why the breaker was reset.")
|
|
9406
9982
|
.option("--json", "Print a JSON envelope.")
|
|
9407
9983
|
.action(async (options) => {
|
|
@@ -9919,7 +10495,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
9919
10495
|
.option("--per-turn-ceiling <number>", "Legacy compatibility only: accepted and clamped, but does not cap an attended turn.")
|
|
9920
10496
|
.option("--journey <slug>", "Optional journey slug to seed the session's goal and context.")
|
|
9921
10497
|
.option("--title <text>", "Optional human title for the session.")
|
|
9922
|
-
.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,
|
|
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.")
|
|
9923
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`.")
|
|
9924
10500
|
.option("--json", "Print a JSON envelope.")
|
|
9925
10501
|
.action(async (options) => {
|
|
@@ -10455,11 +11031,20 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
10455
11031
|
.option("--return <mode>", "Legacy response shape: raw, compact, or summary. Defaults to raw.")
|
|
10456
11032
|
.option("--return-mode <mode>", "Response shape: raw, compact, or summary. Prefer summary for large search responses.")
|
|
10457
11033
|
.option("--oxygen-cursor <cursor>", "Short Oxygen cursor returned as oxygen_next_cursor by a previous tool run.")
|
|
10458
|
-
.option("--max-credits <n>", "Credit ceiling for live paid tools; not needed for no-bill tools.")
|
|
11034
|
+
.option("--max-credits <n>", "Credit ceiling for live paid tools; not needed for no-bill tools, and 0 is accepted for no-bill operations.")
|
|
10459
11035
|
.option("--approved", "Required for live paid tools and external writes after inspecting dry-run output.")
|
|
10460
11036
|
.option("--json", "Print a JSON envelope.")
|
|
10461
11037
|
.action(async (toolId, options) => {
|
|
10462
|
-
|
|
11038
|
+
// Zero is a real ceiling here, not a typo: /api/cli/tools/run only
|
|
11039
|
+
// demands a positive max_credits for BILLED tools, so a documented
|
|
11040
|
+
// no-bill operation is legitimately run with --max-credits 0. The
|
|
11041
|
+
// positive-only reader rejected that locally, before the request.
|
|
11042
|
+
//
|
|
11043
|
+
// Non-negative NUMBER, not readPositiveNumberOrZero (a whole seat
|
|
11044
|
+
// count): this command's own dry-run hint prints "--max-credits
|
|
11045
|
+
// 24.94", so an integer reader would refuse the very command the CLI
|
|
11046
|
+
// just told the operator to run.
|
|
11047
|
+
const maxCredits = readNonNegativeNumber(options.maxCredits);
|
|
10463
11048
|
await handleAsyncAction("tools run", options, () => requestOxygen("/api/cli/tools/run", {
|
|
10464
11049
|
method: "POST",
|
|
10465
11050
|
body: {
|
|
@@ -10502,7 +11087,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
10502
11087
|
.option("--email-pattern-validation <mode>", "Work-email pattern pre-step: leadmagic_valid_only or disabled.")
|
|
10503
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%).")
|
|
10504
11089
|
.option("--verify-phone", "Validate found phone numbers with ClearoutPhone (adds phone_line_type + phone_carrier; filter phone_line_type=mobile for mobile-only).")
|
|
10505
|
-
.option("--allow-premium-lanes", "Opt in to
|
|
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.")
|
|
10506
11091
|
.option("--phone-verification-credential-mode <mode>", "ClearoutPhone credential mode for phone verification: managed or user_api_key.")
|
|
10507
11092
|
.option("--limit <n>", "Rows to estimate. Defaults to 10.")
|
|
10508
11093
|
.option("--all", "Estimate all rows.")
|
|
@@ -10537,7 +11122,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
10537
11122
|
.option("--email-pattern-validation <mode>", "Work-email pattern pre-step: leadmagic_valid_only or disabled.")
|
|
10538
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%).")
|
|
10539
11124
|
.option("--verify-phone", "Validate found phone numbers with ClearoutPhone (adds phone_line_type + phone_carrier; filter phone_line_type=mobile for mobile-only).")
|
|
10540
|
-
.option("--allow-premium-lanes", "Opt in to
|
|
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.")
|
|
10541
11126
|
.option("--phone-verification-credential-mode <mode>", "ClearoutPhone credential mode for phone verification: managed or user_api_key.")
|
|
10542
11127
|
.option("--limit <n>", "Rows to queue.")
|
|
10543
11128
|
.option("--all", "Queue all rows.")
|
|
@@ -10573,7 +11158,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
10573
11158
|
.option("--company-domain <domain>", "Company apex domain, e.g. acme.com.")
|
|
10574
11159
|
.option("--company-name <name>", "Company name.")
|
|
10575
11160
|
.option("--mode <mode>", "dry_run (default) previews the plan and spends nothing; live spends credits and returns the answer.")
|
|
10576
|
-
.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.")
|
|
10577
11162
|
.option("--json", "Print a JSON envelope.")
|
|
10578
11163
|
.action(async (options) => {
|
|
10579
11164
|
await handleAsyncAction("find email", options, () => requestOxygen("/api/cli/find/run", { method: "POST", body: buildFindBody("email", options) }));
|
|
@@ -10587,7 +11172,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
10587
11172
|
.option("--company-name <name>", "Company name.")
|
|
10588
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).")
|
|
10589
11174
|
.option("--mode <mode>", "dry_run (default) previews the plan and spends nothing; live spends credits and returns the answer.")
|
|
10590
|
-
.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.")
|
|
10591
11176
|
.option("--json", "Print a JSON envelope.")
|
|
10592
11177
|
.action(async (options) => {
|
|
10593
11178
|
await handleAsyncAction("find phone", options, () => requestOxygen("/api/cli/find/run", { method: "POST", body: buildFindBody("phone", options) }));
|
|
@@ -10600,7 +11185,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
10600
11185
|
.option("--company-name <name>", "Company name.")
|
|
10601
11186
|
.option("--company-linkedin-url <url>", "Company LinkedIn URL.")
|
|
10602
11187
|
.option("--mode <mode>", "dry_run (default) previews the plan and spends nothing; live spends credits and returns the answer.")
|
|
10603
|
-
.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.")
|
|
10604
11189
|
.option("--json", "Print a JSON envelope.")
|
|
10605
11190
|
.action(async (options) => {
|
|
10606
11191
|
await handleAsyncAction("find linkedin", options, () => requestOxygen("/api/cli/find/run", { method: "POST", body: buildFindBody("linkedin", options) }));
|
|
@@ -10612,7 +11197,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
10612
11197
|
.option("--linkedin-url <url>", "Company LinkedIn URL.")
|
|
10613
11198
|
.option("--fields <fields>", "Comma-separated company fields. Defaults to domain,linkedin_url,headcount,industry.")
|
|
10614
11199
|
.option("--mode <mode>", "dry_run (default) previews the plan and spends nothing; live spends credits and returns the answer.")
|
|
10615
|
-
.option("--max-credits <credits>", "Spend ceiling. Required for --mode live.")
|
|
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.")
|
|
10616
11201
|
.option("--json", "Print a JSON envelope.")
|
|
10617
11202
|
.action(async (options) => {
|
|
10618
11203
|
await handleAsyncAction("find company", options, () => requestOxygen("/api/cli/find/run", { method: "POST", body: buildFindBody("company", options) }));
|
|
@@ -10621,11 +11206,11 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
10621
11206
|
.command("verify")
|
|
10622
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.")
|
|
10623
11208
|
.addCommand(new Command("email")
|
|
10624
|
-
.description("Verify one or more email addresses. MillionVerifier answers first;
|
|
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.")
|
|
10625
11210
|
.argument("<emails...>", "One or more email addresses to verify.")
|
|
10626
11211
|
.option("--mode <mode>", "dry_run (default) previews the plan and spends nothing; live spends credits and returns the answer.")
|
|
10627
11212
|
.option("--approved", "Same as --mode live. Accepted because the global help footer names --approved as the way to authorize a credit-spending command.")
|
|
10628
|
-
.option("--max-credits <credits>", "Spend ceiling for the whole run. Required for --mode live.
|
|
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.")
|
|
10629
11214
|
.option("--json", "Print a JSON envelope.")
|
|
10630
11215
|
.action(async (emails, options) => {
|
|
10631
11216
|
await handleAsyncAction("verify email", options, () => requestOxygen("/api/cli/verify/run", {
|
|
@@ -10658,6 +11243,102 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
10658
11243
|
},
|
|
10659
11244
|
}));
|
|
10660
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
|
+
}));
|
|
10661
11342
|
program
|
|
10662
11343
|
.command("integrations")
|
|
10663
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.")
|
|
@@ -10763,7 +11444,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
10763
11444
|
await handleAsyncAction("integrations list", options, () => requestOxygen("/api/cli/integrations/composio/list"));
|
|
10764
11445
|
}))
|
|
10765
11446
|
.addCommand(new Command("connect")
|
|
10766
|
-
.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).")
|
|
10767
11448
|
.argument("<integration_id>", "Integration id, such as 'slack' or 'serpapi'.")
|
|
10768
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.")
|
|
10769
11450
|
.option("--credential <name=value>", "Named provider credential field. Repeat for multi-field integrations (for example, PostHog: --credential subdomain=us).", collectRepeatable, [])
|
|
@@ -10885,7 +11566,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
10885
11566
|
});
|
|
10886
11567
|
}));
|
|
10887
11568
|
program.addCommand(new Command("senders")
|
|
10888
|
-
.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`.")
|
|
10889
11570
|
.addCommand(new Command("list")
|
|
10890
11571
|
.description("List connected LinkedIn sender accounts with health status, rate limits, and today's usage.")
|
|
10891
11572
|
.option("--status <status>", "Filter by sender status: active, paused, disconnected, restricted, or credentials_required.")
|
|
@@ -11103,7 +11784,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
11103
11784
|
});
|
|
11104
11785
|
})))
|
|
11105
11786
|
.addCommand(new Command("profiles")
|
|
11106
|
-
.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.")
|
|
11107
11788
|
.addCommand(new Command("list")
|
|
11108
11789
|
.description("List sender profiles with their per-channel account counts (LinkedIn / WhatsApp / inboxes).")
|
|
11109
11790
|
.option("--status <status>", "Filter by status: active, paused, or archived.")
|
|
@@ -11141,14 +11822,14 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
11141
11822
|
await handleAsyncAction("senders profiles get", options, () => requestOxygen(`/api/cli/senders/profiles/${encodeURIComponent(id)}`));
|
|
11142
11823
|
}))
|
|
11143
11824
|
.addCommand(new Command("create")
|
|
11144
|
-
.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.")
|
|
11145
11826
|
.option("--name <name>", "Display name for the profile. Optional when --from-linkedin is given (derived from the LinkedIn account).")
|
|
11146
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.")
|
|
11147
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.")
|
|
11148
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.")
|
|
11149
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.")
|
|
11150
11831
|
.option("--status <status>", "Initial status: active (default), paused, or archived.")
|
|
11151
|
-
.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.")
|
|
11152
11833
|
.option("--mailboxes <ids>", "Comma-separated email mailbox ids to attach.")
|
|
11153
11834
|
.option("--json", "Print a JSON envelope.")
|
|
11154
11835
|
.action(async (options) => {
|
|
@@ -11188,12 +11869,12 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
11188
11869
|
}));
|
|
11189
11870
|
}))
|
|
11190
11871
|
.addCommand(new Command("set-photo")
|
|
11191
|
-
.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.")
|
|
11192
11873
|
.argument("<id>", "Sender profile id (from `oxygen senders profiles list`).")
|
|
11193
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.")
|
|
11194
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.")
|
|
11195
|
-
.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.")
|
|
11196
|
-
.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.")
|
|
11197
11878
|
.option("--json", "Print a JSON envelope.")
|
|
11198
11879
|
.action(async (id, options) => {
|
|
11199
11880
|
await handleAsyncAction("senders profiles set-photo", options, async () => {
|
|
@@ -11233,9 +11914,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
11233
11914
|
});
|
|
11234
11915
|
}))
|
|
11235
11916
|
.addCommand(new Command("attach")
|
|
11236
|
-
.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.")
|
|
11237
11918
|
.argument("<id>", "Sender profile id.")
|
|
11238
|
-
.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.")
|
|
11239
11920
|
.option("--mailboxes <ids>", "Comma-separated email mailbox ids to attach.")
|
|
11240
11921
|
.option("--json", "Print a JSON envelope.")
|
|
11241
11922
|
.action(async (id, options) => {
|
|
@@ -11248,7 +11929,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
11248
11929
|
}));
|
|
11249
11930
|
}))
|
|
11250
11931
|
.addCommand(new Command("detach")
|
|
11251
|
-
.description("Detach
|
|
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.")
|
|
11252
11933
|
.argument("<id>", "Sender profile id.")
|
|
11253
11934
|
.option("--senders <ids>", "Comma-separated sender account ids to detach.")
|
|
11254
11935
|
.option("--mailboxes <ids>", "Comma-separated email mailbox ids to detach.")
|
|
@@ -11263,7 +11944,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
11263
11944
|
}));
|
|
11264
11945
|
}))
|
|
11265
11946
|
.addCommand(new Command("delete")
|
|
11266
|
-
.description("Delete a sender profile.
|
|
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.")
|
|
11267
11948
|
.argument("<id>", "Sender profile id.")
|
|
11268
11949
|
.option("--json", "Print a JSON envelope.")
|
|
11269
11950
|
.action(async (id, options) => {
|
|
@@ -11473,7 +12154,46 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
11473
12154
|
});
|
|
11474
12155
|
})));
|
|
11475
12156
|
program.addCommand(new Command("engagement")
|
|
11476
|
-
.description("Capture LinkedIn intent from one known post (harvest needs its public URL or the composite social_id from `oxygen posts get`), and react or comment on it. Recurring capture — a post's engagers on a cadence, 'who viewed my profile', new followers, new connections — is a live table: arm it with `oxygen linkedin intent setup` and operate it with `oxygen feeds list|pause|resume|run`. For
|
|
12157
|
+
.description("Capture LinkedIn intent from one known post (harvest needs its public URL or the composite social_id from `oxygen posts get`), and react or comment on it. Recurring capture — a post's engagers on a cadence, 'who viewed my profile', new followers, new connections — is a live table: arm it with `oxygen linkedin intent setup` and operate it with `oxygen feeds list|pause|resume|run`. For daily monitoring across profiles and their recent post engagers, start with `oxygen tables watcher preview --help` (free credit review; one editable table). Harvests run as a slow, durable drip under a conservative read budget.")
|
|
12158
|
+
.addCommand(new Command("engagers")
|
|
12159
|
+
.description("Read one post's reactors and commenters right now as a de-duplicated people list ready to enroll. Pages both sources up to --max-pages (default 5, max 20) x 100 per page; `truncated: true` in the envelope means a cap stopped a source that still had more — raise --max-pages, or use `oxygen engagement harvest` for a post too big to read in one request. Nothing is sent and no credits are spent, but every page is one LinkedIn read against the sender account. --post is the composite social_id from `oxygen posts get` (NOT the activity URN).")
|
|
12160
|
+
.requiredOption("--post <social_id>", "Composite post social_id from `oxygen posts get` (NOT the activity URN).")
|
|
12161
|
+
.option("--account <ref>", "Sender account that reads (sender id, connection id, or Unipile account id). Omit for the org default.")
|
|
12162
|
+
.option("--limit <n>", "Engagers per PAGE (default 100, which is also the provider maximum). Per source you get --limit x --max-pages.")
|
|
12163
|
+
.option("--max-pages <n>", "Pages to walk per source, 1-20 (default 5, so 500 reactors + 500 commenters).")
|
|
12164
|
+
.option("--no-reactions", "Skip reactors.")
|
|
12165
|
+
.option("--no-comments", "Skip commenters.")
|
|
12166
|
+
.option("--json", "Print a JSON envelope.")
|
|
12167
|
+
.action(async (options) => {
|
|
12168
|
+
await handleAsyncAction("engagement engagers", options, async () => {
|
|
12169
|
+
const post = readOption(options.post);
|
|
12170
|
+
if (!post)
|
|
12171
|
+
throw new Error("--post is required (the composite social_id from `oxygen posts get`).");
|
|
12172
|
+
const account = readOption(options.account);
|
|
12173
|
+
const limit = readPositiveInteger(options.limit);
|
|
12174
|
+
const maxPages = readPositiveInteger(options.maxPages);
|
|
12175
|
+
const params = new URLSearchParams({ post });
|
|
12176
|
+
if (account)
|
|
12177
|
+
params.set("account", account);
|
|
12178
|
+
if (limit !== undefined)
|
|
12179
|
+
params.set("limit", String(limit));
|
|
12180
|
+
// Forwarded as-is: the route owns the 1-20 range so an
|
|
12181
|
+
// out-of-range value fails loudly instead of silently reading
|
|
12182
|
+
// fewer pages than the caller asked for.
|
|
12183
|
+
if (maxPages !== undefined)
|
|
12184
|
+
params.set("max_pages", String(maxPages));
|
|
12185
|
+
if (options.reactions === false)
|
|
12186
|
+
params.set("include_reactions", "false");
|
|
12187
|
+
if (options.comments === false)
|
|
12188
|
+
params.set("include_comments", "false");
|
|
12189
|
+
const data = await requestOxygen(`/api/cli/linkedin/engagement?${params.toString()}`);
|
|
12190
|
+
// Ahead of the payload, and only for a human: a machine caller
|
|
12191
|
+
// reads truncated/partial_reason off the envelope itself.
|
|
12192
|
+
if (!options.json)
|
|
12193
|
+
writeEngagersReceipt(data);
|
|
12194
|
+
return data;
|
|
12195
|
+
});
|
|
12196
|
+
}))
|
|
11477
12197
|
.addCommand(new Command("harvest")
|
|
11478
12198
|
.description("Start (or re-arm) a harvest of a post's engagers into a workspace table you can enroll into a sequence. Engagers drip into the table over many ticks; poll `engagement status` to watch it fill. No messages are sent. Cookieless harvests spend Oxygen credits per scraper page and require --max-credits.")
|
|
11479
12199
|
.requiredOption("--post <social_id_or_url>", "Composite post social_id from `oxygen posts get` (NOT the activity URN), or the public LinkedIn post URL for cookieless.")
|
|
@@ -11613,7 +12333,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
11613
12333
|
});
|
|
11614
12334
|
}))
|
|
11615
12335
|
.addCommand(new Command("status")
|
|
11616
|
-
.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.")
|
|
11617
12337
|
.option("--json", "Print a JSON envelope.")
|
|
11618
12338
|
.action(async (options) => {
|
|
11619
12339
|
await handleAsyncAction("linkedin intent status", options, () => requestOxygen("/api/cli/linkedin/intent"));
|
|
@@ -11624,7 +12344,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
11624
12344
|
// cycle. One verb carrying both postures would make the safe default
|
|
11625
12345
|
// ambiguous on the surface a founder reads first.
|
|
11626
12346
|
.addCommand(new Command("autoenroll")
|
|
11627
|
-
.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`.")
|
|
11628
12348
|
.requiredOption("--account <ref>", "Connected LinkedIn sender whose captures are authorized (sender id, connection id, or Unipile account id).")
|
|
11629
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.")
|
|
11630
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.")
|
|
@@ -11973,7 +12693,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
11973
12693
|
.option("--domain <domains>", "Email only: comma-separated counterpart domains to include.")
|
|
11974
12694
|
.option("--exclude-domain <domains>", "Email only: comma-separated counterpart domains to exclude.")
|
|
11975
12695
|
.option("--mailbox-id <ids>", "Email only: comma-separated mailbox ids.")
|
|
11976
|
-
.option("--search <text>", "
|
|
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.")
|
|
11977
12697
|
.option("--include-archived", "Include archived conversations.")
|
|
11978
12698
|
.option("--limit <n>", "Maximum conversations to return (1-200). Defaults to 50.")
|
|
11979
12699
|
.option("--cursor <cursor>", "channel=all only: the previous page's next_cursor — resumes the merged stream after that row.")
|
|
@@ -12102,7 +12822,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
12102
12822
|
.option("--sender-account-id <ids>", "DMs only: comma-separated LinkedIn/WhatsApp sender account ids.")
|
|
12103
12823
|
.option("--since <iso>", "Only conversations whose last message is on/after this ISO date/timestamp. Cross-channel.")
|
|
12104
12824
|
.option("--until <iso>", "Only conversations whose last message is on/before this ISO date/timestamp. Cross-channel.")
|
|
12105
|
-
.option("--search <text>", "
|
|
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.")
|
|
12106
12826
|
.option("--include-archived", "Also mark archived conversations read.")
|
|
12107
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.")
|
|
12108
12828
|
.option("--yes", "Apply the sweep. Without this flag, returns a preview of the unread count only.")
|
|
@@ -12882,6 +13602,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
12882
13602
|
.option("--max-new-enrollments-per-day <n>", "Daily drip cap on NEW first-touch leads the planner starts (positive integer).")
|
|
12883
13603
|
.option("--sequence-prioritization <mode>", "Under a tight daily budget, serve 'followups' or 'new_leads' first.")
|
|
12884
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.")
|
|
12885
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.")
|
|
12886
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.")
|
|
12887
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.")
|
|
@@ -12984,6 +13705,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
12984
13705
|
.option("--max-new-enrollments-per-day <n>", "Daily drip cap on NEW first-touch leads the planner starts (positive integer).")
|
|
12985
13706
|
.option("--sequence-prioritization <mode>", "Under a tight daily budget, serve 'followups' or 'new_leads' first.")
|
|
12986
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.")
|
|
12987
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.")
|
|
12988
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.")
|
|
12989
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.")
|
|
@@ -13669,18 +14391,63 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
13669
14391
|
});
|
|
13670
14392
|
}))
|
|
13671
14393
|
.addCommand(new Command("remove")
|
|
13672
|
-
.description("Remove
|
|
13673
|
-
.
|
|
13674
|
-
.option("--
|
|
13675
|
-
.
|
|
13676
|
-
|
|
13677
|
-
|
|
13678
|
-
|
|
13679
|
-
|
|
13680
|
-
|
|
13681
|
-
|
|
14394
|
+
.description("Remove one lead provider id (--lead), or every identity of one merged person (--subject-id), from the org do-not-contact list (re-enable contact). A subject id is stamped on each identifier of an imported contact: `hubspot:contacts:<id>` from `suppressions hubspot sync`, `manual:<uuid>` returned by `suppressions import-person`. Read it as metadata.dnc_subject_id in `suppressions list` (LinkedIn rows only) or in `suppressions addresses|phones|companies --json`, which is where an email- or phone-only contact appears.")
|
|
14395
|
+
.option("--lead <provider_id>", "The lead provider id to un-suppress. Mutually exclusive with --subject-id.")
|
|
14396
|
+
.option("--subject-id <id>", "DNC subject id (metadata.dnc_subject_id). Clears that person's email, phone, LinkedIn, and company suppressions in one transaction.")
|
|
14397
|
+
.option("--json", "Print a JSON envelope.")
|
|
14398
|
+
.action(async (options) => {
|
|
14399
|
+
const subjectId = readOption(options.subjectId);
|
|
14400
|
+
if (!subjectId) {
|
|
14401
|
+
await handleAsyncAction("suppressions remove", options, () => {
|
|
14402
|
+
const lead = readOption(options.lead);
|
|
14403
|
+
if (!lead)
|
|
14404
|
+
throw new Error("Pass --lead <provider_id> or --subject-id <id>.");
|
|
14405
|
+
return requestOxygen(`/api/cli/suppressions?lead_provider_id=${encodeURIComponent(lead)}`, {
|
|
14406
|
+
method: "DELETE",
|
|
14407
|
+
});
|
|
13682
14408
|
});
|
|
13683
|
-
|
|
14409
|
+
return;
|
|
14410
|
+
}
|
|
14411
|
+
try {
|
|
14412
|
+
if (readOption(options.lead)) {
|
|
14413
|
+
throw new Error("Pass either --lead or --subject-id, not both.");
|
|
14414
|
+
}
|
|
14415
|
+
const data = await requestOxygen(`/api/cli/suppressions?subject_id=${encodeURIComponent(subjectId)}`, { method: "DELETE" });
|
|
14416
|
+
if (options.json) {
|
|
14417
|
+
writeJson(success("suppressions remove", data));
|
|
14418
|
+
}
|
|
14419
|
+
else {
|
|
14420
|
+
// Same compact multi-ledger receipt style as `suppressions import`:
|
|
14421
|
+
// the per-channel counts plus up to 10 cleared identifiers (the full
|
|
14422
|
+
// list is always in the --json envelope).
|
|
14423
|
+
const parsed = (data ?? {});
|
|
14424
|
+
const identities = Array.isArray(parsed.removed_identities) ? parsed.removed_identities : [];
|
|
14425
|
+
const removedCount = parsed.removed_count ?? 0;
|
|
14426
|
+
writeJson({
|
|
14427
|
+
subject_id: parsed.subject_id ?? subjectId,
|
|
14428
|
+
removed_count: removedCount,
|
|
14429
|
+
removed_emails: parsed.removed?.email ?? 0,
|
|
14430
|
+
removed_phones: parsed.removed?.phone ?? 0,
|
|
14431
|
+
removed_linkedin: parsed.removed?.linkedin ?? 0,
|
|
14432
|
+
removed_companies: parsed.removed?.company ?? 0,
|
|
14433
|
+
removed_identities: identities.slice(0, 10),
|
|
14434
|
+
// Counted off removed_count, not the echoed array: the API caps
|
|
14435
|
+
// that list too, so --json is NOT a way to see the rest. The
|
|
14436
|
+
// counts above are the exact record of what was cleared.
|
|
14437
|
+
...(removedCount > Math.min(identities.length, 10)
|
|
14438
|
+
? {
|
|
14439
|
+
removed_identities_note: `Showing ${Math.min(identities.length, 10)} of ${removedCount} cleared identifiers; the counts above are exact.`,
|
|
14440
|
+
}
|
|
14441
|
+
: {}),
|
|
14442
|
+
...(parsed.note ? { note: parsed.note } : {}),
|
|
14443
|
+
deep_link: parsed.deepLink,
|
|
14444
|
+
});
|
|
14445
|
+
}
|
|
14446
|
+
writeCreditsReceipt(data);
|
|
14447
|
+
}
|
|
14448
|
+
catch (error) {
|
|
14449
|
+
emitCliFailure("suppressions remove", error);
|
|
14450
|
+
}
|
|
13684
14451
|
}))
|
|
13685
14452
|
.addCommand(new Command("import")
|
|
13686
14453
|
.description("Bulk-import a do-not-contact blocklist from a file (newline / comma / whitespace separated, max 5000). An entry with '@' goes on the per-address email list; a bare domain (e.g. acme.com) blocks email to that whole domain; a LinkedIn profile URL or member id (ACo...) lands on the people do-not-contact list; any other URL is rejected. A domain block is a sharp tool, so --reason is REQUIRED when the file contains any domains; otherwise manual is the default. Idempotent. Consumes 0 credits.")
|
|
@@ -13776,6 +14543,12 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
13776
14543
|
});
|
|
13777
14544
|
}))
|
|
13778
14545
|
.addCommand(new Command("import-person")
|
|
14546
|
+
// Deliberately NOT extended with the subjects[] pointer: this help text
|
|
14547
|
+
// is already 199 chars, and the generated CLI reference cell truncates a
|
|
14548
|
+
// description past 200 to its FIRST sentence — appending anything drops
|
|
14549
|
+
// the "domain is company-wide, never inferred from --email" warning from
|
|
14550
|
+
// the public table. The receipt itself carries `subjects[]`, and
|
|
14551
|
+
// `suppressions remove --help` names where subject ids come from.
|
|
13779
14552
|
.description("Import one person as one DNC subject with any combination of email, LinkedIn, E.164 phone, and an explicitly supplied company domain. The domain is company-wide and is never inferred from --email.")
|
|
13780
14553
|
.option("--name <name>", "Optional contact display name.")
|
|
13781
14554
|
.option("--email <address>", "Email address to suppress.")
|
|
@@ -14274,24 +15047,63 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
14274
15047
|
});
|
|
14275
15048
|
})));
|
|
14276
15049
|
program.addCommand(new Command("mailboxes")
|
|
14277
|
-
.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).")
|
|
14278
15051
|
.addCommand(new Command("list")
|
|
14279
|
-
.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`.")
|
|
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.")
|
|
14280
15053
|
.option("--status <status>", "Filter by status: active, paused, disabled, or provisioning (ordered, still being set up).")
|
|
14281
15054
|
.option("--tag <tags>", "Comma-separated workspace tags — matches mailboxes carrying ANY of these tags (see `oxygen tags list`).")
|
|
14282
|
-
.option("--
|
|
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.")
|
|
14283
15059
|
.action(async (options) => {
|
|
14284
|
-
|
|
14285
|
-
|
|
14286
|
-
|
|
14287
|
-
|
|
14288
|
-
|
|
14289
|
-
|
|
14290
|
-
|
|
14291
|
-
|
|
14292
|
-
|
|
14293
|
-
|
|
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;
|
|
14294
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 });
|
|
14295
15107
|
}))
|
|
14296
15108
|
.addCommand(new Command("get")
|
|
14297
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.")
|
|
@@ -14330,13 +15142,13 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
14330
15142
|
});
|
|
14331
15143
|
}))
|
|
14332
15144
|
.addCommand(new Command("health")
|
|
14333
|
-
.description("Fleet email-health: the sending pool rolled up by external deliverability reputation (healthy/degraded/critical/unknown), per-mailbox scores, connected health providers, and DIRECTIONAL recommendations. Pure read — 0 credits; never pauses a mailbox.")
|
|
15145
|
+
.description("Fleet email-health: the sending pool rolled up by external deliverability reputation (healthy/degraded/critical/unknown), per-mailbox scores, connected health providers, and DIRECTIONAL recommendations — plus OXYGEN's OWN evidence for the last 7 days, which needs no provider connected: the bounce notifications each mailbox received classified as provider rejection / dead address / mailbox full / delay (a Gmail reputation block shows up as provider_rejected), distinct recipients that first hard-bounced, sequence sends OXYGEN logged (source=sequence only), warm-up day, health score and stop cause, the advisory daily cap that warm-up age supports, and the same rollup per sending domain ranked worst-first. Notifications whose body was never stored count as signals.dsn7d.unclassified, which means OXYGEN could not look — not that nothing was wrong. Read health_coverage to see how many mailboxes an external provider has actually scored. Start from the two fleet reads instead of scanning the mailbox array: warmup.byCause (why warm-up stopped, counted once for the whole pool) and signals.capOverRecommendedMailboxes (mailboxes whose configured cap is above the one warm-up supports — per mailbox, compare dailyCap with recommendedDailyCap; the gap is flagged as the cap_exceeds_readiness warning). Every count is scoped, and the scope is stamped into the payload as signals.window (7d) and signals.sendSource (sequence sends only), on the fleet object and on every mailbox: a 0 means nothing was recorded in that window for that send source, NOT that the fleet is un-blocklisted everywhere. Pure read — 0 credits; never pauses a mailbox or changes a cap.")
|
|
14334
15146
|
.option("--json", "Print a JSON envelope.")
|
|
14335
15147
|
.action(async (options) => {
|
|
14336
15148
|
await handleAsyncAction("mailboxes health", options, () => requestOxygen("/api/cli/mailboxes/health"));
|
|
14337
15149
|
}))
|
|
14338
15150
|
.addCommand(new Command("compatibility")
|
|
14339
|
-
.description("Read-only compatibility report for
|
|
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.")
|
|
14340
15152
|
.option("--mailboxes <list>", "Comma-separated mailbox ids or addresses. Omit for the whole pool.")
|
|
14341
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.")
|
|
14342
15154
|
.option("--json", "Print a JSON envelope.")
|
|
@@ -14358,11 +15170,11 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
14358
15170
|
});
|
|
14359
15171
|
}))
|
|
14360
15172
|
.addCommand(new Command("import")
|
|
14361
|
-
.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.")
|
|
14362
15174
|
.addHelpText("after", [
|
|
14363
15175
|
"",
|
|
14364
15176
|
"Ordinary identity file contract (CSV / JSON / JSONL / XLSX):",
|
|
14365
|
-
" Canonical fields: email_address, provider, workspace_external_id?, infrastructure_platform?, tenant_id?. Common vendor aliases such as Email, From Email, ESP, Mailbox ID,
|
|
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.",
|
|
14366
15178
|
"",
|
|
14367
15179
|
"All local file imports:",
|
|
14368
15180
|
" Limits: 500 rows / 5 MB for identity and credential files.",
|
|
@@ -14380,6 +15192,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
14380
15192
|
.option("--vendor <slug>", "Non-secret source provenance (for example instantly, mailforge, or smartlead). Required with --from credentials; optional for identity files.")
|
|
14381
15193
|
.option("--connection <id>", "Zapmail connection id (--from zapmail). Defaults to the org's active Zapmail connection.")
|
|
14382
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.")
|
|
14383
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.")
|
|
14384
15197
|
.option("--json", "Print a JSON envelope.")
|
|
14385
15198
|
.action(async (options) => {
|
|
@@ -14389,6 +15202,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
14389
15202
|
const vendor = readOption(options.vendor);
|
|
14390
15203
|
const connection = readOption(options.connection);
|
|
14391
15204
|
const provider = readOption(options.provider);
|
|
15205
|
+
const sender = readOption(options.sender);
|
|
14392
15206
|
const validateOnly = options.validateOnly === true;
|
|
14393
15207
|
if (from &&
|
|
14394
15208
|
from !== "zapmail" &&
|
|
@@ -14412,6 +15226,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
14412
15226
|
source: "zapmail",
|
|
14413
15227
|
...(connection ? { connection_id: connection } : {}),
|
|
14414
15228
|
...(provider ? { service_provider: provider } : {}),
|
|
15229
|
+
...(sender ? { sender_profile_id: sender } : {}),
|
|
14415
15230
|
},
|
|
14416
15231
|
});
|
|
14417
15232
|
}
|
|
@@ -14447,6 +15262,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
14447
15262
|
...(sourceProvider
|
|
14448
15263
|
? { source_provider: sourceProvider }
|
|
14449
15264
|
: {}),
|
|
15265
|
+
...(sender ? { sender_profile_id: sender } : {}),
|
|
14450
15266
|
mailboxes,
|
|
14451
15267
|
},
|
|
14452
15268
|
});
|
|
@@ -14696,7 +15512,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
14696
15512
|
.option("--arm", "Arm the standing permission. Requires --max-credits; previews unless --approved.")
|
|
14697
15513
|
.option("--disarm", "Turn it off. Existing subscriptions are untouched. 0 credits, no approval needed.")
|
|
14698
15514
|
.option("--run", "Enrol one bounded batch now.")
|
|
14699
|
-
.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.")
|
|
14700
15516
|
.option("--max-credits <n>", "Per-billing-cycle credit ceiling this permission may spend (required with --arm).")
|
|
14701
15517
|
.option("--approved", "Actually arm (otherwise --arm returns a preview).")
|
|
14702
15518
|
.option("--json", "Print a JSON envelope.")
|
|
@@ -14958,7 +15774,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
14958
15774
|
});
|
|
14959
15775
|
}))
|
|
14960
15776
|
.addCommand(new Command("resume")
|
|
14961
|
-
.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.")
|
|
14962
15778
|
.option("--mailboxes <list>", "Comma-separated mailbox ids or addresses. Omit for the whole pool.")
|
|
14963
15779
|
.option("--json", "Print a JSON envelope.")
|
|
14964
15780
|
.action(async (options) => {
|
|
@@ -16418,6 +17234,7 @@ Full trigger schema: oxygen workflows schema --subject trigger --json
|
|
|
16418
17234
|
registerVisualCommands(program, handleAsyncAction);
|
|
16419
17235
|
registerFunctionsCommands(program, handleAsyncAction);
|
|
16420
17236
|
registerUgcCommands(program, handleAsyncAction);
|
|
17237
|
+
registerKnowledgeRepositoryCommands(program, handleAsyncAction);
|
|
16421
17238
|
return program;
|
|
16422
17239
|
}
|
|
16423
17240
|
/**
|
|
@@ -18453,11 +19270,15 @@ function readFeedBindBody(table, options) {
|
|
|
18453
19270
|
...(options.approved ? { approved: true } : {}),
|
|
18454
19271
|
};
|
|
18455
19272
|
}
|
|
18456
|
-
function readSignalsSearchPlanBody(options) {
|
|
19273
|
+
function readSignalsSearchPlanBody(options, promptArg) {
|
|
19274
|
+
// The prompt may arrive as --prompt or as the positional argument; the flag wins.
|
|
19275
|
+
const promptSource = options.prompt ?? promptArg;
|
|
18457
19276
|
const targetCount = readPositiveInt(options.targetCount);
|
|
18458
19277
|
const filters = readSignalSearchFilters(options);
|
|
18459
19278
|
return {
|
|
18460
|
-
|
|
19279
|
+
// Omit when neither was given so the request still reaches the server, which
|
|
19280
|
+
// answers with a clean `prompt is required.` instead of crashing readFileIfPresent.
|
|
19281
|
+
...(promptSource !== undefined ? { prompt: readFileIfPresent(promptSource) } : {}),
|
|
18461
19282
|
family: options.family,
|
|
18462
19283
|
...(readOption(options.scope) ? { scope: readOption(options.scope) } : {}),
|
|
18463
19284
|
...(targetCount !== undefined ? { target_count: targetCount } : {}),
|
|
@@ -18465,8 +19286,10 @@ function readSignalsSearchPlanBody(options) {
|
|
|
18465
19286
|
...(options.estimate ? { estimate: true } : {}),
|
|
18466
19287
|
};
|
|
18467
19288
|
}
|
|
18468
|
-
function readSignalsSearchRunBody(options) {
|
|
18469
|
-
|
|
19289
|
+
function readSignalsSearchRunBody(options, promptArg) {
|
|
19290
|
+
// The prompt may arrive as --prompt or as the positional argument; the flag wins.
|
|
19291
|
+
const promptSource = options.prompt ?? promptArg;
|
|
19292
|
+
const prompt = promptSource ? readFileIfPresent(promptSource) : null;
|
|
18470
19293
|
const plan = options.planJson ? readSearchPlanJson(options.planJson) : null;
|
|
18471
19294
|
if (!prompt && !plan) {
|
|
18472
19295
|
throw new OxygenError("invalid_request", "Pass --prompt or --plan-json.", { exitCode: 1 });
|
|
@@ -18538,11 +19361,15 @@ function readSignalSearchFilters(options) {
|
|
|
18538
19361
|
}
|
|
18539
19362
|
return Object.keys(filters).length > 0 ? filters : null;
|
|
18540
19363
|
}
|
|
18541
|
-
function readCompaniesSearchPlanBody(options) {
|
|
19364
|
+
function readCompaniesSearchPlanBody(options, promptArg) {
|
|
19365
|
+
// The prompt may arrive as --prompt or as the positional argument; the flag wins.
|
|
19366
|
+
const promptSource = options.prompt ?? promptArg;
|
|
18542
19367
|
const targetCount = readPositiveInt(options.targetCount);
|
|
18543
19368
|
const filters = readCompanySearchFilters(options);
|
|
18544
19369
|
return {
|
|
18545
|
-
|
|
19370
|
+
// Omit when neither was given so the request still reaches the server, which
|
|
19371
|
+
// answers with a clean `prompt is required.` instead of crashing readFileIfPresent.
|
|
19372
|
+
...(promptSource !== undefined ? { prompt: readFileIfPresent(promptSource) } : {}),
|
|
18546
19373
|
...(targetCount !== undefined ? { target_count: targetCount } : {}),
|
|
18547
19374
|
...(options.sourceIntent ? { source_intent: options.sourceIntent } : {}),
|
|
18548
19375
|
...(filters ? { filters } : {}),
|
|
@@ -18550,8 +19377,10 @@ function readCompaniesSearchPlanBody(options) {
|
|
|
18550
19377
|
...(options.materializePreview ? { materialize_preview: true } : {}),
|
|
18551
19378
|
};
|
|
18552
19379
|
}
|
|
18553
|
-
function readCompaniesSearchRunBody(options) {
|
|
18554
|
-
|
|
19380
|
+
function readCompaniesSearchRunBody(options, promptArg) {
|
|
19381
|
+
// The prompt may arrive as --prompt or as the positional argument; the flag wins.
|
|
19382
|
+
const promptSource = options.prompt ?? promptArg;
|
|
19383
|
+
const prompt = promptSource ? readFileIfPresent(promptSource) : null;
|
|
18555
19384
|
const plan = options.planJson ? readSearchPlanJson(options.planJson) : null;
|
|
18556
19385
|
if (!prompt && !plan) {
|
|
18557
19386
|
throw new OxygenError("invalid_request", "Pass --prompt or --plan-json.", { exitCode: 1 });
|
|
@@ -18993,6 +19822,7 @@ async function importRows(table, options) {
|
|
|
18993
19822
|
let lastResult = null;
|
|
18994
19823
|
let writeRequestCount = 0;
|
|
18995
19824
|
let requestTooLargeRetries = 0;
|
|
19825
|
+
let byteSplits = 0;
|
|
18996
19826
|
let minimumBatchSizeUsed = null;
|
|
18997
19827
|
for (const [batchIndex, batch] of chunk(target.rows, effectiveBatchSize).entries()) {
|
|
18998
19828
|
const result = await writeImportBatchWithAutoSplit({
|
|
@@ -19014,6 +19844,7 @@ async function importRows(table, options) {
|
|
|
19014
19844
|
warningsTruncated = warningsTruncated || result.warningsTruncated || warningCount > warnings.length;
|
|
19015
19845
|
writeRequestCount += result.writeRequestCount;
|
|
19016
19846
|
requestTooLargeRetries += result.requestTooLargeRetries;
|
|
19847
|
+
byteSplits += result.byteSplits;
|
|
19017
19848
|
minimumBatchSizeUsed = minimumBatchSizeUsed === null
|
|
19018
19849
|
? result.minimumBatchSizeUsed
|
|
19019
19850
|
: Math.min(minimumBatchSizeUsed, result.minimumBatchSizeUsed);
|
|
@@ -19033,10 +19864,11 @@ async function importRows(table, options) {
|
|
|
19033
19864
|
batchCount: writeRequestCount,
|
|
19034
19865
|
batchSize: effectiveBatchSize,
|
|
19035
19866
|
...(effectiveBatchSize !== batchSize ? { requestedBatchSize: batchSize } : {}),
|
|
19036
|
-
|
|
19037
|
-
|
|
19038
|
-
|
|
19039
|
-
} : {}),
|
|
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 } : {}),
|
|
19040
19872
|
...(target.tableWebUrl ? { table_web_url: target.tableWebUrl } : {}),
|
|
19041
19873
|
...(readRecordString(lastResult, "web_url") ? { web_url: readRecordString(lastResult, "web_url") } : {}),
|
|
19042
19874
|
};
|
|
@@ -19165,38 +19997,58 @@ function remapImportRows(rows, sourceKeyMap) {
|
|
|
19165
19997
|
});
|
|
19166
19998
|
}
|
|
19167
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
|
+
}
|
|
19168
20020
|
try {
|
|
19169
20021
|
const result = await requestOxygen(input.upsertKey ? "/api/cli/tables/rows/upsert" : "/api/cli/tables/rows", {
|
|
19170
20022
|
method: "POST",
|
|
19171
20023
|
timeoutMs: 300_000,
|
|
19172
|
-
body
|
|
19173
|
-
table: input.tableRef,
|
|
19174
|
-
rows: input.rows,
|
|
19175
|
-
...(input.upsertKey ? { key: input.upsertKey } : {}),
|
|
19176
|
-
...(!input.upsertKey && input.requestId ? { request_id: input.requestId } : {}),
|
|
19177
|
-
},
|
|
20024
|
+
body,
|
|
19178
20025
|
});
|
|
19179
20026
|
return summarizeImportBatchWrite(result, input.rows.length);
|
|
19180
20027
|
}
|
|
19181
20028
|
catch (error) {
|
|
19182
20029
|
if (!isRequestTooLargeError(error) || input.rows.length <= 1)
|
|
19183
20030
|
throw error;
|
|
20031
|
+
// Fallback for a server whose ceiling is tighter than the shared constant.
|
|
19184
20032
|
const midpoint = Math.ceil(input.rows.length / 2);
|
|
19185
20033
|
process.stderr.write(`note: import batch of ${input.rows.length} rows exceeded the request limit; `
|
|
19186
20034
|
+ `retrying as ${midpoint} and ${input.rows.length - midpoint} row batches.\n`);
|
|
19187
|
-
const first = await
|
|
19188
|
-
|
|
19189
|
-
rows: input.rows.slice(0, midpoint),
|
|
19190
|
-
...(input.requestId ? { requestId: `${input.requestId}:0` } : {}),
|
|
19191
|
-
});
|
|
19192
|
-
const second = await writeImportBatchWithAutoSplit({
|
|
19193
|
-
...input,
|
|
19194
|
-
rows: input.rows.slice(midpoint),
|
|
19195
|
-
...(input.requestId ? { requestId: `${input.requestId}:1` } : {}),
|
|
19196
|
-
});
|
|
19197
|
-
return combineImportBatchWriteSummaries(first, second, 1);
|
|
20035
|
+
const [first, second] = await writeImportBatchHalves(input, midpoint);
|
|
20036
|
+
return combineImportBatchWriteSummaries(first, second, { requestTooLargeRetries: 1 });
|
|
19198
20037
|
}
|
|
19199
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
|
+
}
|
|
19200
20052
|
function summarizeImportBatchWrite(result, batchSize) {
|
|
19201
20053
|
const warningCount = readCount(result.warningCount);
|
|
19202
20054
|
const batchWarnings = Array.isArray(result.warnings) ? result.warnings : [];
|
|
@@ -19209,11 +20061,12 @@ function summarizeImportBatchWrite(result, batchSize) {
|
|
|
19209
20061
|
warningsTruncated: result.warningsTruncated === true || warningCount > batchWarnings.length,
|
|
19210
20062
|
writeRequestCount: 1,
|
|
19211
20063
|
requestTooLargeRetries: 0,
|
|
20064
|
+
byteSplits: 0,
|
|
19212
20065
|
minimumBatchSizeUsed: batchSize,
|
|
19213
20066
|
lastResult: result,
|
|
19214
20067
|
};
|
|
19215
20068
|
}
|
|
19216
|
-
function combineImportBatchWriteSummaries(first, second,
|
|
20069
|
+
function combineImportBatchWriteSummaries(first, second, extra) {
|
|
19217
20070
|
const warnings = [...first.warnings, ...second.warnings].slice(0, 20);
|
|
19218
20071
|
const warningCount = first.warningCount + second.warningCount;
|
|
19219
20072
|
return {
|
|
@@ -19224,7 +20077,8 @@ function combineImportBatchWriteSummaries(first, second, extraRequestTooLargeRet
|
|
|
19224
20077
|
warnings,
|
|
19225
20078
|
warningsTruncated: first.warningsTruncated || second.warningsTruncated || warningCount > warnings.length,
|
|
19226
20079
|
writeRequestCount: first.writeRequestCount + second.writeRequestCount,
|
|
19227
|
-
requestTooLargeRetries: first.requestTooLargeRetries + second.requestTooLargeRetries +
|
|
20080
|
+
requestTooLargeRetries: first.requestTooLargeRetries + second.requestTooLargeRetries + (extra.requestTooLargeRetries ?? 0),
|
|
20081
|
+
byteSplits: first.byteSplits + second.byteSplits + (extra.byteSplits ?? 0),
|
|
19228
20082
|
minimumBatchSizeUsed: Math.min(first.minimumBatchSizeUsed, second.minimumBatchSizeUsed),
|
|
19229
20083
|
lastResult: second.lastResult ?? first.lastResult,
|
|
19230
20084
|
};
|
|
@@ -19778,12 +20632,33 @@ async function listTableRuns(options) {
|
|
|
19778
20632
|
runCount: runs.length,
|
|
19779
20633
|
output: outputPath ?? null,
|
|
19780
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
|
+
: {}),
|
|
19781
20640
|
...(typeof result.web_url === "string" ? { web_url: result.web_url } : {}),
|
|
19782
20641
|
};
|
|
19783
20642
|
}
|
|
19784
|
-
|
|
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];
|
|
19785
20655
|
const TABLE_BUNDLE_MAX_PAGE_SIZE = 1000;
|
|
19786
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;
|
|
19787
20662
|
// skipcq: JS-R1005 — intentional branching across describe/query pagination, column-definition normalization, and output sink (stdout/file)
|
|
19788
20663
|
async function exportTableBundle(table, options) {
|
|
19789
20664
|
const pageSize = Math.min(readPositiveInt(options.pageSize) ?? TABLE_BUNDLE_DEFAULT_PAGE_SIZE, TABLE_BUNDLE_MAX_PAGE_SIZE);
|
|
@@ -19793,96 +20668,198 @@ async function exportTableBundle(table, options) {
|
|
|
19793
20668
|
const describe = await requestOxygen("/api/cli/tables/describe", { method: "POST", body: { table } });
|
|
19794
20669
|
const tableMeta = describe.table ?? null;
|
|
19795
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;
|
|
19796
20688
|
const rows = [];
|
|
19797
20689
|
let cursor = null;
|
|
19798
20690
|
let expectedTotal = null;
|
|
19799
20691
|
let pageCount = 0;
|
|
20692
|
+
let rowCount = 0;
|
|
19800
20693
|
let hasMoreFlag = false;
|
|
19801
|
-
|
|
19802
|
-
|
|
19803
|
-
|
|
19804
|
-
|
|
19805
|
-
|
|
19806
|
-
|
|
19807
|
-
|
|
19808
|
-
|
|
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
|
+
}));
|
|
19809
20702
|
}
|
|
19810
|
-
|
|
19811
|
-
|
|
19812
|
-
|
|
19813
|
-
|
|
19814
|
-
|
|
19815
|
-
|
|
19816
|
-
|
|
19817
|
-
|
|
19818
|
-
|
|
19819
|
-
|
|
19820
|
-
|
|
19821
|
-
|
|
19822
|
-
|
|
19823
|
-
|
|
19824
|
-
|
|
19825
|
-
|
|
19826
|
-
|
|
19827
|
-
|
|
19828
|
-
|
|
19829
|
-
|
|
19830
|
-
|
|
19831
|
-
|
|
19832
|
-
|
|
19833
|
-
|
|
19834
|
-
|
|
19835
|
-
|
|
19836
|
-
|
|
19837
|
-
|
|
19838
|
-
|
|
19839
|
-
|
|
19840
|
-
|
|
19841
|
-
|
|
19842
|
-
|
|
19843
|
-
|
|
19844
|
-
|
|
19845
|
-
|
|
19846
|
-
totals
|
|
19847
|
-
rowCount
|
|
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,
|
|
19848
20741
|
...(expectedTotal !== null ? { sourceTotalCount: expectedTotal } : {}),
|
|
19849
20742
|
pages: pageCount,
|
|
19850
20743
|
pageSize,
|
|
19851
|
-
}
|
|
19852
|
-
|
|
19853
|
-
|
|
19854
|
-
|
|
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;
|
|
19855
20826
|
}
|
|
19856
|
-
const summary = {
|
|
19857
|
-
table_id: tableId ?? null,
|
|
19858
|
-
table_slug: tableSlug ?? null,
|
|
19859
|
-
schema_version: TABLE_BUNDLE_SCHEMA_VERSION,
|
|
19860
|
-
column_count: columns.length,
|
|
19861
|
-
row_count: rows.length,
|
|
19862
|
-
pages: pageCount,
|
|
19863
|
-
page_size: pageSize,
|
|
19864
|
-
output: options.output ?? null,
|
|
19865
|
-
...(tableId ? { web_url: tableWebUrl(tableId) } : tableSlug ? { web_url: tableWebUrl(tableSlug) } : {}),
|
|
19866
|
-
};
|
|
19867
|
-
// When piping to stdout, also emit the full bundle so it can be redirected
|
|
19868
|
-
// into a file. With --output, the file is the source of truth; keep the
|
|
19869
|
-
// stdout response a small summary so it's readable.
|
|
19870
|
-
return options.output ? summary : { ...summary, bundle };
|
|
19871
20827
|
}
|
|
19872
20828
|
async function importTableBundle(// skipcq: JS-R1005
|
|
19873
|
-
options) {
|
|
20829
|
+
options, binaryName) {
|
|
19874
20830
|
const path = options.file;
|
|
19875
20831
|
if (!path || !path.trim()) {
|
|
19876
20832
|
throw new OxygenError("invalid_input", "--file is required.", { exitCode: 1 });
|
|
19877
20833
|
}
|
|
19878
20834
|
const batchSize = normalizeImportBatchSize(options.batchSize);
|
|
19879
20835
|
const effectiveBatchSize = Math.min(batchSize, SAFE_IMPORT_WRITE_BATCH_SIZE);
|
|
19880
|
-
const
|
|
19881
|
-
|
|
19882
|
-
|
|
19883
|
-
|
|
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) {
|
|
19884
20858
|
throw new OxygenError("invalid_bundle", "Bundle has no columns; nothing to import.", { exitCode: 1 });
|
|
19885
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
|
+
}
|
|
19886
20863
|
const validColumnKeys = new Set(columns.map((column) => column.key).filter((key) => Boolean(key)));
|
|
19887
20864
|
const into = readOption(options.into);
|
|
19888
20865
|
const upsertKey = readOption(options.key);
|
|
@@ -19940,13 +20917,14 @@ options) {
|
|
|
19940
20917
|
?? (created ? readRecordString(created, "slug") : null);
|
|
19941
20918
|
createdTable = true;
|
|
19942
20919
|
}
|
|
19943
|
-
const stagedRows = bundle.rows.map((row) => stripRowForImport(row, validColumnKeys));
|
|
19944
20920
|
// In upsert mode every row must carry the key value; otherwise the upsert
|
|
19945
|
-
// route rejects the whole batch midway.
|
|
19946
|
-
//
|
|
19947
|
-
// predictable data problem.
|
|
19948
|
-
|
|
19949
|
-
|
|
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);
|
|
19950
20928
|
if (missingIndex >= 0) {
|
|
19951
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 });
|
|
19952
20930
|
}
|
|
@@ -19956,28 +20934,34 @@ options) {
|
|
|
19956
20934
|
tableId: newTableId,
|
|
19957
20935
|
key: upsertKey ?? null,
|
|
19958
20936
|
batchSize: effectiveBatchSize,
|
|
20937
|
+
binaryName,
|
|
19959
20938
|
});
|
|
19960
20939
|
let processed = 0;
|
|
19961
20940
|
let insertedCount = 0;
|
|
19962
20941
|
let updatedCount = 0;
|
|
19963
|
-
|
|
19964
|
-
|
|
19965
|
-
|
|
19966
|
-
|
|
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) {
|
|
19967
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
|
+
});
|
|
19968
20960
|
if (upsertKey) {
|
|
19969
|
-
|
|
19970
|
-
|
|
19971
|
-
body: { table: newTableId, key: upsertKey, rows: batch, return: "summary" },
|
|
19972
|
-
});
|
|
19973
|
-
insertedCount += readCount(response.insertedCount);
|
|
19974
|
-
updatedCount += readCount(response.updatedCount);
|
|
20961
|
+
insertedCount += result.insertedCount;
|
|
20962
|
+
updatedCount += result.updatedCount;
|
|
19975
20963
|
}
|
|
19976
20964
|
else {
|
|
19977
|
-
await requestOxygen("/api/cli/tables/rows", {
|
|
19978
|
-
method: "POST",
|
|
19979
|
-
body: { table: newTableId, rows: batch },
|
|
19980
|
-
});
|
|
19981
20965
|
insertedCount += batch.length;
|
|
19982
20966
|
}
|
|
19983
20967
|
processed += batch.length;
|
|
@@ -19994,13 +20978,13 @@ options) {
|
|
|
19994
20978
|
const recovery = upsertKey
|
|
19995
20979
|
? `Re-run with --into ${newTableId} --key ${upsertKey} to resume — already-imported rows are matched by "${upsertKey}", not duplicated.`
|
|
19996
20980
|
: `Resume with: ${resumeCommand}`;
|
|
19997
|
-
throw new OxygenError("bundle_import_incomplete", `Bundle import stopped after ${processed}/${
|
|
20981
|
+
throw new OxygenError("bundle_import_incomplete", `Bundle import stopped after ${processed}/${bundle.rowCount} rows. ${recovery}`, {
|
|
19998
20982
|
details: {
|
|
19999
20983
|
table_id: newTableId,
|
|
20000
20984
|
table_slug: newTableSlug,
|
|
20001
|
-
rows_total:
|
|
20985
|
+
rows_total: bundle.rowCount,
|
|
20002
20986
|
rows_processed: processed,
|
|
20003
|
-
failed_batch_offset:
|
|
20987
|
+
failed_batch_offset: processed,
|
|
20004
20988
|
failed_batch_size: batch.length,
|
|
20005
20989
|
mode: upsertKey ? "upsert" : "insert",
|
|
20006
20990
|
...(upsertKey ? { key: upsertKey } : {}),
|
|
@@ -20019,6 +21003,12 @@ options) {
|
|
|
20019
21003
|
column_count: columns.length,
|
|
20020
21004
|
row_count: processed,
|
|
20021
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
|
+
: {}),
|
|
20022
21012
|
...(upsertKey
|
|
20023
21013
|
? { upsert_key: upsertKey, inserted_count: insertedCount, updated_count: updatedCount }
|
|
20024
21014
|
: {}),
|
|
@@ -20034,7 +21024,10 @@ options) {
|
|
|
20034
21024
|
function buildBundleResumeCommand(input) {
|
|
20035
21025
|
const fileArg = /\s/.test(input.file) ? `"${input.file}"` : input.file;
|
|
20036
21026
|
const parts = [
|
|
20037
|
-
"oxygen
|
|
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`,
|
|
20038
21031
|
`--file ${fileArg}`,
|
|
20039
21032
|
`--into ${input.tableId}`,
|
|
20040
21033
|
`--key ${input.key ?? "<uniqueColumn>"}`,
|
|
@@ -20083,6 +21076,183 @@ function bundleColumnToCreateInput(column) {
|
|
|
20083
21076
|
: {}),
|
|
20084
21077
|
};
|
|
20085
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
|
+
}
|
|
20086
21256
|
function parseBundleFile(text) {
|
|
20087
21257
|
let parsed;
|
|
20088
21258
|
try {
|
|
@@ -20099,9 +21269,12 @@ function parseBundleFile(text) {
|
|
|
20099
21269
|
}
|
|
20100
21270
|
const record = parsed;
|
|
20101
21271
|
const schemaVersion = typeof record.schemaVersion === "number" ? record.schemaVersion : 1;
|
|
20102
|
-
|
|
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) {
|
|
20103
21276
|
throw new OxygenError("unsupported_bundle_version", `Bundle schema version ${schemaVersion} is not supported by this CLI.`, {
|
|
20104
|
-
details: { supported:
|
|
21277
|
+
details: { supported: TABLE_BUNDLE_SUPPORTED_SCHEMA_VERSIONS, got: schemaVersion },
|
|
20105
21278
|
exitCode: 1,
|
|
20106
21279
|
});
|
|
20107
21280
|
}
|
|
@@ -20325,14 +21498,24 @@ function readRecord(value, key) {
|
|
|
20325
21498
|
const entry = value[key];
|
|
20326
21499
|
return isRecord(entry) ? entry : null;
|
|
20327
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.
|
|
20328
21506
|
function tableWebUrl(tableIdOrSlug) {
|
|
20329
|
-
return
|
|
21507
|
+
return `${defaultApiUrl().replace(/\/+$/, "")}/tables/${encodeURIComponent(tableIdOrSlug)}`;
|
|
20330
21508
|
}
|
|
20331
21509
|
function formatImportFileSizeLimit(tier) {
|
|
20332
21510
|
const limits = PLAN_LIMITS[tier].import;
|
|
20333
21511
|
const megabytes = Math.round(limits.maxFileBytes / (1024 * 1024));
|
|
20334
21512
|
return `${megabytes} MB`;
|
|
20335
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
|
+
}
|
|
20336
21519
|
// A backgrounded import returns as soon as the file is staged, with counts.rows
|
|
20337
21520
|
// still 0 while the worker loads. With no follow-up command in the envelope that
|
|
20338
21521
|
// reads as a truncated import - which is how a 500-row `--batch-size` default
|
|
@@ -23359,6 +24542,28 @@ function readCsvOption(value) {
|
|
|
23359
24542
|
.map((entry) => entry.trim())
|
|
23360
24543
|
.filter(Boolean);
|
|
23361
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
|
+
}
|
|
23362
24567
|
// Assemble the directory listing profile payload shared by `directory update`
|
|
23363
24568
|
// and `admin directory enable`. --profile-json provides the base object (and
|
|
23364
24569
|
// the only way to null-clear fields); explicit flags override it.
|
|
@@ -24341,6 +25546,27 @@ function readPositiveNumber(value) {
|
|
|
24341
25546
|
}
|
|
24342
25547
|
return parsed;
|
|
24343
25548
|
}
|
|
25549
|
+
/**
|
|
25550
|
+
* A spend ceiling where ZERO IS VALID and meaningful: `find company
|
|
25551
|
+
* --max-credits 0` asks for the lanes that cost nothing (crustdata's identify
|
|
25552
|
+
* lane resolves domain↔LinkedIn for 0 credits) and skips every priced one.
|
|
25553
|
+
* readPositiveNumber rejected it client-side, so the free lanes were
|
|
25554
|
+
* unreachable from the CLI at all (Plain T-111). Fractional ceilings stay legal
|
|
25555
|
+
* — lane estimates are fractional — so this is not the whole-number reader.
|
|
25556
|
+
*/
|
|
25557
|
+
function readCreditCeilingOrZero(value) {
|
|
25558
|
+
const trimmed = value?.trim();
|
|
25559
|
+
if (!trimmed)
|
|
25560
|
+
return undefined;
|
|
25561
|
+
const parsed = Number(trimmed);
|
|
25562
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
25563
|
+
throw new OxygenError("invalid_number", "Expected a number of 0 or more.", {
|
|
25564
|
+
details: { value },
|
|
25565
|
+
exitCode: 1,
|
|
25566
|
+
});
|
|
25567
|
+
}
|
|
25568
|
+
return parsed;
|
|
25569
|
+
}
|
|
24344
25570
|
/**
|
|
24345
25571
|
* A whole count (days, rows, windows). Its positive-number sibling accepts 2.5,
|
|
24346
25572
|
* which for a scan bound is always a typo — rejected here so it costs no round trip,
|
|
@@ -24392,19 +25618,6 @@ function readNonNegativeNumber(value) {
|
|
|
24392
25618
|
}
|
|
24393
25619
|
return parsed;
|
|
24394
25620
|
}
|
|
24395
|
-
function readNonNegativeInt(value) {
|
|
24396
|
-
const trimmed = value?.trim();
|
|
24397
|
-
if (!trimmed)
|
|
24398
|
-
return undefined;
|
|
24399
|
-
const parsed = Number(trimmed);
|
|
24400
|
-
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
|
24401
|
-
throw new OxygenError("invalid_number", "Expected a non-negative integer.", {
|
|
24402
|
-
details: { value },
|
|
24403
|
-
exitCode: 1,
|
|
24404
|
-
});
|
|
24405
|
-
}
|
|
24406
|
-
return parsed;
|
|
24407
|
-
}
|
|
24408
25621
|
// Folds the sequence-level email send controls (--max-emails-per-mailbox-per-day,
|
|
24409
25622
|
// --send-window-file) into a partial `settings` object. Returns undefined when
|
|
24410
25623
|
// neither flag is set so the field is omitted from the request body entirely.
|
|
@@ -24465,6 +25678,12 @@ function readSequenceSettings(options) {
|
|
|
24465
25678
|
const espMatching = readOption(options.espMatching);
|
|
24466
25679
|
if (espMatching)
|
|
24467
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
|
+
}
|
|
24468
25687
|
const senderFailover = readOption(options.senderFailover);
|
|
24469
25688
|
if (senderFailover)
|
|
24470
25689
|
settings.sender_failover = senderFailover;
|