@oxygen-agent/cli 1.1011.98 → 1.1011.288
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 +25 -5
- package/dist/column-run-notices.d.ts +16 -0
- package/dist/column-run-notices.js +57 -0
- package/dist/command-manifest.js +10 -1
- package/dist/help.d.ts +27 -1
- package/dist/help.js +112 -29
- package/dist/http-client.js +8 -0
- package/dist/index.js +348 -127
- package/node_modules/@oxygen/cli-ugc/dist/commands.js +20 -69
- package/node_modules/@oxygen/shared/dist/byok-connect.d.ts +6 -4
- package/node_modules/@oxygen/shared/dist/byok-connect.js +11 -4
- package/node_modules/@oxygen/shared/dist/capability-discovery.d.ts +127 -1
- package/node_modules/@oxygen/shared/dist/capability-discovery.js +286 -24
- package/node_modules/@oxygen/shared/dist/collab.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/collab.js +12 -0
- package/node_modules/@oxygen/shared/dist/column-decision.js +7 -3
- package/node_modules/@oxygen/shared/dist/column-output-fields.js +45 -3
- package/node_modules/@oxygen/shared/dist/company-enrichment-fields.d.ts +20 -13
- package/node_modules/@oxygen/shared/dist/company-enrichment-fields.js +29 -13
- package/node_modules/@oxygen/shared/dist/contracts/sender-limits.js +1 -1
- package/node_modules/@oxygen/shared/dist/copilot-skills.generated.d.ts +2 -2
- package/node_modules/@oxygen/shared/dist/copilot-skills.generated.js +2 -2
- package/node_modules/@oxygen/shared/dist/credit-guidance.js +12 -1
- package/node_modules/@oxygen/shared/dist/enrichment-intents.d.ts +37 -0
- package/node_modules/@oxygen/shared/dist/enrichment-intents.js +79 -44
- package/node_modules/@oxygen/shared/dist/linkedin-url.d.ts +7 -0
- package/node_modules/@oxygen/shared/dist/linkedin-url.js +9 -0
- package/node_modules/@oxygen/shared/dist/log.d.ts +1 -0
- package/node_modules/@oxygen/shared/dist/log.js +15 -0
- package/node_modules/@oxygen/shared/dist/operational-telemetry.d.ts +1 -0
- package/node_modules/@oxygen/shared/dist/operational-telemetry.js +6 -2
- package/node_modules/@oxygen/shared/dist/otlp-log-sink.d.ts +7 -3
- package/node_modules/@oxygen/shared/dist/otlp-log-sink.js +22 -1
- package/node_modules/@oxygen/shared/dist/plan-limits.d.ts +3 -7
- package/node_modules/@oxygen/shared/dist/plan-limits.js +2 -14
- package/node_modules/@oxygen/shared/dist/sending-schedule.d.ts +39 -0
- package/node_modules/@oxygen/shared/dist/sending-schedule.js +41 -0
- package/node_modules/@oxygen/shared/dist/sequence-lifecycle.d.ts +19 -0
- package/node_modules/@oxygen/shared/dist/sequence-lifecycle.js +20 -0
- package/node_modules/@oxygen/shared/dist/sequence-sender-variables.d.ts +35 -0
- package/node_modules/@oxygen/shared/dist/sequence-sender-variables.js +63 -0
- package/node_modules/@oxygen/shared/dist/sequences.d.ts +13 -7
- package/node_modules/@oxygen/shared/dist/sequences.js +26 -0
- package/node_modules/@oxygen/shared/dist/signup-lead-payload.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/signup-lead-payload.js +5 -0
- package/node_modules/@oxygen/shared/dist/social-post-metrics-series.js +1 -15
- package/node_modules/@oxygen/shared/dist/standard-webhooks.d.ts +51 -0
- package/node_modules/@oxygen/shared/dist/standard-webhooks.js +124 -0
- package/node_modules/@oxygen/shared/dist/tags.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/tags.js +13 -1
- package/node_modules/@oxygen/shared/dist/ugc.d.ts +11 -7
- package/node_modules/@oxygen/shared/dist/ugc.js +14 -6
- package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
- package/node_modules/@oxygen/shared/dist/zoned-time.d.ts +39 -0
- package/node_modules/@oxygen/shared/dist/zoned-time.js +81 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -13,7 +13,7 @@ import { registerVisualCommands } from "./visual-commands.js";
|
|
|
13
13
|
import { renderPrimaryProviderBoard } from "./admin-primary-providers-render.js";
|
|
14
14
|
import { registerFunctionsCommands } from "./functions-commands.js";
|
|
15
15
|
import { STREAMED_FILE_IMPORT_MIN_BYTES, putImportFileStream, rowsForStreamedImportTarget, scanStreamedImportFile, shouldStreamFileImport, } from "./streamed-file-import.js";
|
|
16
|
-
import { applyOxygenHelp, enableCommandSuggestions, unknownCommandHint, unknownOptionHint } from "./help.js";
|
|
16
|
+
import { applyOxygenHelp, argvRequestsJson, commanderUsageFailure, enableCommandSuggestions, unknownCommandHint, unknownOptionHint, withoutHelpForUnknownCommand, } from "./help.js";
|
|
17
17
|
import { buildCommandManifest, getCommandManifestEntry, searchCommandManifest, suggestCommandNames, } from "./command-manifest.js";
|
|
18
18
|
import { AGENCY_DIRECTORY_REGIONS, AGENCY_DIRECTORY_SERVICES, COLLAB_GATE_KINDS, COLLAB_GATE_PROSE, COLLAB_SUBJECT_KINDS, COLLAB_SUBJECT_KINDS_PROSE, COLLAB_SUBJECT_LABELS, COLLAB_SUBJECT_PROSE, GATE_KIND_SUBJECTS, describeWorkflowStatusChange, formatCellForDisplay, formatCopilotPlanDuration, formatCopilotPlanSeconds, formatPublicBudgetScopes, SUBJECT_PATH_FORMS_PROSE, formatSubjectPath, exitCodeForOxygenError, parseSubjectPath, parseSubjectRef, parseWorkflowStatusChange, chunk, isVersionGreater, isVersionLess, KNOWLEDGE_BOOTSTRAP_MAX_CREDITS, MAX_CLI_JSON_BODY_BYTES, MAX_MCP_TOOL_NAME_LENGTH, normalizeCopilotPlanStepStatus, OXYGEN_CAPABILITY_ROUTES, OXYGEN_VERSION, OxygenError, getCapabilityRouteMatch, inferUserCapabilityRoute, parseKnowledgePageMarkdown, PLAN_LIMITS, serializeCapabilityRoute, sleep, success, TAG_KINDS_PROSE, toFailure, workflowMcpToolName, } from "@oxygen/shared";
|
|
19
19
|
import { PURCHASABLE_PLAN_KEYS, planVolumeBonusPercent, resolveBasePricingPlan } from "@oxygen/shared/billing";
|
|
@@ -21,6 +21,7 @@ import { TAG_COLORS } from "@oxygen/shared/select-options";
|
|
|
21
21
|
import { PUBLIC_WEBHOOK_INGRESS_PER_TARGET_PER_MINUTE } from "@oxygen/shared/plan-band";
|
|
22
22
|
import { AI_COLUMN_CREDITS, AI_COLUMN_WEB_SEARCH_TYPICAL_CREDITS } from "@oxygen/shared/pricing-sheet";
|
|
23
23
|
import { LINKEDIN_SENDER_LIMIT_DEFAULTS, LINKEDIN_SENDER_LIMIT_MAXIMUMS, } from "@oxygen/shared/linkedin-sequences";
|
|
24
|
+
import { SEQUENCE_LIFECYCLES, sequenceDisplayStatus } from "@oxygen/shared/sequence-lifecycle";
|
|
24
25
|
import { operationPath } from "@oxygen/shared/contracts/operations";
|
|
25
26
|
import { SENDER_LIMITS_SET_OPERATION } from "@oxygen/shared/contracts/sender-limits";
|
|
26
27
|
import { addContractFlags } from "./contract-flags.js";
|
|
@@ -38,7 +39,7 @@ import { ensureFreshCliForApiUrl, requestOxygen } from "./http-client.js";
|
|
|
38
39
|
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";
|
|
39
40
|
import { waitForCliRun } from "./run-wait.js";
|
|
40
41
|
import { assertModeFlagsExclusive, parseKeyValuePairs, parseJsonObject, readJsonObjectOption, readNonNegativeInt, readPositiveInt, readRecordString, resolveLiveDryRunMode, } from "./cli-values.js";
|
|
41
|
-
import { formatAiPromptPreviewNotice, formatColumnReferenceNotices, } from "./column-run-notices.js";
|
|
42
|
+
import { formatAiPromptPreviewNotice, formatColumnReferenceNotices, formatEnrichmentInputMappingNotice, formatProviderLaneNotices, } from "./column-run-notices.js";
|
|
42
43
|
import { runLocalCustomHttpColumn } from "./local-custom-http-column.js";
|
|
43
44
|
import { formatSearchAiFilterTotalsNotice } from "./search-ai-filter-notice.js";
|
|
44
45
|
import { formatInboxNeedsReplyNotice } from "./inbox-needs-reply-notice.js";
|
|
@@ -867,6 +868,13 @@ function writeManagedProviderAvailabilityNotice(data) {
|
|
|
867
868
|
if (!payload)
|
|
868
869
|
return;
|
|
869
870
|
const availability = asPayloadRecord(payload.provider_availability);
|
|
871
|
+
// `columns run --dry-run` and `enrich-column preview` name their lanes: one
|
|
872
|
+
// line per lane that cannot run, led by the verdict (OXP-1.14.12).
|
|
873
|
+
if (availability && Array.isArray(availability.lanes)) {
|
|
874
|
+
for (const line of formatProviderLaneNotices(availability))
|
|
875
|
+
process.stderr.write(`${line}\n`);
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
870
878
|
if (availability) {
|
|
871
879
|
const providers = asPayloadRecord(availability.providers) ?? {};
|
|
872
880
|
for (const [provider, entry] of Object.entries(providers)) {
|
|
@@ -1159,7 +1167,7 @@ function writeAiPromptPreviewNotice(data) {
|
|
|
1159
1167
|
process.stderr.write(`${notice}\n`);
|
|
1160
1168
|
}
|
|
1161
1169
|
function writeColumnReferenceNotices(data) {
|
|
1162
|
-
for (const notice of formatColumnReferenceNotices(data)) {
|
|
1170
|
+
for (const notice of [...formatColumnReferenceNotices(data), ...formatEnrichmentInputMappingNotice(data)]) {
|
|
1163
1171
|
process.stderr.write(`${notice}\n`);
|
|
1164
1172
|
}
|
|
1165
1173
|
}
|
|
@@ -2360,10 +2368,32 @@ function buildPublishingPostCreateBody(options) {
|
|
|
2360
2368
|
body.ugc_participation_id = ugcParticipationId;
|
|
2361
2369
|
}
|
|
2362
2370
|
const xContent = buildPublishingXContent(options, { forMerge: false });
|
|
2363
|
-
|
|
2364
|
-
|
|
2371
|
+
const firstComment = readPublishingFirstComment(options);
|
|
2372
|
+
if (content || xContent || firstComment) {
|
|
2373
|
+
body.content = { ...(content ?? {}), ...(xContent ?? {}), ...(firstComment ? { first_comment: firstComment } : {}) };
|
|
2374
|
+
}
|
|
2365
2375
|
return body;
|
|
2366
2376
|
}
|
|
2377
|
+
/**
|
|
2378
|
+
* `--first-comment` / `--clear-first-comment`: the comment to set, null to remove
|
|
2379
|
+
* it (update only), or undefined when neither flag was passed. The API refuses a
|
|
2380
|
+
* first comment on a provider that cannot post one (publishing_first_comment_unsupported),
|
|
2381
|
+
* judging the post's stored provider too, so the CLI does not keep its own list.
|
|
2382
|
+
*/
|
|
2383
|
+
function readPublishingFirstComment(options) {
|
|
2384
|
+
if (options.firstComment !== undefined && options.clearFirstComment) {
|
|
2385
|
+
throw new OxygenError("invalid_request", "Choose --first-comment or --clear-first-comment, not both.", { exitCode: 2 });
|
|
2386
|
+
}
|
|
2387
|
+
if (options.clearFirstComment)
|
|
2388
|
+
return null;
|
|
2389
|
+
if (options.firstComment === undefined)
|
|
2390
|
+
return undefined;
|
|
2391
|
+
const text = options.firstComment.trim();
|
|
2392
|
+
if (!text) {
|
|
2393
|
+
throw new OxygenError("invalid_request", "--first-comment needs the comment text. To remove a post's first comment, use --clear-first-comment on `publishing posts update`.", { exitCode: 2 });
|
|
2394
|
+
}
|
|
2395
|
+
return text;
|
|
2396
|
+
}
|
|
2367
2397
|
/**
|
|
2368
2398
|
* The X flags as content keys (see the API's X content contract). On update they
|
|
2369
2399
|
* become a `content_merge` so the rest of the stored content is kept; there
|
|
@@ -2471,8 +2501,17 @@ function buildPublishingPostUpdateBody(options) {
|
|
|
2471
2501
|
body.content = content;
|
|
2472
2502
|
const merge = readOption(options.contentMergeJson);
|
|
2473
2503
|
const xContent = buildPublishingXContent(options, { forMerge: true });
|
|
2474
|
-
|
|
2475
|
-
|
|
2504
|
+
// A merge key, so the post's media and other content survive the change.
|
|
2505
|
+
const firstComment = readPublishingFirstComment(options);
|
|
2506
|
+
if (merge || xContent || firstComment !== undefined) {
|
|
2507
|
+
body.content_merge = {
|
|
2508
|
+
...(merge ? parseJsonObject(merge) : {}),
|
|
2509
|
+
...(xContent ?? {}),
|
|
2510
|
+
// Clearing also nulls the legacy camelCase key the worker still falls back to.
|
|
2511
|
+
...(firstComment === null ? { first_comment: null, firstComment: null } : {}),
|
|
2512
|
+
...(typeof firstComment === "string" ? { first_comment: firstComment } : {}),
|
|
2513
|
+
};
|
|
2514
|
+
}
|
|
2476
2515
|
if (options.confirmNotPublished)
|
|
2477
2516
|
body.confirm_not_published = true;
|
|
2478
2517
|
return body;
|
|
@@ -3826,9 +3865,10 @@ export function createProgram() {
|
|
|
3826
3865
|
}));
|
|
3827
3866
|
program
|
|
3828
3867
|
.command("status")
|
|
3829
|
-
.description("Compare the local Oxygen CLI version against the active profile's deployed Oxygen API. "
|
|
3868
|
+
.description("Compare the local Oxygen CLI version against the active profile's deployed Oxygen API. Read-only, 0 credits. "
|
|
3830
3869
|
+ "`compatible` is the field that matters: false means commands are refused until the CLI updates. "
|
|
3831
|
-
+ "`in_sync` and `skew`
|
|
3870
|
+
+ "`in_sync` and `skew` compare reported versions. A CLI may be ahead during a rollout; "
|
|
3871
|
+
+ "if the difference persists, share the status SHA with support.")
|
|
3832
3872
|
.option("--json", "Print a JSON envelope.")
|
|
3833
3873
|
.action(async (options) => {
|
|
3834
3874
|
await handleAsyncAction("status", options, async () => {
|
|
@@ -4364,7 +4404,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
4364
4404
|
.command("db")
|
|
4365
4405
|
.description("Tenant database commands.")
|
|
4366
4406
|
.addCommand(new Command("status")
|
|
4367
|
-
.description("
|
|
4407
|
+
.description("Read-only, 0 credits: show the current database host and status, including historical Neon IDs, recorded region labels, and plan-included hosting; staff can pass global --org.")
|
|
4368
4408
|
.option("--json", "Print a JSON envelope.")
|
|
4369
4409
|
.action(async (options) => {
|
|
4370
4410
|
await handleAsyncAction("db status", options, () => requestOxygen("/api/cli/db/status"));
|
|
@@ -4499,7 +4539,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
4499
4539
|
});
|
|
4500
4540
|
}))
|
|
4501
4541
|
.addCommand(new Command("cost-policy")
|
|
4502
|
-
.description("Show the
|
|
4542
|
+
.description("Read-only, 0 credits. Show the retired Neon cost-policy status. Its managed field describes the policy, not database hosting; PostgreSQL has no per-workspace sleep control.")
|
|
4503
4543
|
.option("--json", "Print a JSON envelope.")
|
|
4504
4544
|
.action(async (options) => {
|
|
4505
4545
|
await handleAsyncAction("db cost-policy", options, () => requestOxygen("/api/cli/db/cost-policy"));
|
|
@@ -4714,7 +4754,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
4714
4754
|
}))
|
|
4715
4755
|
.addCommand(new Command("create")
|
|
4716
4756
|
.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.")
|
|
4717
|
-
.requiredOption("--publish-at <iso>", "
|
|
4757
|
+
.requiredOption("--publish-at <iso>", "When the post publishes, e.g. 2026-10-06T09:00:00+02:00. With Z or an offset it is that exact instant; without one (2026-10-06T09:00) it is wall-clock time in --timezone.")
|
|
4718
4758
|
.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.")
|
|
4719
4759
|
.option("--channel <channel>", "Publishing channel override. Defaults to provider.")
|
|
4720
4760
|
.option("--sender <sender_account_id>", "LinkedIn sender account id. Required for LinkedIn before the worker can publish.")
|
|
@@ -4723,8 +4763,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
4723
4763
|
.option("--text <text>", "Post text. On LinkedIn, tag a person with @<public-identifier> and a company page with @<handle> from its linkedin.com/company/<handle> URL.")
|
|
4724
4764
|
.option("--text-file <path>", "Read post text from a local file.")
|
|
4725
4765
|
.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. X options have their own flags (--thread-post, --poll-option, --reply-settings, --alt-text, --photo-tag); raw X API keys can still ride in composio.arguments.")
|
|
4766
|
+
.option("--first-comment <text>", "Comment OXYGEN posts right after the post, e.g. the link (LinkedIn, X, Instagram, YouTube). Wins over first_comment in --content-json.")
|
|
4726
4767
|
.option("--composio-action <slug>", "Override the Composio action slug for this scheduled post.")
|
|
4727
|
-
.option("--timezone <tz>", "
|
|
4768
|
+
.option("--timezone <tz>", "IANA zone (Europe/Berlin) the post is shown in and a --publish-at without an offset is read in. Defaults to the LinkedIn sender's own timezone when --sender is set, else UTC.")
|
|
4728
4769
|
.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.")
|
|
4729
4770
|
.option("--draft", "Create as a draft instead of scheduled.")
|
|
4730
4771
|
.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.")
|
|
@@ -4746,14 +4787,14 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
4746
4787
|
}));
|
|
4747
4788
|
}))
|
|
4748
4789
|
.addCommand(new Command("get")
|
|
4749
|
-
.description("Get one scheduled post with resolved media previews, its LinkedIn video cover, and attempt history. For an X post, publish_plan lists every post that will be sent, in order, with media, alt text, poll and reply settings (free); for a LinkedIn post that tags a company page, it shows the text sent, each company tag as its page id, once `publishing posts approve` has resolved the tags (before approval it is absent). Open web_url to preview the post as it will look on LinkedIn or X.")
|
|
4790
|
+
.description("Get one scheduled post with the sender account it publishes from (id, name, profile URL), resolved media previews, its LinkedIn video cover, and attempt history. For an X post, publish_plan lists every post that will be sent, in order, with media, alt text, poll and reply settings (free); for a LinkedIn post that tags a company page, it shows the text sent, each company tag as its page id, once `publishing posts approve` has resolved the tags (before approval it is absent). Open web_url to preview the post as it will look on LinkedIn or X.")
|
|
4750
4791
|
.argument("<post_id>", "Scheduled post id.")
|
|
4751
4792
|
.option("--json", "Print a JSON envelope.")
|
|
4752
4793
|
.action(async (postId, options) => {
|
|
4753
4794
|
await handleAsyncAction("publishing posts get", options, () => requestOxygen(`/api/cli/publishing/posts/${encodeURIComponent(postId)}`));
|
|
4754
4795
|
}))
|
|
4755
4796
|
.addCommand(new Command("update")
|
|
4756
|
-
.description("Update an editable draft, scheduled, or failed post.")
|
|
4797
|
+
.description("Update an editable draft, scheduled, or failed post. Changing its text or content, the first comment included, revokes approval on an approved post: approve it again.")
|
|
4757
4798
|
.argument("<post_id>", "Scheduled post id.")
|
|
4758
4799
|
.option("--provider <provider>", "Publishing provider: linkedin, x, instagram, tiktok, facebook, or youtube.")
|
|
4759
4800
|
.option("--channel <channel>", "Publishing channel override.")
|
|
@@ -4763,9 +4804,11 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
4763
4804
|
.option("--text <text>", "Post text. On LinkedIn, tag a person with @<public-identifier> and a company page with @<handle> from its linkedin.com/company/<handle> URL.")
|
|
4764
4805
|
.option("--text-file <path>", "Read post text from a local file.")
|
|
4765
4806
|
.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. This replaces the existing content object; use --content-merge-json or the X flags to change single keys.")
|
|
4807
|
+
.option("--first-comment <text>", "Set the comment OXYGEN posts right after the post, keeping the rest of its content. Revokes approval on an approved post.")
|
|
4808
|
+
.option("--clear-first-comment", "Remove the first comment. Revokes approval on an approved post.")
|
|
4766
4809
|
.option("--composio-action <slug>", "Override the Composio action slug for this scheduled post.")
|
|
4767
|
-
.option("--publish-at <iso>", "
|
|
4768
|
-
.option("--timezone <tz>", "
|
|
4810
|
+
.option("--publish-at <iso>", "When the post publishes. With Z or an offset it is that exact instant; without one it is wall-clock time in --timezone, else the post's own timezone.")
|
|
4811
|
+
.option("--timezone <tz>", "IANA zone the post is shown in and a --publish-at without an offset is read in. Unchanged when omitted.")
|
|
4769
4812
|
.option("--status <status>", "draft or scheduled.")
|
|
4770
4813
|
.option("--thread-post <text>", "X: text of the next post in a thread (posts 2, 3, ... in order). Repeatable.", collectRepeatable, [])
|
|
4771
4814
|
.option("--thread-media <position=media_id>", "X: attach an uploaded media id to thread post N (2 or later), e.g. 2=<media_id>. Repeatable.", collectRepeatable, [])
|
|
@@ -4950,11 +4993,11 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
4950
4993
|
.description("Accept a draft variant into the publish queue. The post is created needs-approval — approve it separately before it can publish.")
|
|
4951
4994
|
.argument("<draft_id>", "Post draft id.")
|
|
4952
4995
|
.option("--variant <i>", "Which variant to accept (0-based). Defaults to 0.")
|
|
4953
|
-
.option("--publish-at <iso>", "ISO date-time to schedule the post for. Defaults to now (still needs approval).")
|
|
4996
|
+
.option("--publish-at <iso>", "ISO date-time to schedule the post for; without an offset it is wall-clock time in --timezone. Defaults to now (still needs approval).")
|
|
4954
4997
|
.option("--channel <channel>", "Channel override. Defaults to the draft's first target channel.")
|
|
4955
4998
|
.option("--sender <sender_account_id>", "LinkedIn sender account id.")
|
|
4956
4999
|
.option("--provider-connection <connection_id>", "Oxygen integration connection id for Composio-backed providers.")
|
|
4957
|
-
.option("--timezone <tz>", "
|
|
5000
|
+
.option("--timezone <tz>", "IANA zone the post is shown in and a --publish-at without an offset is read in. Defaults to UTC.")
|
|
4958
5001
|
.option("--json", "Print a JSON envelope.")
|
|
4959
5002
|
.action(async (draftId, options) => {
|
|
4960
5003
|
await handleAsyncAction("publishing drafts accept", options, () => requestOxygen(`/api/cli/publishing/drafts/${encodeURIComponent(draftId)}/accept`, {
|
|
@@ -5223,7 +5266,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
5223
5266
|
.addHelpText("after", "\nExamples (stored snapshots, 0 credits, no provider call):\n oxygen publishing analytics summary --channel linkedin --range 30d --sort impressions --json\n oxygen publishing analytics timeseries --channel linkedin --range 30d --tz Europe/Berlin --json\n\nTotals, leader and trends combine every posting account; `accounts` lists them and `--account <key>` counts only the ones you name. `top_posts[].account` names each post's author.\nWhy a metric is null: every null carries a reason in `null_reasons`:\n not_exposed_by_provider the rail OXYGEN currently reads through never returns it (`capabilities.metrics.<metric>.reason` names the limit)\n not_synced_yet no background snapshot has landed yet\n not_returned_by_provider snapshots exist but the provider left it out (LinkedIn shows impressions only to the post's author)\nSee also: the oxygen-linkedin-marketing product skill (`oxygen skills install`) for reading LinkedIn performance.\n"))
|
|
5224
5267
|
.addCommand(new Command("import")
|
|
5225
5268
|
.description("Bulk-load scheduled posts from a .csv or .json file. Dry-run by DEFAULT: it parses, lints, and reports invalid rows while writing nothing. Pass --approved to actually write. Imported posts land as drafts that still need approval — import never approves and never publishes. A row whose idempotency_key was already used is skipped, so re-importing the same file cannot duplicate the queue.")
|
|
5226
|
-
.requiredOption("--file <path>", "Path to a .csv (header row) or .json file ([rows] or { rows: [...] }).")
|
|
5269
|
+
.requiredOption("--file <path>", "Path to a .csv (header row) or .json file ([rows] or { rows: [...] }). Columns: content_text, publish_at, and optionally sender_account_id, provider, timezone, title, first_comment, tags, idempotency_key. A publish_at without Z or an offset is wall-clock time in the row's timezone, else its sender's, else UTC.")
|
|
5227
5270
|
.option("--dry-run", "Parse, lint, and report without writing. Default.")
|
|
5228
5271
|
.option("--approved", "Write the parsed rows into the queue as needs-approval drafts.")
|
|
5229
5272
|
.option("--tags <tags>", "Comma-separated workspace tags for every imported post.")
|
|
@@ -5392,9 +5435,10 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
5392
5435
|
}))
|
|
5393
5436
|
.addCommand(new Command("list")
|
|
5394
5437
|
.description("List the dashboards saved in this workspace.")
|
|
5438
|
+
.option("--tag <tag>", "Only dashboards carrying this workspace tag.")
|
|
5395
5439
|
.option("--json", "Print a JSON envelope.")
|
|
5396
5440
|
.action(async (options) => {
|
|
5397
|
-
await handleAsyncAction("dashboards list", options, () => requestOxygen(
|
|
5441
|
+
await handleAsyncAction("dashboards list", options, () => requestOxygen(`/api/cli/dashboards${options.tag ? `?tag=${encodeURIComponent(options.tag)}` : ""}`));
|
|
5398
5442
|
}))
|
|
5399
5443
|
.addCommand(new Command("create")
|
|
5400
5444
|
.description("Create a saved dashboard. It starts with one empty tab; add widgets to it with `dashboards widgets add`.")
|
|
@@ -6402,7 +6446,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
6402
6446
|
})));
|
|
6403
6447
|
program
|
|
6404
6448
|
.command("signals")
|
|
6405
|
-
.description("The Signals primitive:
|
|
6449
|
+
.description("The Signals primitive: record intent events, read the event feed and type registry, and rank leads with signals leads-today. Tag a registered type with `oxygen tags attach signal_type <type> --tags <labels>`.")
|
|
6406
6450
|
.addCommand(new Command("list")
|
|
6407
6451
|
.description("List the raw GTM signal-event feed newest-first (website_visit / profile_view / post_reaction / new_follower), each resolved onto the person it attached to. Read-only.")
|
|
6408
6452
|
.option("--types <types>", "Comma-separated signal types to include. Defaults to all four.")
|
|
@@ -6413,10 +6457,11 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
6413
6457
|
await handleAsyncAction("signals list", options, () => requestOxygen(buildSignalsListPath(options)));
|
|
6414
6458
|
}))
|
|
6415
6459
|
.addCommand(new Command("registry")
|
|
6416
|
-
.description("Show
|
|
6460
|
+
.description("Show registered signal types with intent weight, capture status, and workspace tags. Tag a type with `oxygen tags attach signal_type <type> --tags <labels>`. Read-only.")
|
|
6461
|
+
.option("--tag <tag>", "Only signal types carrying this workspace tag; use tags attach to set it.")
|
|
6417
6462
|
.option("--json", "Print a JSON envelope.")
|
|
6418
6463
|
.action(async (options) => {
|
|
6419
|
-
await handleAsyncAction("signals registry", options, () => requestOxygen(
|
|
6464
|
+
await handleAsyncAction("signals registry", options, () => requestOxygen(`/api/cli/signals/registry${options.tag ? `?tag=${encodeURIComponent(options.tag)}` : ""}`));
|
|
6420
6465
|
}))
|
|
6421
6466
|
.addCommand(new Command("record")
|
|
6422
6467
|
.description("Record one inbound GTM signal (website_visit, profile_view, post_engager, new_follower). Internal write — free, no approval. Idempotent on --external-event-id.")
|
|
@@ -8537,11 +8582,11 @@ Examples:
|
|
|
8537
8582
|
}))
|
|
8538
8583
|
.addCommand(knowledgeBootstrapCommand)
|
|
8539
8584
|
.addCommand(new Command("synthesize")
|
|
8540
|
-
.description("Synthesize knowledge from workspace evidence (one low-cost AI call): campaign learnings from auto-filed outreach outcomes, or a voice guide from sent messages.
|
|
8541
|
-
.option("--kind <kind>", "What to synthesize: campaign_learnings (default) or voice.", "campaign_learnings")
|
|
8585
|
+
.description("Synthesize knowledge from workspace evidence (one low-cost AI call, priced as one low-reasoning AI column cell): campaign learnings from auto-filed outreach outcomes, or your writing voice (a voice guide) learned from your sent LinkedIn and WhatsApp messages. Every run spends credits, the preview included, unless fewer than 3 sent messages exist (insufficient_source_messages, nothing charged); without --approved nothing is saved. To keep the guide a preview showed, pass its body back with --approved --body (no new AI call); --approved alone synthesizes and charges again. A pinned voice guide is the canonical voice at once and grounds message, reply, sequence and post drafts and AI columns.")
|
|
8586
|
+
.option("--kind <kind>", "What to synthesize: campaign_learnings (default) or voice (your writing voice from sent LinkedIn and WhatsApp messages).", "campaign_learnings")
|
|
8542
8587
|
.option("--sequence <sequence_id>", "Scope campaign learnings to one sequence's learnings page. Omit for the org-wide [[outreach-learnings]] page.")
|
|
8543
8588
|
.option("--approved", "Write the synthesized result (learnings page revision / pinned voice guide) instead of previewing.")
|
|
8544
|
-
.option("--body <markdown>", "With --approved: save this
|
|
8589
|
+
.option("--body <markdown>", "With --approved: save this body verbatim (the guide a preview showed, or your edit) with no new AI call.")
|
|
8545
8590
|
.option("--title <title>", "Optional title for a newly created page/asset.")
|
|
8546
8591
|
.option("--limit <n>", "Voice only: how many recent sent messages to learn from (max 200).")
|
|
8547
8592
|
.option("--json", "Print a JSON envelope.")
|
|
@@ -8624,9 +8669,66 @@ Examples:
|
|
|
8624
8669
|
.action(async (options) => {
|
|
8625
8670
|
await handleAsyncAction("knowledge purge", options, () => runKnowledgeMirrorPurge());
|
|
8626
8671
|
}));
|
|
8672
|
+
program
|
|
8673
|
+
.command("ads")
|
|
8674
|
+
.description("Saved Ads intelligence searches. Saving or tagging a query costs nothing; run its platform and query through the matching adyntel.* tool with the normal preview and credit gate.")
|
|
8675
|
+
.addCommand(new Command("searches")
|
|
8676
|
+
.description("Save reusable Ads queries for 0 credits. Saving never runs a provider search. Example: oxygen ads searches save --name 'Competitor ads' --platform facebook_ads --company-domain acme.com --tags competitor-watch.")
|
|
8677
|
+
.addCommand(new Command("list")
|
|
8678
|
+
.description("List saved Ads search definitions, optionally filtered by tag.")
|
|
8679
|
+
.option("--tag <label>", "Only saved searches carrying this tag.")
|
|
8680
|
+
.option("--json", "Print a JSON envelope.")
|
|
8681
|
+
.action(async (options) => {
|
|
8682
|
+
await handleAsyncAction("ads searches list", options, () => requestOxygen(`/api/cli/ads/searches${options.tag ? `?tag=${encodeURIComponent(options.tag)}` : ""}`));
|
|
8683
|
+
}))
|
|
8684
|
+
.addCommand(new Command("get")
|
|
8685
|
+
.description("Read one saved Ads search definition without running it.")
|
|
8686
|
+
.argument("<id>")
|
|
8687
|
+
.option("--json", "Print a JSON envelope.")
|
|
8688
|
+
.action(async (id, options) => {
|
|
8689
|
+
await handleAsyncAction("ads searches get", options, () => requestOxygen(`/api/cli/ads/searches/${encodeURIComponent(id)}`));
|
|
8690
|
+
}))
|
|
8691
|
+
.addCommand(new Command("save")
|
|
8692
|
+
.alias("create")
|
|
8693
|
+
.description("Save a named Ads search query for 0 credits; no provider call.")
|
|
8694
|
+
.requiredOption("--name <name>", "Name for the saved search.")
|
|
8695
|
+
.requiredOption("--platform <platform>", "Domain: facebook_ads, google_ads, linkedin_ads (use --company-domain). Keyword: facebook_ad_search, linkedin_keyword_search, tiktok_search (use --keyword).")
|
|
8696
|
+
.option("--company-domain <domain>", "Required for facebook_ads, google_ads, and linkedin_ads; e.g. acme.com.")
|
|
8697
|
+
.option("--keyword <keyword>", "Required for facebook_ad_search, linkedin_keyword_search, and tiktok_search.")
|
|
8698
|
+
.option("--country-code <code>", "Optional two-letter country code, e.g. US.")
|
|
8699
|
+
.option("--tags <labels>", "Comma-separated workspace tags.")
|
|
8700
|
+
.option("--json", "Print a JSON envelope.")
|
|
8701
|
+
.action(async (options) => {
|
|
8702
|
+
await handleAsyncAction("ads searches save", options, () => requestOxygen("/api/cli/ads/searches", { method: "POST", body: {
|
|
8703
|
+
name: options.name, platform: options.platform, company_domain: options.companyDomain,
|
|
8704
|
+
keyword: options.keyword, country_code: options.countryCode, tags: splitCommaList(options.tags),
|
|
8705
|
+
} }));
|
|
8706
|
+
}))
|
|
8707
|
+
.addCommand(new Command("update")
|
|
8708
|
+
.description("Replace a saved search's name or query; keep its tags. No provider call.")
|
|
8709
|
+
.argument("<id>")
|
|
8710
|
+
.requiredOption("--name <name>", "Name for the saved search.")
|
|
8711
|
+
.requiredOption("--platform <platform>", "Domain: facebook_ads, google_ads, linkedin_ads (use --company-domain). Keyword: facebook_ad_search, linkedin_keyword_search, tiktok_search (use --keyword).")
|
|
8712
|
+
.option("--company-domain <domain>", "Required for facebook_ads, google_ads, and linkedin_ads; e.g. acme.com.")
|
|
8713
|
+
.option("--keyword <keyword>", "Required for facebook_ad_search, linkedin_keyword_search, and tiktok_search.")
|
|
8714
|
+
.option("--country-code <code>", "Optional two-letter country code, e.g. US.")
|
|
8715
|
+
.option("--json", "Print a JSON envelope.")
|
|
8716
|
+
.action(async (id, options) => {
|
|
8717
|
+
await handleAsyncAction("ads searches update", options, () => requestOxygen(`/api/cli/ads/searches/${encodeURIComponent(id)}`, { method: "PATCH", body: {
|
|
8718
|
+
name: options.name, platform: options.platform, company_domain: options.companyDomain,
|
|
8719
|
+
keyword: options.keyword, country_code: options.countryCode,
|
|
8720
|
+
} }));
|
|
8721
|
+
}))
|
|
8722
|
+
.addCommand(new Command("delete")
|
|
8723
|
+
.description("Archive a saved search; hide it from lists and tag footprints.")
|
|
8724
|
+
.argument("<id>")
|
|
8725
|
+
.option("--json", "Print a JSON envelope.")
|
|
8726
|
+
.action(async (id, options) => {
|
|
8727
|
+
await handleAsyncAction("ads searches delete", options, () => requestOxygen(`/api/cli/ads/searches/${encodeURIComponent(id)}`, { method: "DELETE" }));
|
|
8728
|
+
})));
|
|
8627
8729
|
program
|
|
8628
8730
|
.command("tags")
|
|
8629
|
-
.description(`Workspace tags: one vocabulary across ${TAG_KINDS_PROSE}. Tags normalize to lowercase (trimmed, deduped, max 50 per item; any characters — nothing is slugified, so "Q3 Outbound" becomes "q3 outbound"). ATTACH
|
|
8731
|
+
.description(`Workspace tags: one vocabulary across ${TAG_KINDS_PROSE}. Tags normalize to lowercase (trimmed, deduped, max 50 per item; any characters — nothing is slugified, so "Q3 Outbound" becomes "q3 outbound"). ATTACH with a carrier's own tag command (e.g. \`oxygen inbox tag\`, \`oxygen tables tag\`) or use \`oxygen tags attach <kind> <ref> --tags <labels>\` for Signal types, saved Dashboards, runtime Agents, and saved Ads searches. Then \`tags get <tag>\` returns every carrier with deep-links. CURATE the vocabulary here: \`tags create\` declares a tag (with a description, color, and pin) before anything carries it, \`tags update\` re-annotates one, and \`tags rename\`/\`merge\`/\`delete\` reshape it everywhere at once. Tags are free, inert metadata: attaching or removing one costs 0 credits, makes no provider call, and changes no sending, routing, or warm-up behaviour.`)
|
|
8630
8732
|
.addCommand(new Command("list")
|
|
8631
8733
|
.description("List every workspace tag with per-primitive counts, most-used first.")
|
|
8632
8734
|
.option("--json", "Print a JSON envelope.")
|
|
@@ -8634,7 +8736,7 @@ Examples:
|
|
|
8634
8736
|
await handleAsyncAction("tags list", options, () => requestOxygen("/api/cli/tags"));
|
|
8635
8737
|
}))
|
|
8636
8738
|
.addCommand(new Command("get")
|
|
8637
|
-
.description(`Everything carrying one tag — ${TAG_KINDS_PROSE} — each with its deep-link.`)
|
|
8739
|
+
.description(`Everything carrying one tag — ${TAG_KINDS_PROSE} — each with its deep-link. In the result, ad_search_collection means a saved Ads search.`)
|
|
8638
8740
|
.argument("<tag>", "The tag (case-insensitive).")
|
|
8639
8741
|
.option("--json", "Print a JSON envelope.")
|
|
8640
8742
|
.action(async (tag, options) => {
|
|
@@ -8710,6 +8812,24 @@ Examples:
|
|
|
8710
8812
|
method: "POST",
|
|
8711
8813
|
body: { action: "delete", tags, ...(options.apply ? { apply: true } : {}) },
|
|
8712
8814
|
}));
|
|
8815
|
+
}))
|
|
8816
|
+
.addCommand(new Command("attach")
|
|
8817
|
+
.description("Replace tags on a Signal type, saved Dashboard, runtime Agent, or saved Ads search. [] clears the item; no provider call is made.")
|
|
8818
|
+
.argument("<kind>", "signal_type, dashboard, agent, or ad_search_collection")
|
|
8819
|
+
.argument("<ref>", "Signal type key, dashboard id/slug, Agent slug, or saved Ads search id")
|
|
8820
|
+
.requiredOption("--tags <labels>", "Comma-separated complete tag list; an empty string clears it.")
|
|
8821
|
+
.option("--json", "Print a JSON envelope.")
|
|
8822
|
+
.action(async (kind, ref, options) => {
|
|
8823
|
+
const body = { tags: splitCommaList(options.tags) };
|
|
8824
|
+
await handleAsyncAction("tags attach", options, () => {
|
|
8825
|
+
switch (kind) {
|
|
8826
|
+
case "signal_type": return requestOxygen(`/api/cli/signals/registry/${encodeURIComponent(ref)}/tags`, { method: "POST", body });
|
|
8827
|
+
case "dashboard": return requestOxygen(`/api/cli/dashboards/${encodeURIComponent(ref)}/tags`, { method: "POST", body });
|
|
8828
|
+
case "agent": return requestOxygen(`/api/cli/agent/${encodeURIComponent(ref)}/tags`, { method: "POST", body });
|
|
8829
|
+
case "ad_search_collection": return requestOxygen(`/api/cli/ads/searches/${encodeURIComponent(ref)}/tags`, { method: "POST", body });
|
|
8830
|
+
default: throw new Error("kind must be signal_type, dashboard, agent, or ad_search_collection");
|
|
8831
|
+
}
|
|
8832
|
+
});
|
|
8713
8833
|
}));
|
|
8714
8834
|
program
|
|
8715
8835
|
.command("comments")
|
|
@@ -9391,17 +9511,18 @@ Examples:
|
|
|
9391
9511
|
.command("columns")
|
|
9392
9512
|
.description("Workspace table column commands. Price first: `tools search --for-table <table>` lists every enrichment this table's columns support with its credit cost per row (0 credits). Ready-made templates (pricing pages, job posts, event pages, 10-Ks, website facts, market questions, text extraction, plus free 0-credit cleanups — company names, job titles, email type, text to list; person questions over an enriched LinkedIn profile and person appearances; company research questions — founders, parent company, funding, cloud provider, offers demos, industry, NAICS, HQ and footprint, LinkedIn/Crunchbase/careers page lookups): `columns catalog`, then `columns add <table> --prompt-key <key> --input <name>=<column>`. Column choice and provider routing guide: the oxygen-gtm skill's enriching-and-researching.md (`oxygen skills install`). With --json parse stdout only: the envelope is stdout, and the spend and cost notes (`estimated N credits for a live run`) are stderr, so merging the streams breaks the JSON.")
|
|
9393
9513
|
.addCommand(new Command("add")
|
|
9394
|
-
.description("Add a nullable column to a workspace table. Writes the definition only — this never runs the column and never spends credits; use `columns run` for that, with --dry-run first to see the cost. For a page, posting or filing the row already links to, or a company fact Oxygen already knows how to research (founders, parent company, funding, cloud provider, offers demos, industry, NAICS, HQ, a LinkedIn or Crunchbase page), pick a template from `columns catalog` (--prompt-key) instead of writing a prompt; or read a URL column with --kind research --research-url. The guide is the oxygen-gtm skill's enriching-and-researching.md. A formula column needs no run at all: it evaluates on read from its current inputs, so `tables query` shows its values immediately.")
|
|
9514
|
+
.description("Add a nullable column to a workspace table. Writes the definition only — this never runs the column and never spends credits; use `columns run` for that, with --dry-run first to see the cost. For a page, posting or filing the row already links to, or a company fact Oxygen already knows how to research (founders, parent company, funding, cloud provider, offers demos, industry, NAICS, HQ, a LinkedIn or Crunchbase page), pick a template from `columns catalog` (--prompt-key) instead of writing a prompt; or read a URL column with --kind research --research-url. The guide is the oxygen-gtm skill's enriching-and-researching.md. For ICP fit, use Classify Score: `columns add <table> --label 'ICP fit' --score 'low,medium,high' --prompt 'Assess {{company_name}}: {{description}}, {{employees}} employees' --grounding workspace` (0.075 credits/row). A formula column needs no run at all: it evaluates on read from its current inputs, so `tables query` shows its values immediately.")
|
|
9395
9515
|
.argument("<table>", "Table id or slug.")
|
|
9396
|
-
.option("--preset <preset>", "Add a pre-built column bundle instead of one column, which is what --prompt-key adds: a preset writes SEVERAL columns at once and needs no --input on a table whose identity column it can find, while --prompt-key adds one template column and binds each input you name. Presets: `person_enrich` (one LinkedIn profile lookup — Blitz first, the LinkedIn Scraper only when Blitz has no match — then first/last/full name, current company and its LinkedIn URL, job title, start date, education, school, headline, bio, location and followers as free stored fields that fill when the lookup runs; the person templates in `columns catalog --category people` all read its payload column), `company_enrich` (one company lookup from a domain or LinkedIn URL — Blitz first, the LinkedIn Scraper only when Blitz has no match, about 1 credit a company — keeping the whole profile in its JSON column plus name, domain, LinkedIn URL, headcount, industry, description, founded year and HQ country as free stored fields; no field choices; binds to the person preset's current_company_linkedin_url automatically), `company_tech_stack` (the technologies a company runs, from its website and hiring), or `domain_check` (1 credit/row for a live/redirect/parked/not_found/dead verdict on every domain, cached 30 days, plus a free label column to filter on). For a single cleaned or classified column, use a template from `columns catalog` instead — that is one column, not a bundle. Name the input column with --input: `url` for person_enrich, `domain` and/or `linkedin_url` for the company presets. Any column works whatever it is called; without --input Oxygen guesses from column names. Creating the columns is free; run them afterwards, --dry-run first.")
|
|
9516
|
+
.option("--preset <preset>", "Add a pre-built column bundle instead of one column, which is what --prompt-key adds: a preset writes SEVERAL columns at once and needs no --input on a table whose identity column it can find, while --prompt-key adds one template column and binds each input you name. Presets: `person_enrich` (one LinkedIn profile lookup — Blitz first, the LinkedIn Scraper only when Blitz has no match — then first/last/full name, current company and its LinkedIn URL, job title, start date, education, school, headline, bio, location and followers as free stored fields that fill when the lookup runs; the person templates in `columns catalog --category people` all read its payload column), `company_enrich` (one company lookup from a domain or LinkedIn URL — Blitz first, the LinkedIn Scraper only when Blitz has no match, about 1 credit a company — keeping the whole profile in its JSON column plus name, domain, LinkedIn URL, headcount, industry, description, founded year and HQ country as free stored fields; no field choices; binds to the person preset's current_company_linkedin_url automatically), `company_tech_stack` (the technologies a company runs, from its website and hiring), `company_funding` (total raised, latest round, number of rounds and investors), `company_revenue` (an annual revenue estimate), `company_hiring` (the roles a company is hiring for, with a count) — each of these four is one paid lookup column that fills its own columns, priced per row in `tools search --for-table` — or `domain_check` (1 credit/row for a live/redirect/parked/not_found/dead verdict on every domain, cached 30 days, plus a free label column to filter on). For a single cleaned or classified column, use a template from `columns catalog` instead — that is one column, not a bundle. Name the input column with --input: `url` for person_enrich, `domain` and/or `linkedin_url` for the company presets. Any column works whatever it is called; without --input Oxygen guesses from column names. Creating the columns is free; run them afterwards, --dry-run first.")
|
|
9397
9517
|
.option("--input <slot=column...>", "Bind a preset or template input to an exact column, e.g. --input url=pricing_url with --prompt-key, or --input domain=website --input linkedin_url=company_li with --preset. Repeatable. For a preset it is only needed when the automatic match is wrong or missing; for a template it names the column each declared input reads.", collectRepeatable, [])
|
|
9398
9518
|
// No preset takes a field list any more (founder, 2026-09-24). Hidden,
|
|
9399
9519
|
// not removed: an old script passing them gets the server's refusal,
|
|
9400
|
-
// which names
|
|
9520
|
+
// which names the funding, revenue, hiring and tech-stack presets,
|
|
9401
9521
|
// instead of an unknown-option error that explains nothing.
|
|
9402
9522
|
.addOption(new Option("--preset-fields <fields>", "Retired: presets take no field list.").hideHelp())
|
|
9403
9523
|
.addOption(new Option("--check-technologies <names>", "Retired: presets take no field list.").hideHelp())
|
|
9404
|
-
.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,
|
|
9524
|
+
.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. For a personal (non-work) email add --include-personal-email to work_email: one column finds both (personal_email alone still works for existing scripts). Sets kind=enrichment and jsonb; label and key default from the capability. Inputs are mapped from this table's columns; the response names any it could not find and the command that maps it. Preview cost with `enrich-column preview --capability <same>` and run later with `enrich-column run --approved --max-credits <n>`.")
|
|
9525
|
+
.option("--include-personal-email", "With --capability work_email: also find the person's personal (non-work) email from their LinkedIn URL, billed only on a hit and graded before it is written, into the same cell's personal_email.")
|
|
9405
9526
|
.option("--label <label>", "Display label for the new column. Required unless --prompt-key or --capability supplies a default title.")
|
|
9406
9527
|
.option("--key <key>", "Optional stable column key. Defaults to a normalized label.")
|
|
9407
9528
|
.option("--data-type <type>", "Column data type: text, numeric, boolean, jsonb, or timestamptz.")
|
|
@@ -9429,16 +9550,11 @@ Examples:
|
|
|
9429
9550
|
.option("--structured-output", "Undo --no-structured-output: let the prompt's named sections shape the answer again.")
|
|
9430
9551
|
.option("--output-schema-json <json>", "AI column output JSON schema as inline JSON.")
|
|
9431
9552
|
.option("--output-schema-file <path>", "AI column output JSON schema read from a file path.")
|
|
9432
|
-
|
|
9433
|
-
|
|
9434
|
-
|
|
9435
|
-
|
|
9436
|
-
|
|
9437
|
-
.addOption(new Option("--classify <labels>", "Classification column: label every row with one of these comma-separated labels (2-20), answering --prompt. Each cell holds the label, a 0-1 confidence and uncertain:true below --min-confidence (the best guess is kept). Flat ~0.075 credits/row; see `columns run --dry-run`.").hideHelp())
|
|
9438
|
-
.addOption(new Option("--yes-no <question>", "Classification column: answer this yes/no question per row (reference columns as {{column_key}}). Filter on the answer with `tables query --filter-tree-json` and a path leaf, e.g. '{\"type\":\"group\",\"conjunction\":\"and\",\"children\":[{\"type\":\"leaf\",\"columnKey\":\"<column>\",\"path\":\"answer\",\"operator\":\"is\",\"value\":true}]}'.").hideHelp())
|
|
9439
|
-
.addOption(new Option("--score <levels>", "Classification column: rate each row on these comma-separated levels, lowest first (2-10), answering --prompt.").hideHelp())
|
|
9440
|
-
.addOption(new Option("--min-confidence <0-1>", "Classification columns: below this the answer is marked uncertain. Default 0.85 (0.7 for --yes-no).").hideHelp())
|
|
9441
|
-
.addOption(new Option("--grounding <mode>", "Classification columns: none (default) judges only the row and your question; workspace also sends your workspace knowledge (ICP, positioning), which can pull answers toward your ICP.").hideHelp())
|
|
9553
|
+
.addOption(new Option("--classify <labels>", "Classification column: label every row with one of these comma-separated labels (2-20), answering --prompt. Each cell holds the label, a 0-1 confidence and uncertain:true below --min-confidence (the best guess is kept). Flat ~0.075 credits/row; see `columns run --dry-run`."))
|
|
9554
|
+
.addOption(new Option("--yes-no <question>", "Classification column: answer this yes/no question per row (reference columns as {{column_key}}). Filter on the answer with `tables query --filter-tree-json` and a path leaf, e.g. '{\"type\":\"group\",\"conjunction\":\"and\",\"children\":[{\"type\":\"leaf\",\"columnKey\":\"<column>\",\"path\":\"answer\",\"operator\":\"is\",\"value\":true}]}'."))
|
|
9555
|
+
.addOption(new Option("--score <levels>", "Classification column: rate each row on these comma-separated levels, lowest first (2-10), answering --prompt."))
|
|
9556
|
+
.addOption(new Option("--min-confidence <0-1>", "Classification columns: below this the answer is marked uncertain. Default 0.85 (0.7 for --yes-no)."))
|
|
9557
|
+
.addOption(new Option("--grounding <mode>", "Classification columns: none (default) judges only the row and your question; workspace also sends your workspace knowledge (ICP, positioning), which can pull answers toward your ICP."))
|
|
9442
9558
|
.option("--bind-object <slug>", "Bind column CRM object slug (companies, people, deals, ...). Sets kind=bind and resolves rows to CRM records by identity.")
|
|
9443
9559
|
.option("--bind-map <pairs>", "Bind identity mapping as identity=column pairs, e.g. domain=website,linkedin_url=li.")
|
|
9444
9560
|
.option("--bind-create", "Bind columns: assert a new CRM record for unmatched rows (onNoMatch=create; a truth write, needs --approved on run).")
|
|
@@ -9564,6 +9680,8 @@ Examples:
|
|
|
9564
9680
|
const isResearch = requestedKind === "research";
|
|
9565
9681
|
if (capability)
|
|
9566
9682
|
column.capability = capability;
|
|
9683
|
+
if (options.includePersonalEmail)
|
|
9684
|
+
column.include_personal_email = true;
|
|
9567
9685
|
// An enrichment cell is always the provider envelope, so the server
|
|
9568
9686
|
// rejects any other data type ("Enrichment columns must use jsonb
|
|
9569
9687
|
// data type."). Default it, the way --bind-object already does,
|
|
@@ -10084,7 +10202,7 @@ Examples:
|
|
|
10084
10202
|
});
|
|
10085
10203
|
}))
|
|
10086
10204
|
.addCommand(new Command("catalog")
|
|
10087
|
-
.description("List
|
|
10205
|
+
.description("List column templates and native Classify. For ICP fit, Classify Score (0.075 credits/row) appears in `data.native`, including with `--family classification`; `data.entries` lists separate prompt templates. Templates can be added with `columns add <table> --prompt-key <key>`: the extract templates (pricing-page, job-posting, event, 10-K, website-fact, market and text extractors) and the company research questions (founders, parent company, subsidiaries, funding rounds, market cap, cloud provider, is SaaS, offers demos, industry, NAICS, HQ / states / cities, competitors, LinkedIn / Crunchbase / Glassdoor / careers page lookups) and the person templates (--category people: skill set, grad school, location, job fit and current company read from the person_enrich payload for 15 credits; events, keynotes, podcasts and GitHub profile searched by name), each with the input it reads (bind it with --input <name>=<column_key>), the providers behind it, the answer fields, and the credits per row. Read-only and free. Tool-backed columns that read a page or call a provider — e.g. Website contacts (emails and phones from each row's site) — are found with `oxygen tools search <words>`, not here. Provider waterfalls and enrichment bundles are a different list with their own per-row prices: `oxygen enrichment catalog`.")
|
|
10088
10206
|
.option("--category <category>", "Only templates in one picker section: extract (page and text extraction), normalize, organize, summarize (free cleanups and classifications), people (person questions and appearances) or company (company research questions); comma-separate to combine (extract,people). The outreach prompts sit under copy, scoring, qa, research.")
|
|
10089
10207
|
.option("--family <family>", "Only one family. Extract: pricing, job_posting, events, filings, site_facts, market, portfolio, text_extraction. Cleanups: company, person, email, lists, classification. Company: web_presence, corporate_structure, funding, footprint, business_model, tech, market. People: profile, appearances.")
|
|
10090
10208
|
.option("--kind <kind>", "Only one column kind: research (reads the web or a page, answer with sources), search (a managed web search whose cell is a page URL or the result list, fixed price), ai (reads row text), or formula (free).")
|
|
@@ -10746,7 +10864,7 @@ Examples:
|
|
|
10746
10864
|
.description("Inspect missing company fields, provider routing, and credit estimates without provider calls.")
|
|
10747
10865
|
.argument("<table>", "Table id or slug.")
|
|
10748
10866
|
.option("--missing-fields <fields>", "Comma-separated fields to fill; accepts every key `oxygen find company --list-fields` prints, not just the identity set. Derived fields (description, founded_year, hq_country, funding_stage, latest_funding_round, investors, job_openings, parent_company, subsidiaries) are read from a payload the run already fetches and add no provider lane of their own.")
|
|
10749
|
-
.option("--providers <providers>", "Comma-separated provider order pool. Defaults to scraper,blitzapi,
|
|
10867
|
+
.option("--providers <providers>", "Comma-separated provider order pool. Defaults to scraper,blitzapi,apollo,peopledatalabs,theirstack,leadmagic.")
|
|
10750
10868
|
.option("--all", "Preview all rows.")
|
|
10751
10869
|
.option("--limit <n>", "Preview a limited row scope.")
|
|
10752
10870
|
.option("--row-ids <ids>", "Comma-separated row ids.")
|
|
@@ -10949,7 +11067,7 @@ Examples:
|
|
|
10949
11067
|
.description("Plan-tier limits, spend-safety defaults, per-channel sending limits, and storage capacity posture.")
|
|
10950
11068
|
.addHelpText("after", "\nRun `oxygen limits show --json` to see your current plan's API and webhook rates, Table and workspace storage headroom, and the next scheduled change. The $999 and $1,999 rows-per-Table increases remain held until capacity testing passes.\n")
|
|
10951
11069
|
.addCommand(new Command("show")
|
|
10952
|
-
.description("Compare every plan size: price, monthly credits, volume bonus and price per 100 credits (`plan_bands`). Show your plan band and its limits beside every plan size's (API rate, webhook ingress, storage, import file size, columns per auto-run, signals feed, spend defaults incl. Agent and Copilot caps, fastest workflow schedule), the per-channel sending limits every plan shares (emails per mailbox, WhatsApp, LinkedIn, calls) and its workflow Code-step and run-attempt limits, plus Tables capacity usage naming the largest Table. Each plan size's storage says whether it is in force, scheduled to change on a date, or awaiting its rows-per-Table capacity test, with the values it moves to. `spend_safety_defaults.applies_to` says what each spend default bounds: an unattended trigger delivery, a table auto-run batch, one Agent run, or the attended Copilot. `web_url` opens the same view in the web app (Settings → Plan limits), including any scheduled change. Read-only; spends no credits. Recovery: https://oxygen-agent.com/docs/safety/billing.")
|
|
11070
|
+
.description("Compare every plan size: price, monthly credits, volume bonus and price per 100 credits (`plan_bands`). Show your plan band and its limits beside every plan size's (API rate, webhook ingress, storage, import file size, columns per auto-run, signals feed, spend defaults incl. Agent and Copilot caps, fastest workflow schedule), the per-channel sending limits every plan shares (emails per mailbox, WhatsApp, LinkedIn, calls) and its workflow Code-step and run-attempt limits, plus Tables capacity usage naming the largest Table. Each plan size's storage says whether it is in force, scheduled to change on a date, or awaiting its rows-per-Table capacity test, with the values it moves to. `spend_safety_defaults.enterprise_agent_run_default` gives Enterprise the $1,999 band’s in-force Agent default with no contract override; `agent_run_approved_max_credits` is the maximum for an explicitly approved higher run. `spend_safety_defaults.applies_to` says what each spend default bounds: an unattended trigger delivery, a table auto-run batch, one Agent run, or the attended Copilot. `web_url` opens the same view in the web app (Settings → Plan limits), including any scheduled change. Read-only; spends no credits. Recovery: https://oxygen-agent.com/docs/safety/billing.")
|
|
10953
11071
|
.option("--json", "Print a JSON envelope.")
|
|
10954
11072
|
.action(async (options) => {
|
|
10955
11073
|
await handleAsyncAction("limits show", options, () => requestOxygen("/api/cli/limits"));
|
|
@@ -11202,7 +11320,6 @@ Examples:
|
|
|
11202
11320
|
.option("--rollover <n>", "Max balance credits roll over to. Defaults to the monthly credits.")
|
|
11203
11321
|
.option("--base-tier <tier>", "Base pricing tier to inherit feature flags from. Defaults to growth_250.")
|
|
11204
11322
|
.option("--byok", "Enable bring-your-own-key integrations for the plan.")
|
|
11205
|
-
.option("--agent-run-default-credits <n>", "The contract's own default Agent run ceiling in credits (at most 25,000). Omitted keeps the plan's default; re-running set-plan without it clears it.")
|
|
11206
11323
|
.option("--note <text>", "Internal note stored on the contract.")
|
|
11207
11324
|
.option("--json", "Print a JSON envelope.")
|
|
11208
11325
|
.action(async (options) => {
|
|
@@ -11221,9 +11338,6 @@ Examples:
|
|
|
11221
11338
|
: {}),
|
|
11222
11339
|
...(readOption(options.baseTier) ? { base_tier: readOption(options.baseTier) } : {}),
|
|
11223
11340
|
...(options.byok ? { byok: true } : {}),
|
|
11224
|
-
...(readOption(options.agentRunDefaultCredits)
|
|
11225
|
-
? { agent_run_default_credits: readPositiveNumber(options.agentRunDefaultCredits) }
|
|
11226
|
-
: {}),
|
|
11227
11341
|
...(readOption(options.note) ? { note: readOption(options.note) } : {}),
|
|
11228
11342
|
},
|
|
11229
11343
|
}));
|
|
@@ -12013,9 +12127,10 @@ Examples:
|
|
|
12013
12127
|
}))
|
|
12014
12128
|
.addCommand(new Command("custom")
|
|
12015
12129
|
.description("List the org's user-defined custom agents (distinct from `agent list`, which shows the built-in roster).")
|
|
12130
|
+
.option("--tag <tag>", "Only custom agents carrying this workspace tag.")
|
|
12016
12131
|
.option("--json", "Print a JSON envelope.")
|
|
12017
12132
|
.action(async (options) => {
|
|
12018
|
-
await handleAsyncAction("agent custom", options, () => requestOxygen(
|
|
12133
|
+
await handleAsyncAction("agent custom", options, () => requestOxygen(`/api/cli/agent/custom${options.tag ? `?tag=${encodeURIComponent(options.tag)}` : ""}`));
|
|
12019
12134
|
}))
|
|
12020
12135
|
.addCommand(new Command("delete")
|
|
12021
12136
|
.description("Archive a custom agent (soft delete): its triggers stop firing and it leaves the roster; versions/runs are preserved.")
|
|
@@ -12787,15 +12902,15 @@ Examples:
|
|
|
12787
12902
|
.description("Preflight an enrichment column without provider calls or credit usage. A lane on your own key (BYOK) is quoted at OXYGEN's platform fee per call, hit or miss (provider_cost_breakdown[].byok_platform_fee_credits_per_call).")
|
|
12788
12903
|
.argument("<table>", "Table id or slug.")
|
|
12789
12904
|
.option("--source-column <column>", "Source column key or id. For mobile_phone this is the LinkedIn URL column.")
|
|
12790
|
-
.option("--full-name-column <column>", "Column key or id containing the person's full name for work_email. Pair with company-domain/name for Prospeo,
|
|
12905
|
+
.option("--full-name-column <column>", "Column key or id containing the person's full name for work_email. Pair with company-domain/name for Prospeo, LeadMagic, Hunter, Dropleads, ContactOut, BetterContact, or People Data Labs.")
|
|
12791
12906
|
.option("--first-name-column <column>", "Column key or id containing the person's first name for work_email.")
|
|
12792
12907
|
.option("--last-name-column <column>", "Column key or id containing the person's last name for work_email.")
|
|
12793
|
-
.option("--linkedin-url-column <column>", "Column key or id containing the person's LinkedIn URL. Required by BlitzAPI and enough for
|
|
12908
|
+
.option("--linkedin-url-column <column>", "Column key or id containing the person's LinkedIn URL. Required by BlitzAPI and enough for Prospeo, Hunter, ContactOut, BetterContact, People Data Labs, or Crustdata on your own key.")
|
|
12794
12909
|
.option("--email-column <column>", "Column key or id containing a known work email for providers that accept email as an identity fallback.")
|
|
12795
12910
|
.option("--company-domain-column <column>", "Column key or id containing the company domain for work_email. Pair with full-name/first+last for name+company providers.")
|
|
12796
12911
|
.option("--company-name-column <column>", "Column key or id containing the company name for work_email when no domain is available.")
|
|
12797
12912
|
.option("--company-linkedin-url-column <column>", "Column key or id containing the company's LinkedIn URL for company identity fallback.")
|
|
12798
|
-
.option("--capability <capability>", "What to run: mobile_phone, work_email,
|
|
12913
|
+
.option("--capability <capability>", "What to run: mobile_phone, work_email, linkedin_url (find a missing value), or verify_email (grade an email the row already has). For a personal email add --include-personal-email to work_email (personal_email alone still works). Defaults to mobile_phone.")
|
|
12799
12914
|
.option("--target-column <column>", "Target enrichment column key. Defaults to the capability payload column.")
|
|
12800
12915
|
.option("--on-existing-manual-column <mode>", "How to handle an existing manual target: error, write_if_empty, or create_enrichment_column.")
|
|
12801
12916
|
.option("--provider-order <providers>", "Comma-separated provider order. Overrides the default cost-aware waterfall profile.")
|
|
@@ -12804,6 +12919,7 @@ Examples:
|
|
|
12804
12919
|
.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%).")
|
|
12805
12920
|
.option("--verify-phone", "Validate found phone numbers with ClearoutPhone (adds phone_line_type + phone_carrier; filter phone_line_type=mobile for mobile-only).")
|
|
12806
12921
|
.option("--allow-premium-lanes", "Opt in to managed lanes billing over 50 credits per call. Only linkedin_url has one (LeadMagic email_to_profile, 122.5cr), until the 2026-09 repricing date, when a LinkedIn URL starts billing only on a hit; it is off by default so an unattended run can't bill-shock. No effect on mobile_phone, work_email or verify_email.")
|
|
12922
|
+
.option("--include-personal-email", "work_email only: also find the personal (non-work) email from the LinkedIn URL column, billed only on a hit; the preview adds its price and the address lands in the cell's personal_email.")
|
|
12807
12923
|
.option("--phone-verification-credential-mode <mode>", "ClearoutPhone credential mode for phone verification: managed or user_api_key.")
|
|
12808
12924
|
.option("--limit <n>", "Rows to estimate. Defaults to 10.")
|
|
12809
12925
|
.option("--all", "Estimate all rows.")
|
|
@@ -12821,16 +12937,16 @@ Examples:
|
|
|
12821
12937
|
.description("Create or reuse an enrichment column and queue a background run.")
|
|
12822
12938
|
.argument("<table>", "Table id or slug.")
|
|
12823
12939
|
.option("--source-column <column>", "Source column key or id. For mobile_phone this is the LinkedIn URL column.")
|
|
12824
|
-
.option("--full-name-column <column>", "Column key or id containing the person's full name for work_email. Pair with company-domain/name for Prospeo,
|
|
12940
|
+
.option("--full-name-column <column>", "Column key or id containing the person's full name for work_email. Pair with company-domain/name for Prospeo, LeadMagic, Hunter, Dropleads, ContactOut, BetterContact, or People Data Labs.")
|
|
12825
12941
|
.option("--first-name-column <column>", "Column key or id containing the person's first name for work_email.")
|
|
12826
12942
|
.option("--last-name-column <column>", "Column key or id containing the person's last name for work_email.")
|
|
12827
|
-
.option("--linkedin-url-column <column>", "Column key or id containing the person's LinkedIn URL. Required by BlitzAPI and enough for
|
|
12943
|
+
.option("--linkedin-url-column <column>", "Column key or id containing the person's LinkedIn URL. Required by BlitzAPI and enough for Prospeo, Hunter, ContactOut, BetterContact, People Data Labs, or Crustdata on your own key.")
|
|
12828
12944
|
.option("--email-column <column>", "Column key or id containing a known work email for providers that accept email as an identity fallback.")
|
|
12829
12945
|
.option("--company-domain-column <column>", "Column key or id containing the company domain for work_email. Pair with full-name/first+last for name+company providers.")
|
|
12830
12946
|
.option("--company-name-column <column>", "Column key or id containing the company name for work_email when no domain is available.")
|
|
12831
12947
|
.option("--company-linkedin-url-column <column>", "Column key or id containing the company's LinkedIn URL for company identity fallback.")
|
|
12832
12948
|
.requiredOption("--max-credits <credits>", "Required credit ceiling for the queued run.")
|
|
12833
|
-
.option("--capability <capability>", "What to run: mobile_phone, work_email,
|
|
12949
|
+
.option("--capability <capability>", "What to run: mobile_phone, work_email, linkedin_url (find a missing value), or verify_email (grade an email the row already has). For a personal email add --include-personal-email to work_email (personal_email alone still works). Defaults to mobile_phone.")
|
|
12834
12950
|
.option("--target-column <column>", "Target enrichment column key. Defaults to the capability payload column.")
|
|
12835
12951
|
.option("--on-existing-manual-column <mode>", "How to handle an existing manual target: error, write_if_empty, or create_enrichment_column.")
|
|
12836
12952
|
.option("--provider-order <providers>", "Comma-separated provider order. Overrides the default cost-aware waterfall profile.")
|
|
@@ -12839,6 +12955,7 @@ Examples:
|
|
|
12839
12955
|
.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%).")
|
|
12840
12956
|
.option("--verify-phone", "Validate found phone numbers with ClearoutPhone (adds phone_line_type + phone_carrier; filter phone_line_type=mobile for mobile-only).")
|
|
12841
12957
|
.option("--allow-premium-lanes", "Opt in to managed lanes billing over 50 credits per call. Only linkedin_url has one (LeadMagic email_to_profile, 122.5cr), until the 2026-09 repricing date, when a LinkedIn URL starts billing only on a hit; it is off by default so an unattended run can't bill-shock. No effect on mobile_phone, work_email or verify_email.")
|
|
12958
|
+
.option("--include-personal-email", "work_email only: also find the personal (non-work) email from the LinkedIn URL column, billed only on a hit; the preview adds its price and the address lands in the cell's personal_email.")
|
|
12842
12959
|
.option("--phone-verification-credential-mode <mode>", "ClearoutPhone credential mode for phone verification: managed or user_api_key.")
|
|
12843
12960
|
.option("--limit <n>", "Rows to queue.")
|
|
12844
12961
|
.option("--all", "Queue all rows.")
|
|
@@ -13219,7 +13336,7 @@ Examples:
|
|
|
13219
13336
|
await handleAsyncAction("integrations list", options, () => requestOxygen("/api/cli/integrations/composio/list"));
|
|
13220
13337
|
}))
|
|
13221
13338
|
.addCommand(new Command("connect")
|
|
13222
|
-
.description("Connect an integration. OAuth toolkits
|
|
13339
|
+
.description("Connect an integration. For OAuth toolkits, running this command immediately creates a pending provider authorization and returns a redirect URL; account access starts only if you approve on the provider's page. This is not a price preview. On a repriced workspace, a connected X account reserves 1,000 credits ($10) monthly; inspect the current pricing schedule with `oxygen limits show --json` before connecting. API-key integrations accept --api-key. Run without credentials only for an API-key integration to get a 0-credit preview of its 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).")
|
|
13223
13340
|
.argument("<integration_id>", "Integration id, such as 'slack' or 'serpapi'.")
|
|
13224
13341
|
.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.")
|
|
13225
13342
|
.option("--credential <name=value>", "Named provider credential field. Repeat for multi-field integrations (for example, PostHog: --credential subdomain=us).", collectRepeatable, [])
|
|
@@ -13353,8 +13470,8 @@ Examples:
|
|
|
13353
13470
|
.action(async (options) => {
|
|
13354
13471
|
await handleAsyncAction("senders list", options, () => {
|
|
13355
13472
|
const params = new URLSearchParams();
|
|
13356
|
-
|
|
13357
|
-
|
|
13473
|
+
// The server counts usage unless told not to, so --no-usage must say so.
|
|
13474
|
+
params.set("include_usage", options.usage === false ? "false" : "true");
|
|
13358
13475
|
const status = readOption(options.status);
|
|
13359
13476
|
if (status)
|
|
13360
13477
|
params.set("status", status);
|
|
@@ -13372,7 +13489,7 @@ Examples:
|
|
|
13372
13489
|
await handleAsyncAction("senders checkpoints", options, () => requestOxygen("/api/cli/senders/checkpoints"));
|
|
13373
13490
|
}))
|
|
13374
13491
|
.addCommand(new Command("connect")
|
|
13375
|
-
.description("Get a Unipile hosted-auth URL to connect a new LinkedIn account (or reconnect with --reconnect). Each connected LinkedIn account has one monthly
|
|
13492
|
+
.description("Get a Unipile hosted-auth URL to connect a new LinkedIn account (or reconnect with --reconnect). This command creates a pending provider authorization; it does not preview a price. Each connected LinkedIn account has one monthly charge, never two: an existing dollar seat until it ends at renewal, or a monthly credit reservation after seats retire. On a repriced workspace that reservation is 3,400 credits ($34) monthly; a connect the balance cannot cover fails with insufficient_credits. Invites, messages and other LinkedIn actions cost 0 credits on top. Check `oxygen billing seats --json` before connecting for the current charge and seat status. A workspace linked to another organization's plan is billed at that organization. New accounts require --country (the owner's normal LinkedIn login country) and default to syncing only conversations OXYGEN starts; use --inbox-scope all to opt into the full LinkedIn inbox. Use --cookie-auth and --custom-proxy to expose those inputs inside Unipile's hosted wizard; their secrets never pass through OXYGEN. Use --count for bulk onboarding. LinkedIn restricts automated activity: every connection needs --acknowledge-risk (only a reconnect that already recorded one is exempt), and --count above 1 also needs --owner-consent (each owner connects their own account). Links are shareable and valid for 10 minutes.")
|
|
13376
13493
|
.option("--reconnect <connection_id>", "Reconnect an existing connection instead of creating a new one. Accepts a connection id.")
|
|
13377
13494
|
.option("--country <code>", "Required for new accounts: ISO 3166-1 alpha-2 code for the account owner's normal LinkedIn login country (for example DE or US).")
|
|
13378
13495
|
.option("--sales-nav", "Request Classic + Sales Navigator access during Unipile hosted authentication.")
|
|
@@ -14321,8 +14438,7 @@ Examples:
|
|
|
14321
14438
|
.action(async (options) => {
|
|
14322
14439
|
await handleAsyncAction("whatsapp accounts", options, () => {
|
|
14323
14440
|
const params = new URLSearchParams();
|
|
14324
|
-
|
|
14325
|
-
params.set("include_usage", "true");
|
|
14441
|
+
params.set("include_usage", options.usage === false ? "false" : "true");
|
|
14326
14442
|
const status = readOption(options.status);
|
|
14327
14443
|
if (status)
|
|
14328
14444
|
params.set("status", status);
|
|
@@ -14726,6 +14842,20 @@ Examples:
|
|
|
14726
14842
|
...(options.personRowId ? { person_row_id: readOption(options.personRowId) } : {}),
|
|
14727
14843
|
text: readOption(options.text), ...(options.approved ? { approved: true } : {}) },
|
|
14728
14844
|
}));
|
|
14845
|
+
}))
|
|
14846
|
+
.addCommand(new Command("forget-whatsapp-chat")
|
|
14847
|
+
.description("Permanently erase a terminal old WhatsApp chat and its messages from OXYGEN. Preview first; --approved confirms the exact chat. --sender and --before guard the scope. This does not delete from WhatsApp; for a connected account find whatsapp.chats_delete with oxygen tools search, inspect tools run --mode dry-run, then use --mode live --approved and verify the provider result.")
|
|
14848
|
+
.argument("<conversationId>", "OXYGEN conversation id from inbox list.")
|
|
14849
|
+
.requiredOption("--sender <id>", "Original disconnected WhatsApp sender account id.")
|
|
14850
|
+
.requiredOption("--before <iso>", "Delete only when the latest message predates this ISO timestamp.")
|
|
14851
|
+
.option("--approved", "Approve permanent removal from OXYGEN after reviewing the preview.")
|
|
14852
|
+
.option("--json", "Print a JSON envelope.")
|
|
14853
|
+
.action(async (conversationId, options) => {
|
|
14854
|
+
await handleAsyncAction("inbox forget-whatsapp-chat", options, () => requestOxygen(`/api/cli/inbox/${encodeURIComponent(conversationId)}/forget-whatsapp`, {
|
|
14855
|
+
method: "POST",
|
|
14856
|
+
body: { sender_account_id: readOption(options.sender), before: readOption(options.before),
|
|
14857
|
+
approved: options.approved === true },
|
|
14858
|
+
}));
|
|
14729
14859
|
}))
|
|
14730
14860
|
.addCommand(new Command("delete-message")
|
|
14731
14861
|
.description("Delete your sent LinkedIn message for everyone within 60 minutes. This cannot be undone. Omit --approved for a preview; show it before approving. Other channels and inbound messages are unsupported.")
|
|
@@ -15276,9 +15406,10 @@ Examples:
|
|
|
15276
15406
|
program.addCommand(new Command("sequences")
|
|
15277
15407
|
.description("Multichannel outreach sequences: one enrollment per lead can span LinkedIn, email, WhatsApp, human call tasks, and connected-CRM tasks. LinkedIn steps dispatch natively (rate-limited, credit-capped); email steps send natively or place/move/stop the lead in a bound Instantly campaign (BYOK); crm_task can exact-match/upsert/link CRM records and create a mapped external follow-up (HubSpot first), with replay-safe per-operation receipts. A reply always stops that lead, on every channel. To also pause the replier's colleagues, set `--company-reply-pause` on `create`/`update` (pick the reply categories); to pause or resume a company by hand or from a workflow (e.g. a meeting was booked), use `pause-company` / `resume-company` / `paused-companies`. A pause is resumable; a company do-not-contact entry (`suppressions`) is a permanent block.")
|
|
15278
15408
|
.addCommand(new Command("list")
|
|
15279
|
-
.description("List sequences with persisted launch status kept separate from current operational state. Fleet counts report marked-active versus operationally-live (active with nonterminal work), and rows explain flowing, paced, blocked, degraded, drained, or empty work without SQL. --stats joins each row's LIFETIME funnel.")
|
|
15409
|
+
.description("List sequences with persisted launch status kept separate from current operational state. Fleet counts report marked-active versus operationally-live (active with nonterminal work), and rows explain flowing, paced, paused, blocked, degraded, drained, or empty work without SQL. An active sequence whose every lead has finished shows drained (completed); leads enrolled into it later are contacted on its next dispatch tick. --stats joins each row's LIFETIME funnel.")
|
|
15280
15410
|
.option("--status <status>", "Filter by status: draft, active, paused, or archived.")
|
|
15281
|
-
.addOption(new Option("--operational-state <state>", "Filter by current work state; fleet counts remain unfiltered: empty, drained, never_started (enrolled leads, never launched), blocked, degraded, flowing, or
|
|
15411
|
+
.addOption(new Option("--operational-state <state>", "Filter by current work state; fleet counts remain unfiltered: empty, drained, never_started (enrolled leads, never launched), blocked, degraded, flowing, paced, or paused (work waiting on a paused sequence).").choices(["empty", "drained", "never_started", "blocked", "degraded", "flowing", "paced", "paused"]))
|
|
15412
|
+
.addOption(new Option("--lifecycle <lifecycle>", "Filter by the lifecycle a person reads; fleet counts remain unfiltered: draft, active, completed (active, every lead finished), paused, or archived.").choices([...SEQUENCE_LIFECYCLES]))
|
|
15282
15413
|
.addOption(new Option("--channel <channel>", "Only sequences carrying this channel.").choices(["email", "linkedin", "whatsapp"]))
|
|
15283
15414
|
.option("--tag <tag>", "Only sequences carrying this workspace tag (see `oxygen tags list`).")
|
|
15284
15415
|
.option("--stats", "Attach lifetime stats per sequence (a stats pass per row — slower on large workspaces).")
|
|
@@ -15292,6 +15423,9 @@ Examples:
|
|
|
15292
15423
|
const operationalState = readOption(options.operationalState);
|
|
15293
15424
|
if (operationalState)
|
|
15294
15425
|
params.set("operational_state", operationalState);
|
|
15426
|
+
const lifecycle = readOption(options.lifecycle);
|
|
15427
|
+
if (lifecycle)
|
|
15428
|
+
params.set("lifecycle", lifecycle);
|
|
15295
15429
|
const channel = readOption(options.channel);
|
|
15296
15430
|
if (channel)
|
|
15297
15431
|
params.set("channel", channel);
|
|
@@ -15552,7 +15686,7 @@ Examples:
|
|
|
15552
15686
|
.addHelpText("after", "\nSenders: each new lead gets one of the --sender-profiles people, and its LinkedIn and email steps come from that person (email from one of their inboxes). Per step, in the steps file: email_send.sender_mailbox_id sends that email from one exact inbox; a LinkedIn step's sender_profile_id sends it from one selected person. Invite, message, inmail and withdraw_invite steps must share one sender_profile_id or none: that person then owns every lead's LinkedIn thread (LinkedIn allows one of your accounts per conversation). visit_profile, follow, like_post and comment_post may each name anyone selected. An email whose From changes, including the return to the lead's inbox, needs its own subject_template.\n")
|
|
15553
15687
|
.requiredOption("--name <name>", "Human-readable sequence name.")
|
|
15554
15688
|
.requiredOption("--slug <slug>", "Unique slug for the sequence.")
|
|
15555
|
-
.requiredOption("--steps-file <path>", "Path to a JSON file: { \"steps\": [...] }. LinkedIn steps (visit_profile | invite | wait_for_connection | message | inmail | follow | like_post | comment_post | withdraw_invite), email steps (email_send | email_enroll | email_move | email_stop), WhatsApp (whatsapp_message), human call tasks (call_task), connected-CRM tasks (crm_task; channel crm; exact identity record_mappings + explicit create/update policy + record_links + dynamic task property_mappings/associations), and control steps (wait | wait_for_signal | branch | stop), each with an `id`. Copy templates support {{column}} interpolation from the lead's row, {{column|fallback}} inline fallbacks, deterministic spintax — {{RANDOM|a|b|c}} or industry-standard bare {Hi|Hey|Hello}, nestable like {Would {Tuesday|Thursday} work|next week?} — and {% if column %}…{% endif %} conditionals; a bare {…} region is spintax only when it contains a top-level |, so literal braces (CSS/JSON) pass through.
|
|
15689
|
+
.requiredOption("--steps-file <path>", "Path to a JSON file: { \"steps\": [...] }. LinkedIn steps (visit_profile | invite | wait_for_connection | message | inmail | follow | like_post | comment_post | withdraw_invite), email steps (email_send | email_enroll | email_move | email_stop), WhatsApp (whatsapp_message), human call tasks (call_task), connected-CRM tasks (crm_task; channel crm; exact identity record_mappings + explicit create/update policy + record_links + dynamic task property_mappings/associations), and control steps (wait | wait_for_signal | branch | stop), each with an `id`. Copy templates support {{column}} interpolation from the lead's row, {{column|fallback}} inline fallbacks, deterministic spintax — {{RANDOM|a|b|c}} or industry-standard bare {Hi|Hey|Hello}, nestable like {Would {Tuesday|Thursday} work|next week?} — and {% if column %}…{% endif %} conditionals; a bare {…} region is spintax only when it contains a top-level |, so literal braces (CSS/JSON) pass through. Every step's copy can also use {{sender_first_name}}, {{sender_last_name}} and {{sender_full_name}} (alias {{sender_name}}): the name of the person who sends that step (their sender profile, else the account or inbox display name), with no table column needed; email steps add {{sender_email}} (the from address). A row column of the same name WINS on collision, and a missing name renders empty. comment_post takes text_template and/or ai_prompt — a KG-grounded comment generated at send time (a paid AI call) that falls back to text_template if generation fails. A `branch` routes on signals (then/else) or the legacy connection_accepted/already_connected/open_profile sugar (then_id/else_id). A signal condition can also branch on the LEAD'S DATA: a data leaf { has_column: \"email\" } is true when that row_values column has a non-empty value (add present:false for \"missing\") — e.g. route leads that have an email down an email arm and the rest down a LinkedIn arm. MINIMAL EMAIL STEP SHAPE: { \"id\": \"s1\", \"channel\": \"email\", \"kind\": \"email_send\", \"subject_template\": \"...\", \"body_template\": \"...\" } — body_template is REQUIRED on email_send; subject_template is OPTIONAL and is the one control that decides threading: give a step its own subject and it goes out as a NEW email, omit it and the step continues the lead's previous email in the same thread, going out as \"Re: <the previous email's subject>\" (what the retired email_reply kind used to be — still accepted on input, rewritten into this shape, and reported under `warnings` in the response). The FIRST email step of a sequence must carry a subject, since it has no earlier thread to continue. MINIMAL WHATSAPP STEP SHAPE: { \"id\": \"s1\", \"channel\": \"whatsapp\", \"kind\": \"whatsapp_message\", \"template\": \"Hi {{first_name|there}} …\" } — template is REQUIRED, and a live WhatsApp start also needs the sequence's --whatsapp-cold-initiate opt-in. A/B tests use an explicit `variants` array of copy partials on the step (up to 25 alternates, a–z; spintax varies wording INSIDE one variant and is not A/B-tracked). For the complete replay-safe crm_task upsert mapping shape, install/read the oxygen-sequencer skill.")
|
|
15556
15690
|
.option("--channels <list>", "Comma-separated channels: linkedin,email,whatsapp,call,crm. Defaults to the channels the journey touches. crm_task currently supports HubSpot.")
|
|
15557
15691
|
.option("--whatsapp-cold-initiate", "WhatsApp: allow cold-initiating new chats (no prior conversation). Required to start a WhatsApp sequence live — WhatsApp via Unipile is unofficial WhatsApp Web, so cold-initiating is an explicit ban-risk opt-in.")
|
|
15558
15692
|
.option("--phone-column-key <key>", "WhatsApp: row_values key holding each lead's phone number (else falls back to phone/phone_number/mobile).")
|
|
@@ -15567,16 +15701,15 @@ Examples:
|
|
|
15567
15701
|
.option("--max-credits <n>", "Credit cap for the LinkedIn track (also set when starting).")
|
|
15568
15702
|
.option("--max-live-sends <n>", "External-action ceiling for 0-credit email/WhatsApp/CRM-task tracks (positive integer). Required to start any of them live.")
|
|
15569
15703
|
.option("--max-emails-per-mailbox-per-day <n>", "Per-mailbox daily email cap (positive integer) applied across the sending pool; also sets each mailbox's send pace (send window ÷ cap, catching up when a slot is missed). Daily capacity = this cap × sendable mailboxes; `sequences stats` reports it under daily_budget.")
|
|
15570
|
-
.option("--
|
|
15571
|
-
.option("--
|
|
15572
|
-
.option("--
|
|
15573
|
-
.option("--
|
|
15574
|
-
.option("--linkedin-hours-end <HH:MM>", "LinkedIn sending schedule end (exclusive), e.g. 18:30. Must be after the start. The window only narrows each sender's working hours (default 07:00-22:00, see senders limits get), never widens them.")
|
|
15704
|
+
.option("--schedule-timezone <tz>", "One sending schedule for every channel, the same as Launch's Sending schedule: IANA zone, e.g. Europe/Berlin. Give it with --schedule-days, --schedule-hours-start and --schedule-hours-end; it sets the email window and the LinkedIn/WhatsApp window together. To give one channel different hours, or to send email in each recipient's own timezone, run `sequences update` after creating (--send-window-file, --linkedin-*).")
|
|
15705
|
+
.option("--schedule-days <days>", "Sending schedule days: mon-fri, mon,wed,fri, or ISO numbers 1-7 (1 = Monday).")
|
|
15706
|
+
.option("--schedule-hours-start <HH:MM>", "Sending schedule start, local to --schedule-timezone, e.g. 09:00.")
|
|
15707
|
+
.option("--schedule-hours-end <HH:MM>", "Sending schedule end (exclusive), e.g. 17:00. An email step's own send_window (on the step in --steps-file) still overrides it for that step. LinkedIn sends also stay inside each LinkedIn account's working hours (default 07:00-22:00 in the account's timezone; set with `oxygen senders limits set`); WhatsApp accounts and email mailboxes have no hours of their own.")
|
|
15575
15708
|
.option("--max-emails-per-day <n>", "Sequence-wide daily live-send cap across all senders/mailboxes. Unset means no additional sequence-wide cap; mailbox caps still apply. Omit on update to keep the current setting.")
|
|
15576
15709
|
.option("--max-new-enrollments-per-day <n>", "Daily cap on NEW first-touch leads. Unset means no additional intake cap; sending limits still apply. Omit on update to keep the current setting.")
|
|
15577
15710
|
.option("--sequence-prioritization <mode>", "Under a tight daily budget, serve 'followups' or 'new_leads' first.")
|
|
15578
15711
|
.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.")
|
|
15579
|
-
.option("--esp-routing-file <path>", "Path to a JSON file holding the WHOLE native-email routing policy: { mode?, routes
|
|
15712
|
+
.option("--esp-routing-file <path>", "Path to a JSON file holding the WHOLE native-email routing policy: { mode?, routes? }. `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. Replaces --esp-matching and the file is the complete policy, not a patch. To stop sending from a domain, leave its inboxes out with --mailboxes.")
|
|
15580
15713
|
.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.")
|
|
15581
15714
|
.option("--email-min-gap-minutes <n>", "Minimum same-mailbox gap, default 12 minutes (integer 0-720; 0 disables the extra gap). Sends are spread across the window and may be farther apart. Daily caps and windows still apply.")
|
|
15582
15715
|
.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.")
|
|
@@ -15588,11 +15721,8 @@ Examples:
|
|
|
15588
15721
|
.option("--company-reply-pause <off|default|statuses>", "Pause the replier's company in this campaign when a reply has one of these statuses. Oxygen detects the company from work-email domains or explicit company fields; with no company identity, only the replier stops. New sequences start with this setting off. 'default' = interested,neutral,not_now,not_interested; or comma-separated Inbox status keys (`inbox labels list`); 'off' disables it. Read the saved setting with `sequences get`.")
|
|
15589
15722
|
.option("--stop-on-reply-scope <scope>", "(deprecated: use --company-reply-pause) 'company_domain' = --company-reply-pause interested,neutral,not_now,not_interested,wrong_person; 'lead' = off.")
|
|
15590
15723
|
.option("--bounce-protection <json>", "Sequence bounce policy JSON (partial updates merge): enabled=true, warning_rate=.02, pause_rate=.03, min_sends=20, window_days=7. Hard bounces / accepted sends in the rolling window; pauses this sequence, not the fleet. Rates are fractions; mailbox protection is separate.")
|
|
15591
|
-
.option("--include-unsubscribe
|
|
15592
|
-
.option("--no-include-unsubscribe
|
|
15593
|
-
.option("--reply-to-stop-text <text>", "Personal opt-out text (default empty); empty clears it.")
|
|
15594
|
-
.option("--include-unsubscribe-link", "Append OXYGEN's visible recipient-bound unsubscribe footer to first-touch cold emails. Explicit opt-in; off by default. Plain-text messages show the URL, while HTML messages show a linked Unsubscribe label.")
|
|
15595
|
-
.option("--no-include-unsubscribe-link", "Do not append OXYGEN's visible unsubscribe footer (the default). Sends the authored body unchanged and never removes suppression from recipients who already opted out.")
|
|
15724
|
+
.option("--include-unsubscribe", "Add an unsubscribe option to emails, the same as Launch's switch: a visible recipient-bound link (plain text shows the URL, HTML a linked Unsubscribe label) plus one-click headers that mail apps show as an Unsubscribe button, both on first-touch cold emails; in-thread follow-ups carry neither. Zapbox sends (mailboxes imported from Zapmail, `transport: \"zapbox\"` in `mailboxes list --json`) carry the link only. Off by default. There is no separate opt-out text: write any reply-to-stop line into the email copy. `sequences update` can switch on the link or the headers alone.")
|
|
15725
|
+
.option("--no-include-unsubscribe", "Turn off both the visible unsubscribe link and the one-click headers.")
|
|
15596
15726
|
.option("--tracking-opens", "Opt in to open tracking (default off). Requires a verified tracking domain and deployment tracking secret.")
|
|
15597
15727
|
.option("--no-tracking-opens", "Turn OFF the open pixel for this sequence's native email sends (a deliverability knob — a pixel is spam-filter surface).")
|
|
15598
15728
|
.option("--tracking-clicks", "Opt in to click tracking (default off). Requires a verified tracking domain and deployment tracking secret.")
|
|
@@ -15637,6 +15767,7 @@ Examples:
|
|
|
15637
15767
|
...(options.trackingOpens !== undefined ? { tracking_opens: options.trackingOpens } : {}),
|
|
15638
15768
|
...(options.trackingClicks !== undefined ? { tracking_clicks: options.trackingClicks } : {}),
|
|
15639
15769
|
...(settings ? { settings } : {}),
|
|
15770
|
+
...readUnifiedSequenceBody(options),
|
|
15640
15771
|
},
|
|
15641
15772
|
});
|
|
15642
15773
|
});
|
|
@@ -15669,11 +15800,11 @@ Examples:
|
|
|
15669
15800
|
}
|
|
15670
15801
|
}))
|
|
15671
15802
|
.addCommand(new Command("update")
|
|
15672
|
-
.description("Update a sequence. Journey structure/channels/email binding are draft-only so revised scope receives a fresh launch approval; a started sequence may only re-time waits it already has (clear a delay by zeroing it, never by deleting the step — enrollments track position by index); sender pools can change while draft or paused; launch caps change through `sequences start` after first start. Name, throttles, tags, the
|
|
15803
|
+
.description("Update a sequence. Journey structure/channels/email binding are draft-only so revised scope receives a fresh launch approval; a started sequence may only re-time waits it already has (clear a delay by zeroing it, never by deleting the step — enrollments track position by index); sender pools can change while draft or paused; launch caps change through `sequences start` after first start. Name, throttles, the sending schedule, tags, the unsubscribe option (off by default), and open/click tracking toggles remain editable as allowed by the server. Pass only the fields you want to change.")
|
|
15673
15804
|
.addHelpText("after", "\nSenders: email_send.sender_mailbox_id sends one email from an exact inbox of a selected sender; a LinkedIn step's sender_profile_id sends it from a selected person (invite, message, inmail and withdraw_invite share one or none). Omit both to use the lead's assigned person. An email whose From changes needs its own subject_template.\n")
|
|
15674
15805
|
.argument("<sequence>", "Sequence id or slug.")
|
|
15675
15806
|
.option("--name <name>", "New human-readable sequence name.")
|
|
15676
|
-
.option("--steps-file <path>", "Draft only: path to a JSON file: { \"steps\": [...] } replacing the journey. Copy supports {{column}} interpolation;
|
|
15807
|
+
.option("--steps-file <path>", "Draft only: path to a JSON file: { \"steps\": [...] } replacing the journey. Copy supports {{column}} interpolation; every step also takes {{sender_first_name}} / {{sender_last_name}} / {{sender_full_name}} for the person who sends it, and email adds {{sender_email}} (a same-named row column wins). A `branch` can also route on the lead's data with a data leaf { has_column: \"email\" } (true when that row_values column is non-empty; present:false for \"missing\").")
|
|
15677
15808
|
.option("--channels <list>", "Draft only: comma-separated channels: linkedin,email,whatsapp,call,crm.")
|
|
15678
15809
|
.option("--whatsapp-cold-initiate", "WhatsApp: allow cold-initiating new chats (no prior conversation). Required to start a WhatsApp sequence live (draft-editable).")
|
|
15679
15810
|
.option("--phone-column-key <key>", "WhatsApp: row_values key holding each lead's phone number (else falls back to phone/phone_number/mobile).")
|
|
@@ -15688,16 +15819,20 @@ Examples:
|
|
|
15688
15819
|
.option("--max-credits <n>", "Credit cap for the LinkedIn track (draft only).")
|
|
15689
15820
|
.option("--max-live-sends <n>", "Draft-time external-action ceiling for 0-credit email/WhatsApp/CRM-task tracks (positive integer). After first start, change it through `sequences start`.")
|
|
15690
15821
|
.option("--max-emails-per-mailbox-per-day <n>", "Per-mailbox daily email cap (positive integer) applied across the sending pool; also sets each mailbox's send pace (send window ÷ cap, catching up when a slot is missed). Daily capacity = this cap × sendable mailboxes; `sequences stats` reports it under daily_budget.")
|
|
15691
|
-
.option("--
|
|
15692
|
-
.option("--
|
|
15822
|
+
.option("--schedule-timezone <tz>", "One sending schedule for every channel, the same as Launch's Sending schedule: IANA zone, e.g. Europe/Berlin. Give it with --schedule-days, --schedule-hours-start and --schedule-hours-end; it sets the email window and the LinkedIn/WhatsApp window together. Use it instead of --send-window-file and the --linkedin-* flags. Email set to send in each recipient's own timezone keeps doing so, with this zone for leads that have none.")
|
|
15823
|
+
.option("--schedule-days <days>", "Sending schedule days: mon-fri, mon,wed,fri, or ISO numbers 1-7 (1 = Monday).")
|
|
15824
|
+
.option("--schedule-hours-start <HH:MM>", "Sending schedule start, local to --schedule-timezone, e.g. 09:00.")
|
|
15825
|
+
.option("--schedule-hours-end <HH:MM>", "Sending schedule end (exclusive), e.g. 17:00. An email step's own send_window (on the step in --steps-file) still overrides it for that step. LinkedIn sends also stay inside each LinkedIn account's working hours (default 07:00-22:00 in the account's timezone; set with `oxygen senders limits set`); WhatsApp accounts and email mailboxes have no hours of their own.")
|
|
15826
|
+
.option("--send-window-file <path>", "Email-only window JSON: { timezone, days?, start, end, timezone_mode?, recipient_timezone_column? }; days are ISO 1-7. New sequences default to weekdays 09:00-17:00 in the workspace timezone when available, otherwise UTC. Use it to keep email on different hours from LinkedIn/WhatsApp, or to send in each recipient's own timezone (timezone_mode recipient, which --schedule-* then keeps); for one schedule across channels, use --schedule-* instead.")
|
|
15827
|
+
.option("--linkedin-timezone <tz>", "LinkedIn/WhatsApp-only sending schedule: IANA zone, e.g. Europe/Zurich. Set with --linkedin-days, --linkedin-hours-start and --linkedin-hours-end; for LinkedIn the window narrows each account's working hours, for WhatsApp it is the only hours limit. For one schedule across channels, use --schedule-* instead.")
|
|
15693
15828
|
.option("--linkedin-days <days>", "LinkedIn sending schedule days: mon-fri, mon,wed,fri, or ISO numbers 1-7 (1 = Monday).")
|
|
15694
15829
|
.option("--linkedin-hours-start <HH:MM>", "LinkedIn sending schedule start, local to --linkedin-timezone, e.g. 06:45.")
|
|
15695
|
-
.option("--linkedin-hours-end <HH:MM>", "LinkedIn sending schedule end (exclusive), e.g. 18:30. Must be after the start.
|
|
15830
|
+
.option("--linkedin-hours-end <HH:MM>", "LinkedIn sending schedule end (exclusive), e.g. 18:30. Must be after the start. For LinkedIn the window only narrows each account's working hours (default 07:00-22:00, see senders limits get), never widens them.")
|
|
15696
15831
|
.option("--max-emails-per-day <n>", "Sequence-wide daily live-send cap across all senders/mailboxes. Unset means no additional sequence-wide cap; mailbox caps still apply. Omit on update to keep the current setting.")
|
|
15697
15832
|
.option("--max-new-enrollments-per-day <n>", "Daily cap on NEW first-touch leads. Unset means no additional intake cap; sending limits still apply. Omit on update to keep the current setting.")
|
|
15698
15833
|
.option("--sequence-prioritization <mode>", "Under a tight daily budget, serve 'followups' or 'new_leads' first.")
|
|
15699
15834
|
.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.")
|
|
15700
|
-
.option("--esp-routing-file <path>", "Path to a JSON file holding the WHOLE native-email routing policy: { mode?, routes
|
|
15835
|
+
.option("--esp-routing-file <path>", "Path to a JSON file holding the WHOLE native-email routing policy: { mode?, routes? }. `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. Replaces --esp-matching and the file is the complete policy, not a patch. To stop sending from a domain, leave its inboxes out with --mailboxes.")
|
|
15701
15836
|
.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.")
|
|
15702
15837
|
.option("--email-min-gap-minutes <n>", "Minimum same-mailbox gap, default 12 minutes (integer 0-720; 0 disables the extra gap). Sends are spread across the window and may be farther apart. Daily caps and windows still apply.")
|
|
15703
15838
|
.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.")
|
|
@@ -15709,10 +15844,11 @@ Examples:
|
|
|
15709
15844
|
.option("--company-reply-pause <off|default|statuses>", "Pause the replier's company in this campaign when a reply has one of these statuses. Oxygen detects the company from work-email domains or explicit company fields; with no company identity, only the replier stops. New sequences start with this setting off. 'default' = interested,neutral,not_now,not_interested; or comma-separated Inbox status keys (`inbox labels list`); 'off' disables it. Read the saved setting with `sequences get`.")
|
|
15710
15845
|
.option("--stop-on-reply-scope <scope>", "(deprecated: use --company-reply-pause) 'company_domain' = --company-reply-pause interested,neutral,not_now,not_interested,wrong_person; 'lead' = off.")
|
|
15711
15846
|
.option("--bounce-protection <json>", "Sequence bounce policy JSON (partial updates merge): enabled=true, warning_rate=.02, pause_rate=.03, min_sends=20, window_days=7. Hard bounces / accepted sends in the rolling window; pauses this sequence, not the fleet. Rates are fractions; mailbox protection is separate.")
|
|
15712
|
-
.option("--include-unsubscribe
|
|
15847
|
+
.option("--include-unsubscribe", "Add an unsubscribe option to emails, the same as Launch's switch: a visible recipient-bound link (plain text shows the URL, HTML a linked Unsubscribe label) plus one-click headers that mail apps show as an Unsubscribe button, both on first-touch cold emails; in-thread follow-ups carry neither. Zapbox sends (mailboxes imported from Zapmail, `transport: \"zapbox\"` in `mailboxes list --json`) carry the link only. Off by default. There is no separate opt-out text: write any reply-to-stop line into the email copy.")
|
|
15848
|
+
.option("--no-include-unsubscribe", "Turn off both the visible unsubscribe link and the one-click headers.")
|
|
15849
|
+
.option("--include-unsubscribe-headers", "Only the one-click unsubscribe headers (default off); --include-unsubscribe turns on the headers and the visible link together.")
|
|
15713
15850
|
.option("--no-include-unsubscribe-headers", "Omit one-click headers.")
|
|
15714
|
-
.option("--
|
|
15715
|
-
.option("--include-unsubscribe-link", "Append OXYGEN's visible recipient-bound unsubscribe footer to first-touch cold emails. Explicit opt-in; off by default. Plain-text messages show the URL, while HTML messages show a linked Unsubscribe label.")
|
|
15851
|
+
.option("--include-unsubscribe-link", "Only the visible recipient-bound unsubscribe footer on first-touch cold emails (default off; --include-unsubscribe adds the one-click headers too). Plain-text messages show the URL, while HTML messages show a linked Unsubscribe label.")
|
|
15716
15852
|
.option("--no-include-unsubscribe-link", "Turn the visible unsubscribe footer OFF. Future first-touch emails send the authored body unchanged; existing suppression records remain enforced.")
|
|
15717
15853
|
.option("--tracking-opens", "Opt in to open tracking (default off). Requires a verified tracking domain and deployment tracking secret.")
|
|
15718
15854
|
.option("--no-tracking-opens", "Turn OFF the open pixel for this sequence's native email sends (a deliverability knob — a pixel is spam-filter surface).")
|
|
@@ -15757,6 +15893,7 @@ Examples:
|
|
|
15757
15893
|
const settings = readSequenceSettings(options);
|
|
15758
15894
|
if (settings)
|
|
15759
15895
|
body.settings = settings;
|
|
15896
|
+
Object.assign(body, readUnifiedSequenceBody(options));
|
|
15760
15897
|
// Tri-state tracking toggles: only send the key when a flag was
|
|
15761
15898
|
// explicitly passed, so an unrelated update never flips tracking.
|
|
15762
15899
|
if (options.trackingOpens !== undefined)
|
|
@@ -15772,7 +15909,7 @@ Examples:
|
|
|
15772
15909
|
body.email = email;
|
|
15773
15910
|
}
|
|
15774
15911
|
if (Object.keys(body).length === 0) {
|
|
15775
|
-
throw new Error("Provide at least one field to update (--name, --steps-file, --source-table, --channels, --senders, --tags, --email-*, --clear-email, --max-credits, --max-live-sends, --max-emails-per-mailbox-per-day, --send-window-file, --linkedin-* schedule, --company-reply-pause, --[no-]include-unsubscribe-link, --no-stop-on-bounce, --[no-]exclude-contacted, or --[no-]tracking-opens / --[no-]tracking-clicks).");
|
|
15912
|
+
throw new Error("Provide at least one field to update (--name, --steps-file, --source-table, --channels, --senders, --tags, --email-*, --clear-email, --max-credits, --max-live-sends, --max-emails-per-mailbox-per-day, --schedule-* sending schedule, --send-window-file, --linkedin-* schedule, --company-reply-pause, --[no-]include-unsubscribe, --[no-]include-unsubscribe-link, --no-stop-on-bounce, --[no-]exclude-contacted, or --[no-]tracking-opens / --[no-]tracking-clicks).");
|
|
15776
15913
|
}
|
|
15777
15914
|
return requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}`, {
|
|
15778
15915
|
method: "PATCH",
|
|
@@ -15789,7 +15926,7 @@ Examples:
|
|
|
15789
15926
|
await handleAsyncAction("sequences get", options, () => requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}${options.readiness ? "?include=readiness" : ""}`));
|
|
15790
15927
|
}))
|
|
15791
15928
|
.addCommand(new Command("enroll")
|
|
15792
|
-
.description("Enroll leads into a sequence. Enrolling spends no credits
|
|
15929
|
+
.description("Enroll leads into a sequence. Enrolling spends no credits. An active sequence contacts the new leads on its next dispatch tick; a draft or paused one holds them until `sequences start`. --from-table enrolls every not-yet-enrolled row of the sequence's bound source table (up to 500 per run; the response's from_table.has_more says whether to run again) — for a LinkedIn sequence the profile is read from the bound table's URL column, where a full /in/ URL and a bare public handle carrying a hyphen or digit (ada-lovelace) both work, so no pre-resolved provider ids are needed; each handle Oxygen canonicalized is echoed in linkedin_handles_canonicalized (value + url) to check before start, and every row that still cannot be resolved is named in unresolved_linkedin_lead_details (row, value, reason) rather than only counted in unresolved_linkedin_leads. --leads-file enrolls an explicit JSON list { leads: [...] }: for an email/WhatsApp sequence a lead identified only by an email (row_values.email) or phone (row_values.phone) is auto-filed as a row in the sequence's leads table (auto-creating and binding one if there is none), deduped by email/phone, so no table has to exist first (the table is returned as leads_table with a web_url); a LinkedIn lead needs a lead_provider_id, a lead_profile_url (a /in/ URL or a bare public handle with a hyphen or digit), or a table_row_id whose row carries one of those. A CRM-only lead needs a stable table_row_id or lead_provider_id in addition to mapped row_values; a HubSpot contact id used in crm_task associations identifies the destination record, not the Oxygen enrollment. When the sequence is bound to a source table, a lead's table_row_id auto-snapshots that row's columns (incl. AI/tool outputs) into row_values for {{column}} copy — explicit row_values win. Idempotent per table row (and per email/phone for auto-filed leads). The org do-not-contact list is always enforced; --exclude-contacted and --suppress-list add further opt-in skips (reported under skipped_by_reason). Leads already owned by a sender account (from an earlier real send) are routed back to that same account; a lead owned by a sender NOT on this sequence is skipped (bound_to_other_sender) unless --ignore-sender-bindings. This enrolls into an OXYGEN Sequence; to put the same rows into a campaign you already run in Smartlead, Instantly, lemlist or HeyReach, use `sequences push-external`.")
|
|
15793
15930
|
.argument("<sequence>", "Sequence id or slug.")
|
|
15794
15931
|
.option("--leads-file <path>", "Path to a JSON file: { \"leads\": [{ row_values: { email }, lead_name }] } for cold email; { lead_provider_id, lead_name, table_row_id, row_values } for LinkedIn/existing rows; CRM-only leads require table_row_id or a stable lead_provider_id alongside mapped row_values. A CRM association object id is not the enrollment identity. Exactly one of --leads-file or --from-table.")
|
|
15795
15932
|
.option("--from-table", "Enroll every not-yet-enrolled row of the sequence's bound source table (up to 500 per run; re-run to continue). Exactly one of --leads-file or --from-table.")
|
|
@@ -15964,19 +16101,28 @@ Examples:
|
|
|
15964
16101
|
});
|
|
15965
16102
|
}))
|
|
15966
16103
|
.addCommand(new Command("contacts")
|
|
15967
|
-
.description("List a launched campaign's table-backed contacts with full source variables,
|
|
16104
|
+
.description("List a launched campaign's table-backed contacts with full source variables, contact tags, and factual or assigned sender. A contact carries every tag it reached: contacted means an invite, message or email was sent (a profile visit is not), and a positive reply is also replied and contacted. Contacts removed from this campaign are hidden; source rows remain intact.")
|
|
15968
16105
|
.argument("<sequence>", "Sequence id or slug.")
|
|
15969
|
-
.option("--contact-state <
|
|
16106
|
+
.option("--contact-state <tags>", "Only contacts with any of these tags, comma-separated: not_contacted, contacted, connected, replied, positive_reply; or all.", "all")
|
|
16107
|
+
.option("--exclude-contact-state <tags>", "Hide contacts with any of these tags, comma-separated.")
|
|
15970
16108
|
.option("--limit <n>", "Maximum contacts per page (1-500). Page with --cursor (next_cursor from the previous page) or --offset.", "100")
|
|
15971
16109
|
.option("--cursor <c>", "Pagination cursor from the previous page's next_cursor.")
|
|
15972
16110
|
.option("--offset <n>", "Skip this many contacts before the page (0-based); an absolute alternative to --cursor.")
|
|
15973
16111
|
.option("--json", "Print a JSON envelope.")
|
|
15974
16112
|
.action(async (sequence, options) => {
|
|
15975
16113
|
await handleAsyncAction("sequences contacts", options, () => {
|
|
15976
|
-
const
|
|
15977
|
-
|
|
15978
|
-
|
|
15979
|
-
|
|
16114
|
+
const contactTags = ["not_contacted", "contacted", "connected", "replied", "positive_reply"];
|
|
16115
|
+
const readTags = (flag, raw) => {
|
|
16116
|
+
const tags = raw.split(",").map((tag) => tag.trim()).filter(Boolean);
|
|
16117
|
+
if (tags.length === 0 || tags.some((tag) => !contactTags.includes(tag))) {
|
|
16118
|
+
throw new Error(`${flag} takes comma-separated tags: ${contactTags.join(", ")}.`);
|
|
16119
|
+
}
|
|
16120
|
+
return tags.join(",");
|
|
16121
|
+
};
|
|
16122
|
+
const contactStateRaw = readOption(options.contactState) ?? "all";
|
|
16123
|
+
const contactState = contactStateRaw === "all" ? "all" : readTags("--contact-state", contactStateRaw);
|
|
16124
|
+
const excludeRaw = readOption(options.excludeContactState);
|
|
16125
|
+
const excludeContactState = excludeRaw ? readTags("--exclude-contact-state", excludeRaw) : null;
|
|
15980
16126
|
const limit = readPositiveInt(options.limit);
|
|
15981
16127
|
if (limit === undefined || limit > 500) {
|
|
15982
16128
|
throw new Error("--limit must be between 1 and 500.");
|
|
@@ -15985,6 +16131,8 @@ Examples:
|
|
|
15985
16131
|
contact_state: contactState,
|
|
15986
16132
|
limit: String(limit),
|
|
15987
16133
|
});
|
|
16134
|
+
if (excludeContactState)
|
|
16135
|
+
params.set("exclude_contact_state", excludeContactState);
|
|
15988
16136
|
const cursor = readOption(options.cursor);
|
|
15989
16137
|
if (cursor)
|
|
15990
16138
|
params.set("cursor", cursor);
|
|
@@ -17740,7 +17888,7 @@ Examples:
|
|
|
17740
17888
|
.addCommand(new Command("emailguard")
|
|
17741
17889
|
.description("Assess every mailbox origin for EmailGuard and connect credential-capable Google Workspace mailboxes from managed, Zapmail, or compatible encrypted external imports. Manual/native Google rows report credential_required; Microsoft 365/Azure reports vendor_blocked. Preview never materializes a password; approved execution fetches it just in time and never prints it.")
|
|
17742
17890
|
.addCommand(new Command("connect")
|
|
17743
|
-
.description("Preview or connect selected mailboxes to EmailGuard.
|
|
17891
|
+
.description("Preview or connect selected mailboxes to EmailGuard. On a repriced workspace managed mode is 135 credits/inbox-month, with two placement tests per inbox per cycle included; an extra managed test costs 66 credits. The preview shows the price in force; BYOK is 0 Oxygen credits. Google Workspace requires a real app password, fetched only after approval. Microsoft 365/Azure is explicitly vendor-blocked because EmailGuard exposes neither Microsoft OAuth nor tenant consent; Oxygen never downgrades it to password auth. Starts with a 0-credit, no-provider-write preview. Account connection sends no placement probe.")
|
|
17744
17892
|
.option("--mailboxes <list>", "Comma-separated mailbox ids or addresses. Omit for the whole pool.")
|
|
17745
17893
|
.option("--approved", "Perform the external EmailGuard account write.")
|
|
17746
17894
|
.option("--plan <hash>", "Exact hash from the fresh preview (required with --approved).")
|
|
@@ -17857,7 +18005,7 @@ Examples:
|
|
|
17857
18005
|
});
|
|
17858
18006
|
}))
|
|
17859
18007
|
.addCommand(new Command("warmup")
|
|
17860
|
-
.description("OXYGEN Warm-up for the sending pool — the weeks of low-volume conversational mail that make a new inbox look trusted before you cold-send from it. It costs 100 credits per warming inbox per month ($1), or nothing once it is included in your own mailbox's 100-credit monthly connection (2026-09 repricing); the preview shows which applies. Fresh managed InboxKit Google/Microsoft/Azure orders with the default-on warm-up add-on activate automatically after provisioning under the approved order quote; do not run a second `warmup enable`. Google reuses the InboxKit-held credential only during activation and never stores or returns it; Microsoft/Azure uses exact native export. Standalone preview → approval is for BYOK/imported mailboxes, a managed opt-out, or later separate enrollment. InboxKit auto-export stays off for Microsoft/Azure because Oxygen submits only the exact authorized UIDs, and any non-cancelled InboxKit warm-up blocks either handoff so one mailbox cannot warm twice. OXYGEN Warm-up never owns campaign dispatch: native OXYGEN Sequences still own campaigns and sends. Eligible non-InboxKit Microsoft mailboxes use the separate `mailboxes warmup microsoft` OAuth fallback. Inboxes already warming on the retired TrulyInbox rail keep reporting and are wound down there with `warmup disable`; they are never silently moved to the current managed rail.
|
|
18008
|
+
.description("OXYGEN Warm-up for the sending pool — the weeks of low-volume conversational mail that make a new inbox look trusted before you cold-send from it. It costs 100 credits per warming inbox per month ($1), or nothing once it is included in your own mailbox's 100-credit monthly connection (2026-09 repricing); the preview shows which applies. Fresh managed InboxKit Google/Microsoft/Azure orders with the default-on warm-up add-on activate automatically after provisioning under the approved order quote; do not run a second `warmup enable`. Google reuses the InboxKit-held credential only during activation and never stores or returns it; Microsoft/Azure uses exact native export. Standalone preview → approval is for BYOK/imported mailboxes, a managed opt-out, or later separate enrollment. InboxKit auto-export stays off for Microsoft/Azure because Oxygen submits only the exact authorized UIDs, and any non-cancelled InboxKit warm-up blocks either handoff so one mailbox cannot warm twice. OXYGEN Warm-up never owns campaign dispatch: native OXYGEN Sequences still own campaigns and sends. Eligible non-InboxKit Microsoft mailboxes use the separate `mailboxes warmup microsoft` OAuth fallback. Inboxes already warming on the retired TrulyInbox rail keep reporting and are wound down there with `warmup disable`; they are never silently moved to the current managed rail. On existing active paid seats, Google credential repair uses its own enabled-by-default switch. When the separate worker recovery mode is enforce (the unset default), Microsoft OAuth or tenant-consent seats get a credential-free vendor reactivation, and stalled dispatch or DNS readiness is rechecked. Shadow reports candidates in those enabled lanes without their vendor writes; off skips those lanes. Google credential repair can still run in shadow/off. Correct bad DNS records yourself; an active unpaused paid seat in enforce mode needs no manual resume after that correction. OXYGEN never invents consent, resumes a human or safety pause, starts a new paid enrollment, or blindly repeats an ambiguous provider write. Check `warmup status` and `mailboxes list` for the exact next action.")
|
|
17861
18009
|
.addHelpText("after", "\nDocs: https://oxygen-agent.com/docs/providers/mailboxes\n")
|
|
17862
18010
|
.addCommand(new Command("enable")
|
|
17863
18011
|
.description("Optional provider warm-up defaults to 1/day, +1/day, max 10/day (dedicated-tenant profiles stay stricter); see `mailboxes warmup config` for the effective plan and unsupported OXYGEN Warm-up reply-rate control. This is separate from the 5/10/20 campaign ramp. STANDALONE ENROLLMENT: enroll BYOK/imported mailboxes, a managed order that opted out, or mailboxes being added to warmup later. Fresh managed InboxKit Google/Microsoft/Azure orders whose approved quote included warmup activate automatically after provisioning and do not need this command. Targets the whole pool unless --mailboxes is given; one synchronous preview/approval is capped at 220 mailboxes, so split larger pools into explicit batches. Without --approved this is a 0-credit, no-provider-write priced preview: nothing is handed off, enrolled, or billed. The price is 100 credits per warming mailbox-month, or 0 once warm-up is included in the mailbox's 100-credit monthly connection (2026-09 repricing; the preview's included_in_connection says which, and its max_credits is then 0); the preview returns the exact first-cycle ceiling. Preview one inbox with `oxygen mailboxes warmup enable --mailboxes sender@example.com --json`. Execute by echoing its --plan and --max-credits with --approved. Eligible managed Google targets use the InboxKit-held credential just in time without storing or returning it; Microsoft/Azure targets use native InboxKit Sequencer export with exact UIDs and auto-export off. Any non-cancelled InboxKit warmup must be cancelled before either handoff (pausing is not enough). Only eligible non-InboxKit Microsoft mailboxes need `mailboxes warmup microsoft`. OXYGEN Warm-up retains no campaign authority — OXYGEN Sequences own enrollment and dispatch. New enrollments only ever land on the managed OXYGEN rail; the retired TrulyInbox rail is refused here and only accepts teardown.")
|
|
@@ -18075,7 +18223,7 @@ Examples:
|
|
|
18075
18223
|
});
|
|
18076
18224
|
}))
|
|
18077
18225
|
.addCommand(new Command("reconnect")
|
|
18078
|
-
.description("Manual repair for an existing live paid seat when
|
|
18226
|
+
.description("Manual repair for an existing live paid seat when automatic credential repair is disabled, exhausted, or needs a new credential: push the mailbox's CURRENT credential to its warmup provider. Use this when a new app password is needed because the old one was rotated or revoked (the provider reporting 'Username and Password not accepted'): re-import the new credential with `mailboxes import`, then run this. It costs 0 credits and leaves your warmup subscription and its renewal date exactly as they are — you do NOT need to disable and re-enable warmup, which would charge a second month. The provider cannot change a stored password in place, so the inbox's warmup history restarts from day 1. Targets the whole pool unless --mailboxes is given.")
|
|
18079
18227
|
.option("--mailboxes <list>", "Comma-separated mailbox ids or addresses. Omit for the whole pool.")
|
|
18080
18228
|
.option("--json", "Print a JSON envelope.")
|
|
18081
18229
|
.action(async (options) => {
|
|
@@ -18217,7 +18365,7 @@ Examples:
|
|
|
18217
18365
|
}));
|
|
18218
18366
|
}))
|
|
18219
18367
|
.addCommand(new Command("list")
|
|
18220
|
-
.description("Inspect recent directional inbox-placement tests, their status, results, and credits used, plus `pricing`:
|
|
18368
|
+
.description("Inspect recent directional inbox-placement tests, their status, results, and credits used, plus `pricing`: EmailGuard monitoring credits per inbox-month, two included tests per inbox per cycle, and the managed extra-test price in force (0 on your own EmailGuard key). The returned web link opens Accounts; use get for an exact result link. Costs 0 credits and never creates or sends a test; active tests may refresh their observations.")
|
|
18221
18369
|
.option("--limit <n>", "Max rows (default 50, cap 200).")
|
|
18222
18370
|
.option("--json", "Print a JSON envelope.")
|
|
18223
18371
|
.action(async (options) => {
|
|
@@ -18234,7 +18382,7 @@ Examples:
|
|
|
18234
18382
|
await handleAsyncAction("deliverability placement-test get", options, () => requestOxygen(`/api/cli/deliverability/placement-tests/${encodeURIComponent(id)}`));
|
|
18235
18383
|
}))));
|
|
18236
18384
|
program.addCommand(new Command("domains")
|
|
18237
|
-
.description("Cold-email domains: sync zones from your own Cloudflare account (BYOK), inspect age/warmup/DNS health, check availability and pricing, buy domains, and archive or delete domains with their inboxes (OXYGEN-bought domains included).
|
|
18385
|
+
.description("Cold-email domains: sync zones from your own Cloudflare account (BYOK), inspect age/warmup/DNS health, check availability and pricing, buy domains, and archive or delete domains with their inboxes (OXYGEN-bought domains included). Your own Cloudflare account bills purchases to its payment method; domains registered by OXYGEN cost credits at the vendor price. Preview `domains buy` before approval.")
|
|
18238
18386
|
.addCommand(new Command("list")
|
|
18239
18387
|
.description("List the org's cached Cloudflare domains with cold-email metadata (age, mailboxes, warmup, sending volume, DNS health). Each row's `dns` object explains the status (ok, issues, not checked, check failed, partial check, no zone) with a reason and fix path; `managed_domains` lists InboxKit-managed bundle domains (vendor-registered + vendor-DNS'd — manage via `managed-inboxes`), each carrying `internal_billing_status` (ok|past_due) and `last_billed_cycle_key` — a domain the vendor keeps live can still be one OXYGEN could not renew. Reads the cache only — run `domains sync` to refresh.")
|
|
18240
18388
|
.option("--status <status>", "Filter by zone status: unknown, initializing, pending, active, moved, or deleted.")
|
|
@@ -20971,7 +21119,7 @@ function printColumnCatalog(result) {
|
|
|
20971
21119
|
lines.push("Add one: oxygen columns add <table> --prompt-key <key> --input <input>=<column_key>, then `columns run <table> <key> --dry-run` for the exact cost.");
|
|
20972
21120
|
// The blind acceptance judge watched an agent filter this list with python3
|
|
20973
21121
|
// because nothing on screen said the command can narrow itself.
|
|
20974
|
-
lines.push("Narrow this list: --family pricing|job_posting|events|filings|site_facts|market|portfolio|text_extraction, --category extract, --search <word>, --kind research|ai|formula.
|
|
21122
|
+
lines.push("Narrow this list: --family pricing|job_posting|events|filings|site_facts|market|portfolio|text_extraction, --category extract, --search <word>, --kind research|ai|formula. Template credits may reserve primary and fallback lanes; Classify has the fixed per-row price shown in its entry.");
|
|
20975
21123
|
process.stderr.write(`${lines.join("\n")}\n`);
|
|
20976
21124
|
}
|
|
20977
21125
|
function applyAiColumnConfig(definition, options) {
|
|
@@ -25928,9 +26076,11 @@ function renderTextTable(headers, rows) {
|
|
|
25928
26076
|
return [renderRow(headers), separator, ...rows.map(renderRow)];
|
|
25929
26077
|
}
|
|
25930
26078
|
// Sequence state is derived only by the shared API. These helpers render those
|
|
25931
|
-
// facts; they never infer flowing/blocked/live client-side.
|
|
25932
|
-
//
|
|
25933
|
-
|
|
26079
|
+
// facts; they never infer flowing/blocked/live client-side. The one label drawn
|
|
26080
|
+
// from them, completed, is the server's `lifecycle` (the shared helper covers a
|
|
26081
|
+
// server that predates it). JSON remains the untouched API data inside the
|
|
26082
|
+
// standard CLI envelope.
|
|
26083
|
+
const SEQUENCE_OPERATIONAL_STATE_ORDER = ["empty", "drained", "never_started", "blocked", "degraded", "flowing", "paced", "paused"];
|
|
25934
26084
|
/**
|
|
25935
26085
|
* `crm objects`, as an operator reads it.
|
|
25936
26086
|
*
|
|
@@ -26164,6 +26314,9 @@ function formatSequenceListHealth(data) {
|
|
|
26164
26314
|
}
|
|
26165
26315
|
else {
|
|
26166
26316
|
lines.push(...renderTextTable(["SEQUENCE", "PERSISTED", "OPERATIONAL", "OPEN WORK", "SENT / FAILED", "REASONS"], sequences.map((sequence) => sequenceHealthRow(sequence))));
|
|
26317
|
+
const warnings = sequenceRowWarnings(sequences);
|
|
26318
|
+
if (warnings.length > 0)
|
|
26319
|
+
lines.push("", ...warnings);
|
|
26167
26320
|
lines.push("", ...sequenceInspectionLinks(sequences));
|
|
26168
26321
|
}
|
|
26169
26322
|
const listLink = stringValue(data.web_url) ?? stringValue(data.deepLink);
|
|
@@ -26293,7 +26446,7 @@ function formatSequenceStatsHealth(data) {
|
|
|
26293
26446
|
const lines = [
|
|
26294
26447
|
stringValue(data.name) ?? stringValue(data.sequence_id) ?? "Sequence",
|
|
26295
26448
|
`Persisted status: ${stringValue(data.status) ?? "unknown"}`,
|
|
26296
|
-
`Operational state: ${
|
|
26449
|
+
`Operational state: ${operationalStateText(data, operational) ?? "unknown (legacy response)"}`,
|
|
26297
26450
|
`Open work: ${formatSequenceOpenWork(operational.openWork)}`,
|
|
26298
26451
|
`Reasons: ${formatSequenceReasons(operational)}`,
|
|
26299
26452
|
`${formatFailureWindow(operational.failureAnalytics)} outcomes: ${formatSequenceFailureTotals(operational.failureAnalytics)}`,
|
|
@@ -26373,12 +26526,37 @@ function sequenceHealthRow(sequence) {
|
|
|
26373
26526
|
return [
|
|
26374
26527
|
stringValue(sequence.name) ?? stringValue(sequence.slug) ?? "—",
|
|
26375
26528
|
stringValue(sequence.status) ?? "unknown",
|
|
26376
|
-
|
|
26529
|
+
operationalStateText(sequence, operational) ?? "unknown",
|
|
26377
26530
|
formatSequenceOpenWork(operational.openWork),
|
|
26378
26531
|
formatSequenceFailureTotals(operational.failureAnalytics),
|
|
26379
26532
|
formatSequenceReasons(operational),
|
|
26380
26533
|
];
|
|
26381
26534
|
}
|
|
26535
|
+
/**
|
|
26536
|
+
* The operational state, naming the lifecycle a person reads when the two
|
|
26537
|
+
* disagree. A campaign that contacted every lead keeps its persisted `active`,
|
|
26538
|
+
* and a blind user eval (2026-09-29) had to infer it was done from `drained`
|
|
26539
|
+
* plus zero open work. The web lists the same Sequence as Completed.
|
|
26540
|
+
*/
|
|
26541
|
+
function operationalStateText(sequence, operational) {
|
|
26542
|
+
const state = stringValue(operational.operationalState);
|
|
26543
|
+
if (!state)
|
|
26544
|
+
return null;
|
|
26545
|
+
// The server's own `lifecycle`; the shared helper covers a server that predates it.
|
|
26546
|
+
const lifecycle = stringValue(sequence.lifecycle) ?? sequenceDisplayStatus(stringValue(sequence.status) ?? "", state);
|
|
26547
|
+
return lifecycle === "completed" ? `${state} (completed)` : state;
|
|
26548
|
+
}
|
|
26549
|
+
/** A listed Sequence's launch warnings (e.g. two sending schedules), one line each. */
|
|
26550
|
+
function sequenceRowWarnings(sequences) {
|
|
26551
|
+
return sequences.flatMap((sequence) => {
|
|
26552
|
+
const label = stringValue(sequence.name) ?? stringValue(sequence.slug) ?? "Sequence";
|
|
26553
|
+
const warnings = Array.isArray(sequence.warnings) ? sequence.warnings.filter(isRecord) : [];
|
|
26554
|
+
return warnings.flatMap((warning) => {
|
|
26555
|
+
const message = stringValue(warning.message);
|
|
26556
|
+
return message ? [`Warning (${label}): ${message}`] : [];
|
|
26557
|
+
});
|
|
26558
|
+
});
|
|
26559
|
+
}
|
|
26382
26560
|
function sequenceInspectionLinks(sequences) {
|
|
26383
26561
|
return sequences.flatMap((sequence) => {
|
|
26384
26562
|
const exactLink = stringValue(sequence.web_url) ?? stringValue(sequence.deepLink);
|
|
@@ -26394,27 +26572,38 @@ function formatSequenceFleetSummary(value) {
|
|
|
26394
26572
|
// should not read a line of zeroes every time it lists its sequences.
|
|
26395
26573
|
const neverStarted = typeof fleet.neverStarted === "number" ? fleet.neverStarted : 0;
|
|
26396
26574
|
const parkedLeads = typeof fleet.neverStartedEnrollments === "number" ? fleet.neverStartedEnrollments : 0;
|
|
26575
|
+
const completed = recordValue(fleet.byLifecycle).completed;
|
|
26576
|
+
const completedText = typeof completed === "number" ? ` · ${numberText(completed)} completed` : "";
|
|
26397
26577
|
return [
|
|
26398
|
-
`Fleet: ${numberText(fleet.markedActive)} marked active · ${numberText(fleet.operationallyLive)} operationally live · ${numberText(fleet.total)} total`,
|
|
26578
|
+
`Fleet: ${numberText(fleet.markedActive)} marked active · ${numberText(fleet.operationallyLive)} operationally live${completedText} · ${numberText(fleet.total)} total`,
|
|
26399
26579
|
`States: ${SEQUENCE_OPERATIONAL_STATE_ORDER.map((state) => `${state} ${numberText(states[state])}`).join(" · ")}`,
|
|
26400
26580
|
...(neverStarted > 0
|
|
26401
26581
|
? [`Never started: ${numberText(neverStarted)} draft${neverStarted === 1 ? "" : "s"} holding ${numberText(parkedLeads)} enrolled lead${parkedLeads === 1 ? "" : "s"} — these send nothing until launched. List them with \`oxygen sequences list --operational-state never_started\`, then check one with \`oxygen sequences get <sequence> --readiness\` (read-only).`]
|
|
26402
26582
|
: []),
|
|
26403
26583
|
];
|
|
26404
26584
|
}
|
|
26585
|
+
/**
|
|
26586
|
+
* Spelled out because a blind user eval (2026-09-29) read `enr 352` as 352
|
|
26587
|
+
* enrolled when it meant 352 leads still in progress, and told the founder the
|
|
26588
|
+
* wrong count. Only the first two counts have a unit: ready, in flight, paced
|
|
26589
|
+
* and blocked add leads with no open action to open actions.
|
|
26590
|
+
*/
|
|
26405
26591
|
function formatSequenceOpenWork(value) {
|
|
26406
26592
|
const work = recordValue(value);
|
|
26407
26593
|
if (Object.keys(work).length === 0)
|
|
26408
26594
|
return "unknown";
|
|
26409
|
-
|
|
26410
|
-
|
|
26411
|
-
|
|
26412
|
-
|
|
26413
|
-
|
|
26414
|
-
|
|
26415
|
-
|
|
26416
|
-
|
|
26417
|
-
]
|
|
26595
|
+
const counts = [
|
|
26596
|
+
["leads in progress", work.enrollments],
|
|
26597
|
+
["open actions", work.actions],
|
|
26598
|
+
["ready now", work.actionableNow],
|
|
26599
|
+
["in flight", work.inFlight],
|
|
26600
|
+
["paced", work.paced],
|
|
26601
|
+
["blocked", work.blocked],
|
|
26602
|
+
["unresolved failures", work.unresolvedFailures],
|
|
26603
|
+
];
|
|
26604
|
+
if (counts.every(([, count]) => count === 0))
|
|
26605
|
+
return "none";
|
|
26606
|
+
return counts.map(([label, count]) => `${label} ${numberText(count)}`).join(" · ");
|
|
26418
26607
|
}
|
|
26419
26608
|
function formatSequenceFailureTotals(value) {
|
|
26420
26609
|
const analytics = recordValue(value);
|
|
@@ -28843,6 +29032,7 @@ table, options) {
|
|
|
28843
29032
|
: {}),
|
|
28844
29033
|
...(options.verifyPhone ? { verify_phone: true } : {}),
|
|
28845
29034
|
...(options.allowPremiumLanes ? { allow_premium_lanes: true } : {}),
|
|
29035
|
+
...(options.includePersonalEmail ? { include_personal_email: true } : {}),
|
|
28846
29036
|
...(readOption(options.phoneVerificationCredentialMode)
|
|
28847
29037
|
? { phone_verification_credential_mode: readOption(options.phoneVerificationCredentialMode) }
|
|
28848
29038
|
: {}),
|
|
@@ -28868,6 +29058,25 @@ function readLinkedinScheduleOptions(options) {
|
|
|
28868
29058
|
}
|
|
28869
29059
|
return { timezone, days: parseWorkingDaysOption(days), start, end };
|
|
28870
29060
|
}
|
|
29061
|
+
// Launch's one sending schedule and one unsubscribe switch. The server writes
|
|
29062
|
+
// `sending_schedule` to the email window and the LinkedIn/WhatsApp window, and
|
|
29063
|
+
// `include_unsubscribe` to the visible link and the one-click headers.
|
|
29064
|
+
function readUnifiedSequenceBody(options) {
|
|
29065
|
+
const timezone = readOption(options.scheduleTimezone);
|
|
29066
|
+
const days = readOption(options.scheduleDays);
|
|
29067
|
+
const start = readOption(options.scheduleHoursStart);
|
|
29068
|
+
const end = readOption(options.scheduleHoursEnd);
|
|
29069
|
+
const body = {};
|
|
29070
|
+
if (timezone || days || start || end) {
|
|
29071
|
+
if (!timezone || !days || !start || !end) {
|
|
29072
|
+
throw new OxygenError("invalid_sending_schedule", "Set the sending schedule with all four flags: --schedule-timezone, --schedule-days, --schedule-hours-start and --schedule-hours-end.", { exitCode: 1 });
|
|
29073
|
+
}
|
|
29074
|
+
body.sending_schedule = { timezone, days: parseWorkingDaysOption(days), start, end };
|
|
29075
|
+
}
|
|
29076
|
+
if (options.includeUnsubscribe !== undefined)
|
|
29077
|
+
body.include_unsubscribe = options.includeUnsubscribe;
|
|
29078
|
+
return body;
|
|
29079
|
+
}
|
|
28871
29080
|
/**
|
|
28872
29081
|
* A whole seat count. Unlike readPositiveNumber, ZERO IS VALID and meaningful:
|
|
28873
29082
|
* `--quantity 0` is how a customer gives back every seat of a channel, and
|
|
@@ -29123,8 +29332,6 @@ function readSequenceSettings(options) {
|
|
|
29123
29332
|
settings.bounce_protection = JSON.parse(options.bounceProtection);
|
|
29124
29333
|
if (options.includeUnsubscribeHeaders !== undefined)
|
|
29125
29334
|
settings.include_unsubscribe_headers = options.includeUnsubscribeHeaders;
|
|
29126
|
-
if (options.replyToStopText !== undefined)
|
|
29127
|
-
settings.reply_to_stop_text = options.replyToStopText;
|
|
29128
29335
|
if (options.includeUnsubscribeLink !== undefined) {
|
|
29129
29336
|
settings.include_unsubscribe_link = options.includeUnsubscribeLink;
|
|
29130
29337
|
}
|
|
@@ -29215,23 +29422,31 @@ process.stdout.on("error", (error) => {
|
|
|
29215
29422
|
maybeScheduleBackgroundUpdate();
|
|
29216
29423
|
const program = createProgram();
|
|
29217
29424
|
installCommanderExitOverride(program);
|
|
29425
|
+
const argv = withoutHelpForUnknownCommand(program, process.argv);
|
|
29218
29426
|
try {
|
|
29219
|
-
await program.parseAsync(
|
|
29427
|
+
await program.parseAsync(argv);
|
|
29220
29428
|
}
|
|
29221
29429
|
catch (error) {
|
|
29222
29430
|
if (error instanceof CommanderError) {
|
|
29223
|
-
const cliArgs =
|
|
29431
|
+
const cliArgs = argv.slice(2);
|
|
29432
|
+
// Every hint goes to stderr as it always has; the first, the most specific,
|
|
29433
|
+
// also becomes the --json envelope's details.next_step below.
|
|
29434
|
+
const hints = [];
|
|
29435
|
+
const writeHint = (hint) => {
|
|
29436
|
+
hints.push(hint);
|
|
29437
|
+
process.stderr.write(`${hint}\n`);
|
|
29438
|
+
};
|
|
29224
29439
|
if (error.code === "commander.unknownOption" &&
|
|
29225
29440
|
error.message.includes("unknown option '--json'") &&
|
|
29226
29441
|
cliArgs.slice(-2).join(" ") === "limits --json") {
|
|
29227
|
-
|
|
29442
|
+
writeHint(`Hint: --json belongs to the show subcommand. Run \`${program.name()} limits show --json\`.`);
|
|
29228
29443
|
}
|
|
29229
29444
|
// `error: unknown command 'get'` names what is wrong and nothing to try
|
|
29230
29445
|
// next; commander's edit-distance suggester cannot reach a synonym.
|
|
29231
29446
|
if (error.code === "commander.unknownCommand") {
|
|
29232
29447
|
const hint = unknownCommandHint(program, cliArgs);
|
|
29233
29448
|
if (hint)
|
|
29234
|
-
|
|
29449
|
+
writeHint(hint);
|
|
29235
29450
|
}
|
|
29236
29451
|
// `error: unknown option '--filter'` on `tables query` got no suggestion
|
|
29237
29452
|
// either (blind user eval, 2026-09-18): commander caps its suggester at
|
|
@@ -29239,7 +29454,7 @@ catch (error) {
|
|
|
29239
29454
|
if (error.code === "commander.unknownOption") {
|
|
29240
29455
|
const hint = unknownOptionHint(program, cliArgs, error.message);
|
|
29241
29456
|
if (hint)
|
|
29242
|
-
|
|
29457
|
+
writeHint(hint);
|
|
29243
29458
|
}
|
|
29244
29459
|
if (error.code === "commander.excessArguments" &&
|
|
29245
29460
|
cliArgs[0] === "skills" &&
|
|
@@ -29252,11 +29467,17 @@ catch (error) {
|
|
|
29252
29467
|
const skillArgument = /^[a-z0-9][a-z0-9_-]*$/i.test(requestedSkill)
|
|
29253
29468
|
? requestedSkill
|
|
29254
29469
|
: "<name>";
|
|
29255
|
-
|
|
29256
|
-
? `Hint: --project takes no value; it installs into the current directory. Did you mean \`${program.name()} skills install --project
|
|
29257
|
-
: `Hint: skill names use the --skill option. Did you mean \`${program.name()} skills install --skill ${skillArgument}
|
|
29258
|
-
}
|
|
29259
|
-
|
|
29470
|
+
writeHint(looksLikePath
|
|
29471
|
+
? `Hint: --project takes no value; it installs into the current directory. Did you mean \`${program.name()} skills install --project\`?`
|
|
29472
|
+
: `Hint: skill names use the --skill option. Did you mean \`${program.name()} skills install --skill ${skillArgument}\`?`);
|
|
29473
|
+
}
|
|
29474
|
+
// Commander refused before any action ran, so no handler printed the --json
|
|
29475
|
+
// envelope; agents parse stdout and branch on the exit code, and the exit
|
|
29476
|
+
// table classes a usage error as 2 in every mode.
|
|
29477
|
+
const usage = commanderUsageFailure(program, cliArgs, error, hints[0] ?? null);
|
|
29478
|
+
if (usage && argvRequestsJson(cliArgs))
|
|
29479
|
+
writeJson(usage.envelope);
|
|
29480
|
+
process.exitCode = usage?.exitCode ?? error.exitCode;
|
|
29260
29481
|
}
|
|
29261
29482
|
else {
|
|
29262
29483
|
throw error;
|