@oxygen-agent/cli 1.1011.98 → 1.1011.322
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/connectors-commands.d.ts +11 -0
- package/dist/connectors-commands.js +63 -0
- package/dist/help.d.ts +27 -1
- package/dist/help.js +112 -29
- package/dist/http-client.js +8 -0
- package/dist/index.js +362 -130
- 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 +131 -1
- package/node_modules/@oxygen/shared/dist/capability-discovery.js +325 -30
- 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/connectors.d.ts +23 -0
- package/node_modules/@oxygen/shared/dist/contracts/connectors.js +34 -0
- package/node_modules/@oxygen/shared/dist/contracts/operations.d.ts +0 -6
- package/node_modules/@oxygen/shared/dist/contracts/operations.js +8 -1
- 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/feature-gates.d.ts +5 -0
- package/node_modules/@oxygen/shared/dist/feature-gates.js +6 -0
- package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.d.ts +57 -8
- package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.js +74 -13
- 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/mcp-event-contract.d.ts +597 -0
- package/node_modules/@oxygen/shared/dist/mcp-event-contract.js +713 -0
- package/node_modules/@oxygen/shared/dist/mcp-events-enabled.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/mcp-events-enabled.js +14 -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/select-options.d.ts +6 -0
- package/node_modules/@oxygen/shared/dist/select-options.js +6 -1
- 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
|
@@ -8,12 +8,13 @@ import { stdin as input, stdout as output } from "node:process";
|
|
|
8
8
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
9
9
|
import { Command, CommanderError, Option } from "commander";
|
|
10
10
|
import { registerUgcCommands } from "./ugc-commands.js";
|
|
11
|
+
import { registerConnectorsCommands } from "./connectors-commands.js";
|
|
11
12
|
import { registerKnowledgeRepositoryCommands } from "./knowledge-repository-commands.js";
|
|
12
13
|
import { registerVisualCommands } from "./visual-commands.js";
|
|
13
14
|
import { renderPrimaryProviderBoard } from "./admin-primary-providers-render.js";
|
|
14
15
|
import { registerFunctionsCommands } from "./functions-commands.js";
|
|
15
16
|
import { STREAMED_FILE_IMPORT_MIN_BYTES, putImportFileStream, rowsForStreamedImportTarget, scanStreamedImportFile, shouldStreamFileImport, } from "./streamed-file-import.js";
|
|
16
|
-
import { applyOxygenHelp, enableCommandSuggestions, unknownCommandHint, unknownOptionHint } from "./help.js";
|
|
17
|
+
import { applyOxygenHelp, argvRequestsJson, commanderUsageFailure, enableCommandSuggestions, unknownCommandHint, unknownOptionHint, withoutHelpForUnknownCommand, } from "./help.js";
|
|
17
18
|
import { buildCommandManifest, getCommandManifestEntry, searchCommandManifest, suggestCommandNames, } from "./command-manifest.js";
|
|
18
19
|
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
20
|
import { PURCHASABLE_PLAN_KEYS, planVolumeBonusPercent, resolveBasePricingPlan } from "@oxygen/shared/billing";
|
|
@@ -21,6 +22,7 @@ import { TAG_COLORS } from "@oxygen/shared/select-options";
|
|
|
21
22
|
import { PUBLIC_WEBHOOK_INGRESS_PER_TARGET_PER_MINUTE } from "@oxygen/shared/plan-band";
|
|
22
23
|
import { AI_COLUMN_CREDITS, AI_COLUMN_WEB_SEARCH_TYPICAL_CREDITS } from "@oxygen/shared/pricing-sheet";
|
|
23
24
|
import { LINKEDIN_SENDER_LIMIT_DEFAULTS, LINKEDIN_SENDER_LIMIT_MAXIMUMS, } from "@oxygen/shared/linkedin-sequences";
|
|
25
|
+
import { SEQUENCE_LIFECYCLES, sequenceDisplayStatus } from "@oxygen/shared/sequence-lifecycle";
|
|
24
26
|
import { operationPath } from "@oxygen/shared/contracts/operations";
|
|
25
27
|
import { SENDER_LIMITS_SET_OPERATION } from "@oxygen/shared/contracts/sender-limits";
|
|
26
28
|
import { addContractFlags } from "./contract-flags.js";
|
|
@@ -38,7 +40,7 @@ import { ensureFreshCliForApiUrl, requestOxygen } from "./http-client.js";
|
|
|
38
40
|
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
41
|
import { waitForCliRun } from "./run-wait.js";
|
|
40
42
|
import { assertModeFlagsExclusive, parseKeyValuePairs, parseJsonObject, readJsonObjectOption, readNonNegativeInt, readPositiveInt, readRecordString, resolveLiveDryRunMode, } from "./cli-values.js";
|
|
41
|
-
import { formatAiPromptPreviewNotice, formatColumnReferenceNotices, } from "./column-run-notices.js";
|
|
43
|
+
import { formatAiPromptPreviewNotice, formatColumnReferenceNotices, formatEnrichmentInputMappingNotice, formatProviderLaneNotices, } from "./column-run-notices.js";
|
|
42
44
|
import { runLocalCustomHttpColumn } from "./local-custom-http-column.js";
|
|
43
45
|
import { formatSearchAiFilterTotalsNotice } from "./search-ai-filter-notice.js";
|
|
44
46
|
import { formatInboxNeedsReplyNotice } from "./inbox-needs-reply-notice.js";
|
|
@@ -867,6 +869,13 @@ function writeManagedProviderAvailabilityNotice(data) {
|
|
|
867
869
|
if (!payload)
|
|
868
870
|
return;
|
|
869
871
|
const availability = asPayloadRecord(payload.provider_availability);
|
|
872
|
+
// `columns run --dry-run` and `enrich-column preview` name their lanes: one
|
|
873
|
+
// line per lane that cannot run, led by the verdict (OXP-1.14.12).
|
|
874
|
+
if (availability && Array.isArray(availability.lanes)) {
|
|
875
|
+
for (const line of formatProviderLaneNotices(availability))
|
|
876
|
+
process.stderr.write(`${line}\n`);
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
870
879
|
if (availability) {
|
|
871
880
|
const providers = asPayloadRecord(availability.providers) ?? {};
|
|
872
881
|
for (const [provider, entry] of Object.entries(providers)) {
|
|
@@ -1159,7 +1168,7 @@ function writeAiPromptPreviewNotice(data) {
|
|
|
1159
1168
|
process.stderr.write(`${notice}\n`);
|
|
1160
1169
|
}
|
|
1161
1170
|
function writeColumnReferenceNotices(data) {
|
|
1162
|
-
for (const notice of formatColumnReferenceNotices(data)) {
|
|
1171
|
+
for (const notice of [...formatColumnReferenceNotices(data), ...formatEnrichmentInputMappingNotice(data)]) {
|
|
1163
1172
|
process.stderr.write(`${notice}\n`);
|
|
1164
1173
|
}
|
|
1165
1174
|
}
|
|
@@ -2360,10 +2369,32 @@ function buildPublishingPostCreateBody(options) {
|
|
|
2360
2369
|
body.ugc_participation_id = ugcParticipationId;
|
|
2361
2370
|
}
|
|
2362
2371
|
const xContent = buildPublishingXContent(options, { forMerge: false });
|
|
2363
|
-
|
|
2364
|
-
|
|
2372
|
+
const firstComment = readPublishingFirstComment(options);
|
|
2373
|
+
if (content || xContent || firstComment) {
|
|
2374
|
+
body.content = { ...(content ?? {}), ...(xContent ?? {}), ...(firstComment ? { first_comment: firstComment } : {}) };
|
|
2375
|
+
}
|
|
2365
2376
|
return body;
|
|
2366
2377
|
}
|
|
2378
|
+
/**
|
|
2379
|
+
* `--first-comment` / `--clear-first-comment`: the comment to set, null to remove
|
|
2380
|
+
* it (update only), or undefined when neither flag was passed. The API refuses a
|
|
2381
|
+
* first comment on a provider that cannot post one (publishing_first_comment_unsupported),
|
|
2382
|
+
* judging the post's stored provider too, so the CLI does not keep its own list.
|
|
2383
|
+
*/
|
|
2384
|
+
function readPublishingFirstComment(options) {
|
|
2385
|
+
if (options.firstComment !== undefined && options.clearFirstComment) {
|
|
2386
|
+
throw new OxygenError("invalid_request", "Choose --first-comment or --clear-first-comment, not both.", { exitCode: 2 });
|
|
2387
|
+
}
|
|
2388
|
+
if (options.clearFirstComment)
|
|
2389
|
+
return null;
|
|
2390
|
+
if (options.firstComment === undefined)
|
|
2391
|
+
return undefined;
|
|
2392
|
+
const text = options.firstComment.trim();
|
|
2393
|
+
if (!text) {
|
|
2394
|
+
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 });
|
|
2395
|
+
}
|
|
2396
|
+
return text;
|
|
2397
|
+
}
|
|
2367
2398
|
/**
|
|
2368
2399
|
* The X flags as content keys (see the API's X content contract). On update they
|
|
2369
2400
|
* become a `content_merge` so the rest of the stored content is kept; there
|
|
@@ -2471,8 +2502,17 @@ function buildPublishingPostUpdateBody(options) {
|
|
|
2471
2502
|
body.content = content;
|
|
2472
2503
|
const merge = readOption(options.contentMergeJson);
|
|
2473
2504
|
const xContent = buildPublishingXContent(options, { forMerge: true });
|
|
2474
|
-
|
|
2475
|
-
|
|
2505
|
+
// A merge key, so the post's media and other content survive the change.
|
|
2506
|
+
const firstComment = readPublishingFirstComment(options);
|
|
2507
|
+
if (merge || xContent || firstComment !== undefined) {
|
|
2508
|
+
body.content_merge = {
|
|
2509
|
+
...(merge ? parseJsonObject(merge) : {}),
|
|
2510
|
+
...(xContent ?? {}),
|
|
2511
|
+
// Clearing also nulls the legacy camelCase key the worker still falls back to.
|
|
2512
|
+
...(firstComment === null ? { first_comment: null, firstComment: null } : {}),
|
|
2513
|
+
...(typeof firstComment === "string" ? { first_comment: firstComment } : {}),
|
|
2514
|
+
};
|
|
2515
|
+
}
|
|
2476
2516
|
if (options.confirmNotPublished)
|
|
2477
2517
|
body.confirm_not_published = true;
|
|
2478
2518
|
return body;
|
|
@@ -3826,9 +3866,10 @@ export function createProgram() {
|
|
|
3826
3866
|
}));
|
|
3827
3867
|
program
|
|
3828
3868
|
.command("status")
|
|
3829
|
-
.description("Compare the local Oxygen CLI version against the active profile's deployed Oxygen API. "
|
|
3869
|
+
.description("Compare the local Oxygen CLI version against the active profile's deployed Oxygen API. Read-only, 0 credits. "
|
|
3830
3870
|
+ "`compatible` is the field that matters: false means commands are refused until the CLI updates. "
|
|
3831
|
-
+ "`in_sync` and `skew`
|
|
3871
|
+
+ "`in_sync` and `skew` compare reported versions. A CLI may be ahead during a rollout; "
|
|
3872
|
+
+ "if the difference persists, share the status SHA with support.")
|
|
3832
3873
|
.option("--json", "Print a JSON envelope.")
|
|
3833
3874
|
.action(async (options) => {
|
|
3834
3875
|
await handleAsyncAction("status", options, async () => {
|
|
@@ -3878,14 +3919,14 @@ export function createProgram() {
|
|
|
3878
3919
|
await handleOrgsListAction(options);
|
|
3879
3920
|
}))
|
|
3880
3921
|
.addCommand(new Command("use")
|
|
3881
|
-
.description("Select the active organization for this CLI profile.")
|
|
3922
|
+
.description("Select the active organization for this CLI profile. Apps connected with OAuth (ChatGPT, Claude…) keep their own default workspace: `oxygen connectors`.")
|
|
3882
3923
|
.argument("<organization>", "Organization id, Clerk org id, or slug returned by orgs list.")
|
|
3883
3924
|
.option("--json", "Print a JSON envelope.")
|
|
3884
3925
|
.action(async (organization, options) => {
|
|
3885
3926
|
await handleOrgUseAction(organization, options, "orgs use");
|
|
3886
3927
|
}))
|
|
3887
3928
|
.addCommand(new Command("select")
|
|
3888
|
-
.description("Alias for orgs use.")
|
|
3929
|
+
.description("Alias for orgs use: this CLI profile only. For ChatGPT, Claude or another connected app, use `oxygen connectors set`.")
|
|
3889
3930
|
.argument("<organization>", "Organization id, Clerk org id, or slug returned by orgs list.")
|
|
3890
3931
|
.option("--json", "Print a JSON envelope.")
|
|
3891
3932
|
.action(async (organization, options) => {
|
|
@@ -4364,7 +4405,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
4364
4405
|
.command("db")
|
|
4365
4406
|
.description("Tenant database commands.")
|
|
4366
4407
|
.addCommand(new Command("status")
|
|
4367
|
-
.description("
|
|
4408
|
+
.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
4409
|
.option("--json", "Print a JSON envelope.")
|
|
4369
4410
|
.action(async (options) => {
|
|
4370
4411
|
await handleAsyncAction("db status", options, () => requestOxygen("/api/cli/db/status"));
|
|
@@ -4499,7 +4540,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
4499
4540
|
});
|
|
4500
4541
|
}))
|
|
4501
4542
|
.addCommand(new Command("cost-policy")
|
|
4502
|
-
.description("Show the
|
|
4543
|
+
.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
4544
|
.option("--json", "Print a JSON envelope.")
|
|
4504
4545
|
.action(async (options) => {
|
|
4505
4546
|
await handleAsyncAction("db cost-policy", options, () => requestOxygen("/api/cli/db/cost-policy"));
|
|
@@ -4714,7 +4755,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
4714
4755
|
}))
|
|
4715
4756
|
.addCommand(new Command("create")
|
|
4716
4757
|
.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>", "
|
|
4758
|
+
.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
4759
|
.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
4760
|
.option("--channel <channel>", "Publishing channel override. Defaults to provider.")
|
|
4720
4761
|
.option("--sender <sender_account_id>", "LinkedIn sender account id. Required for LinkedIn before the worker can publish.")
|
|
@@ -4723,8 +4764,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
4723
4764
|
.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
4765
|
.option("--text-file <path>", "Read post text from a local file.")
|
|
4725
4766
|
.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.")
|
|
4767
|
+
.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
4768
|
.option("--composio-action <slug>", "Override the Composio action slug for this scheduled post.")
|
|
4727
|
-
.option("--timezone <tz>", "
|
|
4769
|
+
.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
4770
|
.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
4771
|
.option("--draft", "Create as a draft instead of scheduled.")
|
|
4730
4772
|
.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 +4788,14 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
4746
4788
|
}));
|
|
4747
4789
|
}))
|
|
4748
4790
|
.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.")
|
|
4791
|
+
.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
4792
|
.argument("<post_id>", "Scheduled post id.")
|
|
4751
4793
|
.option("--json", "Print a JSON envelope.")
|
|
4752
4794
|
.action(async (postId, options) => {
|
|
4753
4795
|
await handleAsyncAction("publishing posts get", options, () => requestOxygen(`/api/cli/publishing/posts/${encodeURIComponent(postId)}`));
|
|
4754
4796
|
}))
|
|
4755
4797
|
.addCommand(new Command("update")
|
|
4756
|
-
.description("Update an editable draft, scheduled, or failed post.")
|
|
4798
|
+
.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
4799
|
.argument("<post_id>", "Scheduled post id.")
|
|
4758
4800
|
.option("--provider <provider>", "Publishing provider: linkedin, x, instagram, tiktok, facebook, or youtube.")
|
|
4759
4801
|
.option("--channel <channel>", "Publishing channel override.")
|
|
@@ -4763,9 +4805,11 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
4763
4805
|
.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
4806
|
.option("--text-file <path>", "Read post text from a local file.")
|
|
4765
4807
|
.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.")
|
|
4808
|
+
.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.")
|
|
4809
|
+
.option("--clear-first-comment", "Remove the first comment. Revokes approval on an approved post.")
|
|
4766
4810
|
.option("--composio-action <slug>", "Override the Composio action slug for this scheduled post.")
|
|
4767
|
-
.option("--publish-at <iso>", "
|
|
4768
|
-
.option("--timezone <tz>", "
|
|
4811
|
+
.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.")
|
|
4812
|
+
.option("--timezone <tz>", "IANA zone the post is shown in and a --publish-at without an offset is read in. Unchanged when omitted.")
|
|
4769
4813
|
.option("--status <status>", "draft or scheduled.")
|
|
4770
4814
|
.option("--thread-post <text>", "X: text of the next post in a thread (posts 2, 3, ... in order). Repeatable.", collectRepeatable, [])
|
|
4771
4815
|
.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 +4994,11 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
4950
4994
|
.description("Accept a draft variant into the publish queue. The post is created needs-approval — approve it separately before it can publish.")
|
|
4951
4995
|
.argument("<draft_id>", "Post draft id.")
|
|
4952
4996
|
.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).")
|
|
4997
|
+
.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
4998
|
.option("--channel <channel>", "Channel override. Defaults to the draft's first target channel.")
|
|
4955
4999
|
.option("--sender <sender_account_id>", "LinkedIn sender account id.")
|
|
4956
5000
|
.option("--provider-connection <connection_id>", "Oxygen integration connection id for Composio-backed providers.")
|
|
4957
|
-
.option("--timezone <tz>", "
|
|
5001
|
+
.option("--timezone <tz>", "IANA zone the post is shown in and a --publish-at without an offset is read in. Defaults to UTC.")
|
|
4958
5002
|
.option("--json", "Print a JSON envelope.")
|
|
4959
5003
|
.action(async (draftId, options) => {
|
|
4960
5004
|
await handleAsyncAction("publishing drafts accept", options, () => requestOxygen(`/api/cli/publishing/drafts/${encodeURIComponent(draftId)}/accept`, {
|
|
@@ -5223,7 +5267,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
5223
5267
|
.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
5268
|
.addCommand(new Command("import")
|
|
5225
5269
|
.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: [...] }).")
|
|
5270
|
+
.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
5271
|
.option("--dry-run", "Parse, lint, and report without writing. Default.")
|
|
5228
5272
|
.option("--approved", "Write the parsed rows into the queue as needs-approval drafts.")
|
|
5229
5273
|
.option("--tags <tags>", "Comma-separated workspace tags for every imported post.")
|
|
@@ -5392,9 +5436,10 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
5392
5436
|
}))
|
|
5393
5437
|
.addCommand(new Command("list")
|
|
5394
5438
|
.description("List the dashboards saved in this workspace.")
|
|
5439
|
+
.option("--tag <tag>", "Only dashboards carrying this workspace tag.")
|
|
5395
5440
|
.option("--json", "Print a JSON envelope.")
|
|
5396
5441
|
.action(async (options) => {
|
|
5397
|
-
await handleAsyncAction("dashboards list", options, () => requestOxygen(
|
|
5442
|
+
await handleAsyncAction("dashboards list", options, () => requestOxygen(`/api/cli/dashboards${options.tag ? `?tag=${encodeURIComponent(options.tag)}` : ""}`));
|
|
5398
5443
|
}))
|
|
5399
5444
|
.addCommand(new Command("create")
|
|
5400
5445
|
.description("Create a saved dashboard. It starts with one empty tab; add widgets to it with `dashboards widgets add`.")
|
|
@@ -6402,7 +6447,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
6402
6447
|
})));
|
|
6403
6448
|
program
|
|
6404
6449
|
.command("signals")
|
|
6405
|
-
.description("The Signals primitive:
|
|
6450
|
+
.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
6451
|
.addCommand(new Command("list")
|
|
6407
6452
|
.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
6453
|
.option("--types <types>", "Comma-separated signal types to include. Defaults to all four.")
|
|
@@ -6413,10 +6458,11 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
6413
6458
|
await handleAsyncAction("signals list", options, () => requestOxygen(buildSignalsListPath(options)));
|
|
6414
6459
|
}))
|
|
6415
6460
|
.addCommand(new Command("registry")
|
|
6416
|
-
.description("Show
|
|
6461
|
+
.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.")
|
|
6462
|
+
.option("--tag <tag>", "Only signal types carrying this workspace tag; use tags attach to set it.")
|
|
6417
6463
|
.option("--json", "Print a JSON envelope.")
|
|
6418
6464
|
.action(async (options) => {
|
|
6419
|
-
await handleAsyncAction("signals registry", options, () => requestOxygen(
|
|
6465
|
+
await handleAsyncAction("signals registry", options, () => requestOxygen(`/api/cli/signals/registry${options.tag ? `?tag=${encodeURIComponent(options.tag)}` : ""}`));
|
|
6420
6466
|
}))
|
|
6421
6467
|
.addCommand(new Command("record")
|
|
6422
6468
|
.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 +8583,11 @@ Examples:
|
|
|
8537
8583
|
}))
|
|
8538
8584
|
.addCommand(knowledgeBootstrapCommand)
|
|
8539
8585
|
.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")
|
|
8586
|
+
.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.")
|
|
8587
|
+
.option("--kind <kind>", "What to synthesize: campaign_learnings (default) or voice (your writing voice from sent LinkedIn and WhatsApp messages).", "campaign_learnings")
|
|
8542
8588
|
.option("--sequence <sequence_id>", "Scope campaign learnings to one sequence's learnings page. Omit for the org-wide [[outreach-learnings]] page.")
|
|
8543
8589
|
.option("--approved", "Write the synthesized result (learnings page revision / pinned voice guide) instead of previewing.")
|
|
8544
|
-
.option("--body <markdown>", "With --approved: save this
|
|
8590
|
+
.option("--body <markdown>", "With --approved: save this body verbatim (the guide a preview showed, or your edit) with no new AI call.")
|
|
8545
8591
|
.option("--title <title>", "Optional title for a newly created page/asset.")
|
|
8546
8592
|
.option("--limit <n>", "Voice only: how many recent sent messages to learn from (max 200).")
|
|
8547
8593
|
.option("--json", "Print a JSON envelope.")
|
|
@@ -8624,9 +8670,66 @@ Examples:
|
|
|
8624
8670
|
.action(async (options) => {
|
|
8625
8671
|
await handleAsyncAction("knowledge purge", options, () => runKnowledgeMirrorPurge());
|
|
8626
8672
|
}));
|
|
8673
|
+
program
|
|
8674
|
+
.command("ads")
|
|
8675
|
+
.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.")
|
|
8676
|
+
.addCommand(new Command("searches")
|
|
8677
|
+
.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.")
|
|
8678
|
+
.addCommand(new Command("list")
|
|
8679
|
+
.description("List saved Ads search definitions, optionally filtered by tag.")
|
|
8680
|
+
.option("--tag <label>", "Only saved searches carrying this tag.")
|
|
8681
|
+
.option("--json", "Print a JSON envelope.")
|
|
8682
|
+
.action(async (options) => {
|
|
8683
|
+
await handleAsyncAction("ads searches list", options, () => requestOxygen(`/api/cli/ads/searches${options.tag ? `?tag=${encodeURIComponent(options.tag)}` : ""}`));
|
|
8684
|
+
}))
|
|
8685
|
+
.addCommand(new Command("get")
|
|
8686
|
+
.description("Read one saved Ads search definition without running it.")
|
|
8687
|
+
.argument("<id>")
|
|
8688
|
+
.option("--json", "Print a JSON envelope.")
|
|
8689
|
+
.action(async (id, options) => {
|
|
8690
|
+
await handleAsyncAction("ads searches get", options, () => requestOxygen(`/api/cli/ads/searches/${encodeURIComponent(id)}`));
|
|
8691
|
+
}))
|
|
8692
|
+
.addCommand(new Command("save")
|
|
8693
|
+
.alias("create")
|
|
8694
|
+
.description("Save a named Ads search query for 0 credits; no provider call.")
|
|
8695
|
+
.requiredOption("--name <name>", "Name for the saved search.")
|
|
8696
|
+
.requiredOption("--platform <platform>", "Domain: facebook_ads, google_ads, linkedin_ads (use --company-domain). Keyword: facebook_ad_search, linkedin_keyword_search, tiktok_search (use --keyword).")
|
|
8697
|
+
.option("--company-domain <domain>", "Required for facebook_ads, google_ads, and linkedin_ads; e.g. acme.com.")
|
|
8698
|
+
.option("--keyword <keyword>", "Required for facebook_ad_search, linkedin_keyword_search, and tiktok_search.")
|
|
8699
|
+
.option("--country-code <code>", "Optional two-letter country code, e.g. US.")
|
|
8700
|
+
.option("--tags <labels>", "Comma-separated workspace tags.")
|
|
8701
|
+
.option("--json", "Print a JSON envelope.")
|
|
8702
|
+
.action(async (options) => {
|
|
8703
|
+
await handleAsyncAction("ads searches save", options, () => requestOxygen("/api/cli/ads/searches", { method: "POST", body: {
|
|
8704
|
+
name: options.name, platform: options.platform, company_domain: options.companyDomain,
|
|
8705
|
+
keyword: options.keyword, country_code: options.countryCode, tags: splitCommaList(options.tags),
|
|
8706
|
+
} }));
|
|
8707
|
+
}))
|
|
8708
|
+
.addCommand(new Command("update")
|
|
8709
|
+
.description("Replace a saved search's name or query; keep its tags. No provider call.")
|
|
8710
|
+
.argument("<id>")
|
|
8711
|
+
.requiredOption("--name <name>", "Name for the saved search.")
|
|
8712
|
+
.requiredOption("--platform <platform>", "Domain: facebook_ads, google_ads, linkedin_ads (use --company-domain). Keyword: facebook_ad_search, linkedin_keyword_search, tiktok_search (use --keyword).")
|
|
8713
|
+
.option("--company-domain <domain>", "Required for facebook_ads, google_ads, and linkedin_ads; e.g. acme.com.")
|
|
8714
|
+
.option("--keyword <keyword>", "Required for facebook_ad_search, linkedin_keyword_search, and tiktok_search.")
|
|
8715
|
+
.option("--country-code <code>", "Optional two-letter country code, e.g. US.")
|
|
8716
|
+
.option("--json", "Print a JSON envelope.")
|
|
8717
|
+
.action(async (id, options) => {
|
|
8718
|
+
await handleAsyncAction("ads searches update", options, () => requestOxygen(`/api/cli/ads/searches/${encodeURIComponent(id)}`, { method: "PATCH", body: {
|
|
8719
|
+
name: options.name, platform: options.platform, company_domain: options.companyDomain,
|
|
8720
|
+
keyword: options.keyword, country_code: options.countryCode,
|
|
8721
|
+
} }));
|
|
8722
|
+
}))
|
|
8723
|
+
.addCommand(new Command("delete")
|
|
8724
|
+
.description("Archive a saved search; hide it from lists and tag footprints.")
|
|
8725
|
+
.argument("<id>")
|
|
8726
|
+
.option("--json", "Print a JSON envelope.")
|
|
8727
|
+
.action(async (id, options) => {
|
|
8728
|
+
await handleAsyncAction("ads searches delete", options, () => requestOxygen(`/api/cli/ads/searches/${encodeURIComponent(id)}`, { method: "DELETE" }));
|
|
8729
|
+
})));
|
|
8627
8730
|
program
|
|
8628
8731
|
.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
|
|
8732
|
+
.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
8733
|
.addCommand(new Command("list")
|
|
8631
8734
|
.description("List every workspace tag with per-primitive counts, most-used first.")
|
|
8632
8735
|
.option("--json", "Print a JSON envelope.")
|
|
@@ -8634,7 +8737,7 @@ Examples:
|
|
|
8634
8737
|
await handleAsyncAction("tags list", options, () => requestOxygen("/api/cli/tags"));
|
|
8635
8738
|
}))
|
|
8636
8739
|
.addCommand(new Command("get")
|
|
8637
|
-
.description(`Everything carrying one tag — ${TAG_KINDS_PROSE} — each with its deep-link.`)
|
|
8740
|
+
.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
8741
|
.argument("<tag>", "The tag (case-insensitive).")
|
|
8639
8742
|
.option("--json", "Print a JSON envelope.")
|
|
8640
8743
|
.action(async (tag, options) => {
|
|
@@ -8710,6 +8813,24 @@ Examples:
|
|
|
8710
8813
|
method: "POST",
|
|
8711
8814
|
body: { action: "delete", tags, ...(options.apply ? { apply: true } : {}) },
|
|
8712
8815
|
}));
|
|
8816
|
+
}))
|
|
8817
|
+
.addCommand(new Command("attach")
|
|
8818
|
+
.description("Replace tags on a Signal type, saved Dashboard, runtime Agent, or saved Ads search. [] clears the item; no provider call is made.")
|
|
8819
|
+
.argument("<kind>", "signal_type, dashboard, agent, or ad_search_collection")
|
|
8820
|
+
.argument("<ref>", "Signal type key, dashboard id/slug, Agent slug, or saved Ads search id")
|
|
8821
|
+
.requiredOption("--tags <labels>", "Comma-separated complete tag list; an empty string clears it.")
|
|
8822
|
+
.option("--json", "Print a JSON envelope.")
|
|
8823
|
+
.action(async (kind, ref, options) => {
|
|
8824
|
+
const body = { tags: splitCommaList(options.tags) };
|
|
8825
|
+
await handleAsyncAction("tags attach", options, () => {
|
|
8826
|
+
switch (kind) {
|
|
8827
|
+
case "signal_type": return requestOxygen(`/api/cli/signals/registry/${encodeURIComponent(ref)}/tags`, { method: "POST", body });
|
|
8828
|
+
case "dashboard": return requestOxygen(`/api/cli/dashboards/${encodeURIComponent(ref)}/tags`, { method: "POST", body });
|
|
8829
|
+
case "agent": return requestOxygen(`/api/cli/agent/${encodeURIComponent(ref)}/tags`, { method: "POST", body });
|
|
8830
|
+
case "ad_search_collection": return requestOxygen(`/api/cli/ads/searches/${encodeURIComponent(ref)}/tags`, { method: "POST", body });
|
|
8831
|
+
default: throw new Error("kind must be signal_type, dashboard, agent, or ad_search_collection");
|
|
8832
|
+
}
|
|
8833
|
+
});
|
|
8713
8834
|
}));
|
|
8714
8835
|
program
|
|
8715
8836
|
.command("comments")
|
|
@@ -9391,17 +9512,18 @@ Examples:
|
|
|
9391
9512
|
.command("columns")
|
|
9392
9513
|
.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
9514
|
.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.")
|
|
9515
|
+
.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
9516
|
.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.")
|
|
9517
|
+
.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
9518
|
.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
9519
|
// No preset takes a field list any more (founder, 2026-09-24). Hidden,
|
|
9399
9520
|
// not removed: an old script passing them gets the server's refusal,
|
|
9400
|
-
// which names
|
|
9521
|
+
// which names the funding, revenue, hiring and tech-stack presets,
|
|
9401
9522
|
// instead of an unknown-option error that explains nothing.
|
|
9402
9523
|
.addOption(new Option("--preset-fields <fields>", "Retired: presets take no field list.").hideHelp())
|
|
9403
9524
|
.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,
|
|
9525
|
+
.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>`.")
|
|
9526
|
+
.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
9527
|
.option("--label <label>", "Display label for the new column. Required unless --prompt-key or --capability supplies a default title.")
|
|
9406
9528
|
.option("--key <key>", "Optional stable column key. Defaults to a normalized label.")
|
|
9407
9529
|
.option("--data-type <type>", "Column data type: text, numeric, boolean, jsonb, or timestamptz.")
|
|
@@ -9429,16 +9551,11 @@ Examples:
|
|
|
9429
9551
|
.option("--structured-output", "Undo --no-structured-output: let the prompt's named sections shape the answer again.")
|
|
9430
9552
|
.option("--output-schema-json <json>", "AI column output JSON schema as inline JSON.")
|
|
9431
9553
|
.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())
|
|
9554
|
+
.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`."))
|
|
9555
|
+
.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}]}'."))
|
|
9556
|
+
.addOption(new Option("--score <levels>", "Classification column: rate each row on these comma-separated levels, lowest first (2-10), answering --prompt."))
|
|
9557
|
+
.addOption(new Option("--min-confidence <0-1>", "Classification columns: below this the answer is marked uncertain. Default 0.85 (0.7 for --yes-no)."))
|
|
9558
|
+
.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
9559
|
.option("--bind-object <slug>", "Bind column CRM object slug (companies, people, deals, ...). Sets kind=bind and resolves rows to CRM records by identity.")
|
|
9443
9560
|
.option("--bind-map <pairs>", "Bind identity mapping as identity=column pairs, e.g. domain=website,linkedin_url=li.")
|
|
9444
9561
|
.option("--bind-create", "Bind columns: assert a new CRM record for unmatched rows (onNoMatch=create; a truth write, needs --approved on run).")
|
|
@@ -9564,6 +9681,8 @@ Examples:
|
|
|
9564
9681
|
const isResearch = requestedKind === "research";
|
|
9565
9682
|
if (capability)
|
|
9566
9683
|
column.capability = capability;
|
|
9684
|
+
if (options.includePersonalEmail)
|
|
9685
|
+
column.include_personal_email = true;
|
|
9567
9686
|
// An enrichment cell is always the provider envelope, so the server
|
|
9568
9687
|
// rejects any other data type ("Enrichment columns must use jsonb
|
|
9569
9688
|
// data type."). Default it, the way --bind-object already does,
|
|
@@ -10084,7 +10203,7 @@ Examples:
|
|
|
10084
10203
|
});
|
|
10085
10204
|
}))
|
|
10086
10205
|
.addCommand(new Command("catalog")
|
|
10087
|
-
.description("List
|
|
10206
|
+
.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
10207
|
.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
10208
|
.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
10209
|
.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 +10865,7 @@ Examples:
|
|
|
10746
10865
|
.description("Inspect missing company fields, provider routing, and credit estimates without provider calls.")
|
|
10747
10866
|
.argument("<table>", "Table id or slug.")
|
|
10748
10867
|
.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,
|
|
10868
|
+
.option("--providers <providers>", "Comma-separated provider order pool. Defaults to scraper,blitzapi,apollo,peopledatalabs,theirstack,leadmagic.")
|
|
10750
10869
|
.option("--all", "Preview all rows.")
|
|
10751
10870
|
.option("--limit <n>", "Preview a limited row scope.")
|
|
10752
10871
|
.option("--row-ids <ids>", "Comma-separated row ids.")
|
|
@@ -10949,7 +11068,7 @@ Examples:
|
|
|
10949
11068
|
.description("Plan-tier limits, spend-safety defaults, per-channel sending limits, and storage capacity posture.")
|
|
10950
11069
|
.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
11070
|
.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.")
|
|
11071
|
+
.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
11072
|
.option("--json", "Print a JSON envelope.")
|
|
10954
11073
|
.action(async (options) => {
|
|
10955
11074
|
await handleAsyncAction("limits show", options, () => requestOxygen("/api/cli/limits"));
|
|
@@ -11202,7 +11321,6 @@ Examples:
|
|
|
11202
11321
|
.option("--rollover <n>", "Max balance credits roll over to. Defaults to the monthly credits.")
|
|
11203
11322
|
.option("--base-tier <tier>", "Base pricing tier to inherit feature flags from. Defaults to growth_250.")
|
|
11204
11323
|
.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
11324
|
.option("--note <text>", "Internal note stored on the contract.")
|
|
11207
11325
|
.option("--json", "Print a JSON envelope.")
|
|
11208
11326
|
.action(async (options) => {
|
|
@@ -11221,9 +11339,6 @@ Examples:
|
|
|
11221
11339
|
: {}),
|
|
11222
11340
|
...(readOption(options.baseTier) ? { base_tier: readOption(options.baseTier) } : {}),
|
|
11223
11341
|
...(options.byok ? { byok: true } : {}),
|
|
11224
|
-
...(readOption(options.agentRunDefaultCredits)
|
|
11225
|
-
? { agent_run_default_credits: readPositiveNumber(options.agentRunDefaultCredits) }
|
|
11226
|
-
: {}),
|
|
11227
11342
|
...(readOption(options.note) ? { note: readOption(options.note) } : {}),
|
|
11228
11343
|
},
|
|
11229
11344
|
}));
|
|
@@ -12013,9 +12128,10 @@ Examples:
|
|
|
12013
12128
|
}))
|
|
12014
12129
|
.addCommand(new Command("custom")
|
|
12015
12130
|
.description("List the org's user-defined custom agents (distinct from `agent list`, which shows the built-in roster).")
|
|
12131
|
+
.option("--tag <tag>", "Only custom agents carrying this workspace tag.")
|
|
12016
12132
|
.option("--json", "Print a JSON envelope.")
|
|
12017
12133
|
.action(async (options) => {
|
|
12018
|
-
await handleAsyncAction("agent custom", options, () => requestOxygen(
|
|
12134
|
+
await handleAsyncAction("agent custom", options, () => requestOxygen(`/api/cli/agent/custom${options.tag ? `?tag=${encodeURIComponent(options.tag)}` : ""}`));
|
|
12019
12135
|
}))
|
|
12020
12136
|
.addCommand(new Command("delete")
|
|
12021
12137
|
.description("Archive a custom agent (soft delete): its triggers stop firing and it leaves the roster; versions/runs are preserved.")
|
|
@@ -12787,15 +12903,15 @@ Examples:
|
|
|
12787
12903
|
.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
12904
|
.argument("<table>", "Table id or slug.")
|
|
12789
12905
|
.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,
|
|
12906
|
+
.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
12907
|
.option("--first-name-column <column>", "Column key or id containing the person's first name for work_email.")
|
|
12792
12908
|
.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
|
|
12909
|
+
.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
12910
|
.option("--email-column <column>", "Column key or id containing a known work email for providers that accept email as an identity fallback.")
|
|
12795
12911
|
.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
12912
|
.option("--company-name-column <column>", "Column key or id containing the company name for work_email when no domain is available.")
|
|
12797
12913
|
.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,
|
|
12914
|
+
.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
12915
|
.option("--target-column <column>", "Target enrichment column key. Defaults to the capability payload column.")
|
|
12800
12916
|
.option("--on-existing-manual-column <mode>", "How to handle an existing manual target: error, write_if_empty, or create_enrichment_column.")
|
|
12801
12917
|
.option("--provider-order <providers>", "Comma-separated provider order. Overrides the default cost-aware waterfall profile.")
|
|
@@ -12804,6 +12920,7 @@ Examples:
|
|
|
12804
12920
|
.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
12921
|
.option("--verify-phone", "Validate found phone numbers with ClearoutPhone (adds phone_line_type + phone_carrier; filter phone_line_type=mobile for mobile-only).")
|
|
12806
12922
|
.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.")
|
|
12923
|
+
.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
12924
|
.option("--phone-verification-credential-mode <mode>", "ClearoutPhone credential mode for phone verification: managed or user_api_key.")
|
|
12808
12925
|
.option("--limit <n>", "Rows to estimate. Defaults to 10.")
|
|
12809
12926
|
.option("--all", "Estimate all rows.")
|
|
@@ -12821,16 +12938,16 @@ Examples:
|
|
|
12821
12938
|
.description("Create or reuse an enrichment column and queue a background run.")
|
|
12822
12939
|
.argument("<table>", "Table id or slug.")
|
|
12823
12940
|
.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,
|
|
12941
|
+
.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
12942
|
.option("--first-name-column <column>", "Column key or id containing the person's first name for work_email.")
|
|
12826
12943
|
.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
|
|
12944
|
+
.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
12945
|
.option("--email-column <column>", "Column key or id containing a known work email for providers that accept email as an identity fallback.")
|
|
12829
12946
|
.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
12947
|
.option("--company-name-column <column>", "Column key or id containing the company name for work_email when no domain is available.")
|
|
12831
12948
|
.option("--company-linkedin-url-column <column>", "Column key or id containing the company's LinkedIn URL for company identity fallback.")
|
|
12832
12949
|
.requiredOption("--max-credits <credits>", "Required credit ceiling for the queued run.")
|
|
12833
|
-
.option("--capability <capability>", "What to run: mobile_phone, work_email,
|
|
12950
|
+
.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
12951
|
.option("--target-column <column>", "Target enrichment column key. Defaults to the capability payload column.")
|
|
12835
12952
|
.option("--on-existing-manual-column <mode>", "How to handle an existing manual target: error, write_if_empty, or create_enrichment_column.")
|
|
12836
12953
|
.option("--provider-order <providers>", "Comma-separated provider order. Overrides the default cost-aware waterfall profile.")
|
|
@@ -12839,6 +12956,7 @@ Examples:
|
|
|
12839
12956
|
.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
12957
|
.option("--verify-phone", "Validate found phone numbers with ClearoutPhone (adds phone_line_type + phone_carrier; filter phone_line_type=mobile for mobile-only).")
|
|
12841
12958
|
.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.")
|
|
12959
|
+
.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
12960
|
.option("--phone-verification-credential-mode <mode>", "ClearoutPhone credential mode for phone verification: managed or user_api_key.")
|
|
12843
12961
|
.option("--limit <n>", "Rows to queue.")
|
|
12844
12962
|
.option("--all", "Queue all rows.")
|
|
@@ -13219,7 +13337,7 @@ Examples:
|
|
|
13219
13337
|
await handleAsyncAction("integrations list", options, () => requestOxygen("/api/cli/integrations/composio/list"));
|
|
13220
13338
|
}))
|
|
13221
13339
|
.addCommand(new Command("connect")
|
|
13222
|
-
.description("Connect an integration. OAuth toolkits
|
|
13340
|
+
.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
13341
|
.argument("<integration_id>", "Integration id, such as 'slack' or 'serpapi'.")
|
|
13224
13342
|
.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
13343
|
.option("--credential <name=value>", "Named provider credential field. Repeat for multi-field integrations (for example, PostHog: --credential subdomain=us).", collectRepeatable, [])
|
|
@@ -13353,8 +13471,8 @@ Examples:
|
|
|
13353
13471
|
.action(async (options) => {
|
|
13354
13472
|
await handleAsyncAction("senders list", options, () => {
|
|
13355
13473
|
const params = new URLSearchParams();
|
|
13356
|
-
|
|
13357
|
-
|
|
13474
|
+
// The server counts usage unless told not to, so --no-usage must say so.
|
|
13475
|
+
params.set("include_usage", options.usage === false ? "false" : "true");
|
|
13358
13476
|
const status = readOption(options.status);
|
|
13359
13477
|
if (status)
|
|
13360
13478
|
params.set("status", status);
|
|
@@ -13372,7 +13490,7 @@ Examples:
|
|
|
13372
13490
|
await handleAsyncAction("senders checkpoints", options, () => requestOxygen("/api/cli/senders/checkpoints"));
|
|
13373
13491
|
}))
|
|
13374
13492
|
.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
|
|
13493
|
+
.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
13494
|
.option("--reconnect <connection_id>", "Reconnect an existing connection instead of creating a new one. Accepts a connection id.")
|
|
13377
13495
|
.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
13496
|
.option("--sales-nav", "Request Classic + Sales Navigator access during Unipile hosted authentication.")
|
|
@@ -14321,8 +14439,7 @@ Examples:
|
|
|
14321
14439
|
.action(async (options) => {
|
|
14322
14440
|
await handleAsyncAction("whatsapp accounts", options, () => {
|
|
14323
14441
|
const params = new URLSearchParams();
|
|
14324
|
-
|
|
14325
|
-
params.set("include_usage", "true");
|
|
14442
|
+
params.set("include_usage", options.usage === false ? "false" : "true");
|
|
14326
14443
|
const status = readOption(options.status);
|
|
14327
14444
|
if (status)
|
|
14328
14445
|
params.set("status", status);
|
|
@@ -14726,6 +14843,20 @@ Examples:
|
|
|
14726
14843
|
...(options.personRowId ? { person_row_id: readOption(options.personRowId) } : {}),
|
|
14727
14844
|
text: readOption(options.text), ...(options.approved ? { approved: true } : {}) },
|
|
14728
14845
|
}));
|
|
14846
|
+
}))
|
|
14847
|
+
.addCommand(new Command("forget-whatsapp-chat")
|
|
14848
|
+
.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.")
|
|
14849
|
+
.argument("<conversationId>", "OXYGEN conversation id from inbox list.")
|
|
14850
|
+
.requiredOption("--sender <id>", "Original disconnected WhatsApp sender account id.")
|
|
14851
|
+
.requiredOption("--before <iso>", "Delete only when the latest message predates this ISO timestamp.")
|
|
14852
|
+
.option("--approved", "Approve permanent removal from OXYGEN after reviewing the preview.")
|
|
14853
|
+
.option("--json", "Print a JSON envelope.")
|
|
14854
|
+
.action(async (conversationId, options) => {
|
|
14855
|
+
await handleAsyncAction("inbox forget-whatsapp-chat", options, () => requestOxygen(`/api/cli/inbox/${encodeURIComponent(conversationId)}/forget-whatsapp`, {
|
|
14856
|
+
method: "POST",
|
|
14857
|
+
body: { sender_account_id: readOption(options.sender), before: readOption(options.before),
|
|
14858
|
+
approved: options.approved === true },
|
|
14859
|
+
}));
|
|
14729
14860
|
}))
|
|
14730
14861
|
.addCommand(new Command("delete-message")
|
|
14731
14862
|
.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 +15407,10 @@ Examples:
|
|
|
15276
15407
|
program.addCommand(new Command("sequences")
|
|
15277
15408
|
.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
15409
|
.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.")
|
|
15410
|
+
.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
15411
|
.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
|
|
15412
|
+
.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"]))
|
|
15413
|
+
.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
15414
|
.addOption(new Option("--channel <channel>", "Only sequences carrying this channel.").choices(["email", "linkedin", "whatsapp"]))
|
|
15283
15415
|
.option("--tag <tag>", "Only sequences carrying this workspace tag (see `oxygen tags list`).")
|
|
15284
15416
|
.option("--stats", "Attach lifetime stats per sequence (a stats pass per row — slower on large workspaces).")
|
|
@@ -15292,6 +15424,9 @@ Examples:
|
|
|
15292
15424
|
const operationalState = readOption(options.operationalState);
|
|
15293
15425
|
if (operationalState)
|
|
15294
15426
|
params.set("operational_state", operationalState);
|
|
15427
|
+
const lifecycle = readOption(options.lifecycle);
|
|
15428
|
+
if (lifecycle)
|
|
15429
|
+
params.set("lifecycle", lifecycle);
|
|
15295
15430
|
const channel = readOption(options.channel);
|
|
15296
15431
|
if (channel)
|
|
15297
15432
|
params.set("channel", channel);
|
|
@@ -15552,7 +15687,7 @@ Examples:
|
|
|
15552
15687
|
.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
15688
|
.requiredOption("--name <name>", "Human-readable sequence name.")
|
|
15554
15689
|
.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.
|
|
15690
|
+
.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
15691
|
.option("--channels <list>", "Comma-separated channels: linkedin,email,whatsapp,call,crm. Defaults to the channels the journey touches. crm_task currently supports HubSpot.")
|
|
15557
15692
|
.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
15693
|
.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 +15702,15 @@ Examples:
|
|
|
15567
15702
|
.option("--max-credits <n>", "Credit cap for the LinkedIn track (also set when starting).")
|
|
15568
15703
|
.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
15704
|
.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.")
|
|
15705
|
+
.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-*).")
|
|
15706
|
+
.option("--schedule-days <days>", "Sending schedule days: mon-fri, mon,wed,fri, or ISO numbers 1-7 (1 = Monday).")
|
|
15707
|
+
.option("--schedule-hours-start <HH:MM>", "Sending schedule start, local to --schedule-timezone, e.g. 09:00.")
|
|
15708
|
+
.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
15709
|
.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
15710
|
.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
15711
|
.option("--sequence-prioritization <mode>", "Under a tight daily budget, serve 'followups' or 'new_leads' first.")
|
|
15578
15712
|
.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
|
|
15713
|
+
.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
15714
|
.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
15715
|
.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
15716
|
.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 +15722,8 @@ Examples:
|
|
|
15588
15722
|
.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
15723
|
.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
15724
|
.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.")
|
|
15725
|
+
.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.")
|
|
15726
|
+
.option("--no-include-unsubscribe", "Turn off both the visible unsubscribe link and the one-click headers.")
|
|
15596
15727
|
.option("--tracking-opens", "Opt in to open tracking (default off). Requires a verified tracking domain and deployment tracking secret.")
|
|
15597
15728
|
.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
15729
|
.option("--tracking-clicks", "Opt in to click tracking (default off). Requires a verified tracking domain and deployment tracking secret.")
|
|
@@ -15637,6 +15768,7 @@ Examples:
|
|
|
15637
15768
|
...(options.trackingOpens !== undefined ? { tracking_opens: options.trackingOpens } : {}),
|
|
15638
15769
|
...(options.trackingClicks !== undefined ? { tracking_clicks: options.trackingClicks } : {}),
|
|
15639
15770
|
...(settings ? { settings } : {}),
|
|
15771
|
+
...readUnifiedSequenceBody(options),
|
|
15640
15772
|
},
|
|
15641
15773
|
});
|
|
15642
15774
|
});
|
|
@@ -15669,11 +15801,11 @@ Examples:
|
|
|
15669
15801
|
}
|
|
15670
15802
|
}))
|
|
15671
15803
|
.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
|
|
15804
|
+
.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
15805
|
.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
15806
|
.argument("<sequence>", "Sequence id or slug.")
|
|
15675
15807
|
.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;
|
|
15808
|
+
.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
15809
|
.option("--channels <list>", "Draft only: comma-separated channels: linkedin,email,whatsapp,call,crm.")
|
|
15678
15810
|
.option("--whatsapp-cold-initiate", "WhatsApp: allow cold-initiating new chats (no prior conversation). Required to start a WhatsApp sequence live (draft-editable).")
|
|
15679
15811
|
.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 +15820,20 @@ Examples:
|
|
|
15688
15820
|
.option("--max-credits <n>", "Credit cap for the LinkedIn track (draft only).")
|
|
15689
15821
|
.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
15822
|
.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("--
|
|
15823
|
+
.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.")
|
|
15824
|
+
.option("--schedule-days <days>", "Sending schedule days: mon-fri, mon,wed,fri, or ISO numbers 1-7 (1 = Monday).")
|
|
15825
|
+
.option("--schedule-hours-start <HH:MM>", "Sending schedule start, local to --schedule-timezone, e.g. 09:00.")
|
|
15826
|
+
.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.")
|
|
15827
|
+
.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.")
|
|
15828
|
+
.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
15829
|
.option("--linkedin-days <days>", "LinkedIn sending schedule days: mon-fri, mon,wed,fri, or ISO numbers 1-7 (1 = Monday).")
|
|
15694
15830
|
.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.
|
|
15831
|
+
.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
15832
|
.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
15833
|
.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
15834
|
.option("--sequence-prioritization <mode>", "Under a tight daily budget, serve 'followups' or 'new_leads' first.")
|
|
15699
15835
|
.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
|
|
15836
|
+
.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
15837
|
.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
15838
|
.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
15839
|
.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 +15845,11 @@ Examples:
|
|
|
15709
15845
|
.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
15846
|
.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
15847
|
.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
|
|
15848
|
+
.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.")
|
|
15849
|
+
.option("--no-include-unsubscribe", "Turn off both the visible unsubscribe link and the one-click headers.")
|
|
15850
|
+
.option("--include-unsubscribe-headers", "Only the one-click unsubscribe headers (default off); --include-unsubscribe turns on the headers and the visible link together.")
|
|
15713
15851
|
.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.")
|
|
15852
|
+
.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
15853
|
.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
15854
|
.option("--tracking-opens", "Opt in to open tracking (default off). Requires a verified tracking domain and deployment tracking secret.")
|
|
15718
15855
|
.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 +15894,7 @@ Examples:
|
|
|
15757
15894
|
const settings = readSequenceSettings(options);
|
|
15758
15895
|
if (settings)
|
|
15759
15896
|
body.settings = settings;
|
|
15897
|
+
Object.assign(body, readUnifiedSequenceBody(options));
|
|
15760
15898
|
// Tri-state tracking toggles: only send the key when a flag was
|
|
15761
15899
|
// explicitly passed, so an unrelated update never flips tracking.
|
|
15762
15900
|
if (options.trackingOpens !== undefined)
|
|
@@ -15772,7 +15910,7 @@ Examples:
|
|
|
15772
15910
|
body.email = email;
|
|
15773
15911
|
}
|
|
15774
15912
|
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).");
|
|
15913
|
+
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
15914
|
}
|
|
15777
15915
|
return requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}`, {
|
|
15778
15916
|
method: "PATCH",
|
|
@@ -15789,7 +15927,7 @@ Examples:
|
|
|
15789
15927
|
await handleAsyncAction("sequences get", options, () => requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}${options.readiness ? "?include=readiness" : ""}`));
|
|
15790
15928
|
}))
|
|
15791
15929
|
.addCommand(new Command("enroll")
|
|
15792
|
-
.description("Enroll leads into a sequence. Enrolling spends no credits
|
|
15930
|
+
.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
15931
|
.argument("<sequence>", "Sequence id or slug.")
|
|
15794
15932
|
.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
15933
|
.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 +16102,28 @@ Examples:
|
|
|
15964
16102
|
});
|
|
15965
16103
|
}))
|
|
15966
16104
|
.addCommand(new Command("contacts")
|
|
15967
|
-
.description("List a launched campaign's table-backed contacts with full source variables,
|
|
16105
|
+
.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
16106
|
.argument("<sequence>", "Sequence id or slug.")
|
|
15969
|
-
.option("--contact-state <
|
|
16107
|
+
.option("--contact-state <tags>", "Only contacts with any of these tags, comma-separated: not_contacted, contacted, connected, replied, positive_reply; or all.", "all")
|
|
16108
|
+
.option("--exclude-contact-state <tags>", "Hide contacts with any of these tags, comma-separated.")
|
|
15970
16109
|
.option("--limit <n>", "Maximum contacts per page (1-500). Page with --cursor (next_cursor from the previous page) or --offset.", "100")
|
|
15971
16110
|
.option("--cursor <c>", "Pagination cursor from the previous page's next_cursor.")
|
|
15972
16111
|
.option("--offset <n>", "Skip this many contacts before the page (0-based); an absolute alternative to --cursor.")
|
|
15973
16112
|
.option("--json", "Print a JSON envelope.")
|
|
15974
16113
|
.action(async (sequence, options) => {
|
|
15975
16114
|
await handleAsyncAction("sequences contacts", options, () => {
|
|
15976
|
-
const
|
|
15977
|
-
|
|
15978
|
-
|
|
15979
|
-
|
|
16115
|
+
const contactTags = ["not_contacted", "contacted", "connected", "replied", "positive_reply"];
|
|
16116
|
+
const readTags = (flag, raw) => {
|
|
16117
|
+
const tags = raw.split(",").map((tag) => tag.trim()).filter(Boolean);
|
|
16118
|
+
if (tags.length === 0 || tags.some((tag) => !contactTags.includes(tag))) {
|
|
16119
|
+
throw new Error(`${flag} takes comma-separated tags: ${contactTags.join(", ")}.`);
|
|
16120
|
+
}
|
|
16121
|
+
return tags.join(",");
|
|
16122
|
+
};
|
|
16123
|
+
const contactStateRaw = readOption(options.contactState) ?? "all";
|
|
16124
|
+
const contactState = contactStateRaw === "all" ? "all" : readTags("--contact-state", contactStateRaw);
|
|
16125
|
+
const excludeRaw = readOption(options.excludeContactState);
|
|
16126
|
+
const excludeContactState = excludeRaw ? readTags("--exclude-contact-state", excludeRaw) : null;
|
|
15980
16127
|
const limit = readPositiveInt(options.limit);
|
|
15981
16128
|
if (limit === undefined || limit > 500) {
|
|
15982
16129
|
throw new Error("--limit must be between 1 and 500.");
|
|
@@ -15985,6 +16132,8 @@ Examples:
|
|
|
15985
16132
|
contact_state: contactState,
|
|
15986
16133
|
limit: String(limit),
|
|
15987
16134
|
});
|
|
16135
|
+
if (excludeContactState)
|
|
16136
|
+
params.set("exclude_contact_state", excludeContactState);
|
|
15988
16137
|
const cursor = readOption(options.cursor);
|
|
15989
16138
|
if (cursor)
|
|
15990
16139
|
params.set("cursor", cursor);
|
|
@@ -16175,7 +16324,7 @@ Examples:
|
|
|
16175
16324
|
});
|
|
16176
16325
|
}))
|
|
16177
16326
|
.addCommand(new Command("stats")
|
|
16178
|
-
.description("Show one sequence's persisted launch status and current operational state separately, including reasons, open work, lifetime sent/terminal-failed/deferral counts, and the funnel.
|
|
16327
|
+
.description("Show one sequence's persisted launch status and current operational state separately, including reasons, open work, lifetime sent/terminal-failed/deferral counts, and the funnel. Today's completed sends, reservations and remaining capacity appear in the default output and daily_budget. operational.reasons carries why work is waiting (outside_send_window, mailbox_spacing, daily_send_cap_reached, ...). For a per-day send series use `sequences analytics --sequence <id> --range <window>`.")
|
|
16179
16328
|
.argument("<sequence>", "Sequence id or slug.")
|
|
16180
16329
|
.option("--json", "Print a JSON envelope.")
|
|
16181
16330
|
.action(async (sequence, options) => {
|
|
@@ -17740,7 +17889,7 @@ Examples:
|
|
|
17740
17889
|
.addCommand(new Command("emailguard")
|
|
17741
17890
|
.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
17891
|
.addCommand(new Command("connect")
|
|
17743
|
-
.description("Preview or connect selected mailboxes to EmailGuard.
|
|
17892
|
+
.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
17893
|
.option("--mailboxes <list>", "Comma-separated mailbox ids or addresses. Omit for the whole pool.")
|
|
17745
17894
|
.option("--approved", "Perform the external EmailGuard account write.")
|
|
17746
17895
|
.option("--plan <hash>", "Exact hash from the fresh preview (required with --approved).")
|
|
@@ -17857,7 +18006,7 @@ Examples:
|
|
|
17857
18006
|
});
|
|
17858
18007
|
}))
|
|
17859
18008
|
.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.
|
|
18009
|
+
.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
18010
|
.addHelpText("after", "\nDocs: https://oxygen-agent.com/docs/providers/mailboxes\n")
|
|
17862
18011
|
.addCommand(new Command("enable")
|
|
17863
18012
|
.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 +18224,7 @@ Examples:
|
|
|
18075
18224
|
});
|
|
18076
18225
|
}))
|
|
18077
18226
|
.addCommand(new Command("reconnect")
|
|
18078
|
-
.description("Manual repair for an existing live paid seat when
|
|
18227
|
+
.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
18228
|
.option("--mailboxes <list>", "Comma-separated mailbox ids or addresses. Omit for the whole pool.")
|
|
18080
18229
|
.option("--json", "Print a JSON envelope.")
|
|
18081
18230
|
.action(async (options) => {
|
|
@@ -18217,7 +18366,7 @@ Examples:
|
|
|
18217
18366
|
}));
|
|
18218
18367
|
}))
|
|
18219
18368
|
.addCommand(new Command("list")
|
|
18220
|
-
.description("Inspect recent directional inbox-placement tests, their status, results, and credits used, plus `pricing`:
|
|
18369
|
+
.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
18370
|
.option("--limit <n>", "Max rows (default 50, cap 200).")
|
|
18222
18371
|
.option("--json", "Print a JSON envelope.")
|
|
18223
18372
|
.action(async (options) => {
|
|
@@ -18234,7 +18383,7 @@ Examples:
|
|
|
18234
18383
|
await handleAsyncAction("deliverability placement-test get", options, () => requestOxygen(`/api/cli/deliverability/placement-tests/${encodeURIComponent(id)}`));
|
|
18235
18384
|
}))));
|
|
18236
18385
|
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).
|
|
18386
|
+
.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
18387
|
.addCommand(new Command("list")
|
|
18239
18388
|
.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
18389
|
.option("--status <status>", "Filter by zone status: unknown, initializing, pending, active, moved, or deleted.")
|
|
@@ -19591,6 +19740,7 @@ Full trigger schema: oxygen workflows schema --subject trigger --json
|
|
|
19591
19740
|
registerFunctionsCommands(program, handleAsyncAction);
|
|
19592
19741
|
registerUgcCommands(program, handleAsyncAction);
|
|
19593
19742
|
registerKnowledgeRepositoryCommands(program, handleAsyncAction);
|
|
19743
|
+
registerConnectorsCommands(program, handleAsyncAction);
|
|
19594
19744
|
// Last, so every command registered above is covered — including the ones the
|
|
19595
19745
|
// register* helpers add after applyOxygenHelp.
|
|
19596
19746
|
enableCommandSuggestions(program);
|
|
@@ -20971,7 +21121,7 @@ function printColumnCatalog(result) {
|
|
|
20971
21121
|
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
21122
|
// The blind acceptance judge watched an agent filter this list with python3
|
|
20973
21123
|
// 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.
|
|
21124
|
+
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
21125
|
process.stderr.write(`${lines.join("\n")}\n`);
|
|
20976
21126
|
}
|
|
20977
21127
|
function applyAiColumnConfig(definition, options) {
|
|
@@ -25928,9 +26078,11 @@ function renderTextTable(headers, rows) {
|
|
|
25928
26078
|
return [renderRow(headers), separator, ...rows.map(renderRow)];
|
|
25929
26079
|
}
|
|
25930
26080
|
// 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
|
-
|
|
26081
|
+
// facts; they never infer flowing/blocked/live client-side. The one label drawn
|
|
26082
|
+
// from them, completed, is the server's `lifecycle` (the shared helper covers a
|
|
26083
|
+
// server that predates it). JSON remains the untouched API data inside the
|
|
26084
|
+
// standard CLI envelope.
|
|
26085
|
+
const SEQUENCE_OPERATIONAL_STATE_ORDER = ["empty", "drained", "never_started", "blocked", "degraded", "flowing", "paced", "paused"];
|
|
25934
26086
|
/**
|
|
25935
26087
|
* `crm objects`, as an operator reads it.
|
|
25936
26088
|
*
|
|
@@ -26164,6 +26316,9 @@ function formatSequenceListHealth(data) {
|
|
|
26164
26316
|
}
|
|
26165
26317
|
else {
|
|
26166
26318
|
lines.push(...renderTextTable(["SEQUENCE", "PERSISTED", "OPERATIONAL", "OPEN WORK", "SENT / FAILED", "REASONS"], sequences.map((sequence) => sequenceHealthRow(sequence))));
|
|
26319
|
+
const warnings = sequenceRowWarnings(sequences);
|
|
26320
|
+
if (warnings.length > 0)
|
|
26321
|
+
lines.push("", ...warnings);
|
|
26167
26322
|
lines.push("", ...sequenceInspectionLinks(sequences));
|
|
26168
26323
|
}
|
|
26169
26324
|
const listLink = stringValue(data.web_url) ?? stringValue(data.deepLink);
|
|
@@ -26290,14 +26445,23 @@ function formatSequenceReplyRollup(replies) {
|
|
|
26290
26445
|
function formatSequenceStatsHealth(data) {
|
|
26291
26446
|
const stats = recordValue(data.stats);
|
|
26292
26447
|
const operational = recordValue(stats.operational);
|
|
26448
|
+
const budget = recordValue(data.daily_budget);
|
|
26293
26449
|
const lines = [
|
|
26294
26450
|
stringValue(data.name) ?? stringValue(data.sequence_id) ?? "Sequence",
|
|
26295
26451
|
`Persisted status: ${stringValue(data.status) ?? "unknown"}`,
|
|
26296
|
-
`Operational state: ${
|
|
26452
|
+
`Operational state: ${operationalStateText(data, operational) ?? "unknown (legacy response)"}`,
|
|
26297
26453
|
`Open work: ${formatSequenceOpenWork(operational.openWork)}`,
|
|
26298
26454
|
`Reasons: ${formatSequenceReasons(operational)}`,
|
|
26299
26455
|
`${formatFailureWindow(operational.failureAnalytics)} outcomes: ${formatSequenceFailureTotals(operational.failureAnalytics)}`,
|
|
26300
26456
|
];
|
|
26457
|
+
const sentToday = numericValue(budget.sends_used);
|
|
26458
|
+
if (sentToday !== null) {
|
|
26459
|
+
const reserved = numericValue(budget.sends_reserved);
|
|
26460
|
+
const remaining = numericValue(budget.sends_remaining);
|
|
26461
|
+
lines.push(`Today${stringValue(budget.day) ? ` (${budget.day} UTC)` : ""}: ${sentToday} sent`
|
|
26462
|
+
+ (reserved === null ? "" : ` · ${reserved} reserved`)
|
|
26463
|
+
+ ` · ${remaining === null ? "unlimited" : remaining} available`);
|
|
26464
|
+
}
|
|
26301
26465
|
const failureMix = formatCountRecord(recordValue(recordValue(operational.failureAnalytics).byFailureClass));
|
|
26302
26466
|
if (failureMix !== "—")
|
|
26303
26467
|
lines.push(`Failure mix: ${failureMix}`);
|
|
@@ -26373,12 +26537,37 @@ function sequenceHealthRow(sequence) {
|
|
|
26373
26537
|
return [
|
|
26374
26538
|
stringValue(sequence.name) ?? stringValue(sequence.slug) ?? "—",
|
|
26375
26539
|
stringValue(sequence.status) ?? "unknown",
|
|
26376
|
-
|
|
26540
|
+
operationalStateText(sequence, operational) ?? "unknown",
|
|
26377
26541
|
formatSequenceOpenWork(operational.openWork),
|
|
26378
26542
|
formatSequenceFailureTotals(operational.failureAnalytics),
|
|
26379
26543
|
formatSequenceReasons(operational),
|
|
26380
26544
|
];
|
|
26381
26545
|
}
|
|
26546
|
+
/**
|
|
26547
|
+
* The operational state, naming the lifecycle a person reads when the two
|
|
26548
|
+
* disagree. A campaign that contacted every lead keeps its persisted `active`,
|
|
26549
|
+
* and a blind user eval (2026-09-29) had to infer it was done from `drained`
|
|
26550
|
+
* plus zero open work. The web lists the same Sequence as Completed.
|
|
26551
|
+
*/
|
|
26552
|
+
function operationalStateText(sequence, operational) {
|
|
26553
|
+
const state = stringValue(operational.operationalState);
|
|
26554
|
+
if (!state)
|
|
26555
|
+
return null;
|
|
26556
|
+
// The server's own `lifecycle`; the shared helper covers a server that predates it.
|
|
26557
|
+
const lifecycle = stringValue(sequence.lifecycle) ?? sequenceDisplayStatus(stringValue(sequence.status) ?? "", state);
|
|
26558
|
+
return lifecycle === "completed" ? `${state} (completed)` : state;
|
|
26559
|
+
}
|
|
26560
|
+
/** A listed Sequence's launch warnings (e.g. two sending schedules), one line each. */
|
|
26561
|
+
function sequenceRowWarnings(sequences) {
|
|
26562
|
+
return sequences.flatMap((sequence) => {
|
|
26563
|
+
const label = stringValue(sequence.name) ?? stringValue(sequence.slug) ?? "Sequence";
|
|
26564
|
+
const warnings = Array.isArray(sequence.warnings) ? sequence.warnings.filter(isRecord) : [];
|
|
26565
|
+
return warnings.flatMap((warning) => {
|
|
26566
|
+
const message = stringValue(warning.message);
|
|
26567
|
+
return message ? [`Warning (${label}): ${message}`] : [];
|
|
26568
|
+
});
|
|
26569
|
+
});
|
|
26570
|
+
}
|
|
26382
26571
|
function sequenceInspectionLinks(sequences) {
|
|
26383
26572
|
return sequences.flatMap((sequence) => {
|
|
26384
26573
|
const exactLink = stringValue(sequence.web_url) ?? stringValue(sequence.deepLink);
|
|
@@ -26394,27 +26583,38 @@ function formatSequenceFleetSummary(value) {
|
|
|
26394
26583
|
// should not read a line of zeroes every time it lists its sequences.
|
|
26395
26584
|
const neverStarted = typeof fleet.neverStarted === "number" ? fleet.neverStarted : 0;
|
|
26396
26585
|
const parkedLeads = typeof fleet.neverStartedEnrollments === "number" ? fleet.neverStartedEnrollments : 0;
|
|
26586
|
+
const completed = recordValue(fleet.byLifecycle).completed;
|
|
26587
|
+
const completedText = typeof completed === "number" ? ` · ${numberText(completed)} completed` : "";
|
|
26397
26588
|
return [
|
|
26398
|
-
`Fleet: ${numberText(fleet.markedActive)} marked active · ${numberText(fleet.operationallyLive)} operationally live · ${numberText(fleet.total)} total`,
|
|
26589
|
+
`Fleet: ${numberText(fleet.markedActive)} marked active · ${numberText(fleet.operationallyLive)} operationally live${completedText} · ${numberText(fleet.total)} total`,
|
|
26399
26590
|
`States: ${SEQUENCE_OPERATIONAL_STATE_ORDER.map((state) => `${state} ${numberText(states[state])}`).join(" · ")}`,
|
|
26400
26591
|
...(neverStarted > 0
|
|
26401
26592
|
? [`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
26593
|
: []),
|
|
26403
26594
|
];
|
|
26404
26595
|
}
|
|
26596
|
+
/**
|
|
26597
|
+
* Spelled out because a blind user eval (2026-09-29) read `enr 352` as 352
|
|
26598
|
+
* enrolled when it meant 352 leads still in progress, and told the founder the
|
|
26599
|
+
* wrong count. Only the first two counts have a unit: ready, in flight, paced
|
|
26600
|
+
* and blocked add leads with no open action to open actions.
|
|
26601
|
+
*/
|
|
26405
26602
|
function formatSequenceOpenWork(value) {
|
|
26406
26603
|
const work = recordValue(value);
|
|
26407
26604
|
if (Object.keys(work).length === 0)
|
|
26408
26605
|
return "unknown";
|
|
26409
|
-
|
|
26410
|
-
|
|
26411
|
-
|
|
26412
|
-
|
|
26413
|
-
|
|
26414
|
-
|
|
26415
|
-
|
|
26416
|
-
|
|
26417
|
-
]
|
|
26606
|
+
const counts = [
|
|
26607
|
+
["leads in progress", work.enrollments],
|
|
26608
|
+
["open actions", work.actions],
|
|
26609
|
+
["ready now", work.actionableNow],
|
|
26610
|
+
["in flight", work.inFlight],
|
|
26611
|
+
["paced", work.paced],
|
|
26612
|
+
["blocked", work.blocked],
|
|
26613
|
+
["unresolved failures", work.unresolvedFailures],
|
|
26614
|
+
];
|
|
26615
|
+
if (counts.every(([, count]) => count === 0))
|
|
26616
|
+
return "none";
|
|
26617
|
+
return counts.map(([label, count]) => `${label} ${numberText(count)}`).join(" · ");
|
|
26418
26618
|
}
|
|
26419
26619
|
function formatSequenceFailureTotals(value) {
|
|
26420
26620
|
const analytics = recordValue(value);
|
|
@@ -28843,6 +29043,7 @@ table, options) {
|
|
|
28843
29043
|
: {}),
|
|
28844
29044
|
...(options.verifyPhone ? { verify_phone: true } : {}),
|
|
28845
29045
|
...(options.allowPremiumLanes ? { allow_premium_lanes: true } : {}),
|
|
29046
|
+
...(options.includePersonalEmail ? { include_personal_email: true } : {}),
|
|
28846
29047
|
...(readOption(options.phoneVerificationCredentialMode)
|
|
28847
29048
|
? { phone_verification_credential_mode: readOption(options.phoneVerificationCredentialMode) }
|
|
28848
29049
|
: {}),
|
|
@@ -28868,6 +29069,25 @@ function readLinkedinScheduleOptions(options) {
|
|
|
28868
29069
|
}
|
|
28869
29070
|
return { timezone, days: parseWorkingDaysOption(days), start, end };
|
|
28870
29071
|
}
|
|
29072
|
+
// Launch's one sending schedule and one unsubscribe switch. The server writes
|
|
29073
|
+
// `sending_schedule` to the email window and the LinkedIn/WhatsApp window, and
|
|
29074
|
+
// `include_unsubscribe` to the visible link and the one-click headers.
|
|
29075
|
+
function readUnifiedSequenceBody(options) {
|
|
29076
|
+
const timezone = readOption(options.scheduleTimezone);
|
|
29077
|
+
const days = readOption(options.scheduleDays);
|
|
29078
|
+
const start = readOption(options.scheduleHoursStart);
|
|
29079
|
+
const end = readOption(options.scheduleHoursEnd);
|
|
29080
|
+
const body = {};
|
|
29081
|
+
if (timezone || days || start || end) {
|
|
29082
|
+
if (!timezone || !days || !start || !end) {
|
|
29083
|
+
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 });
|
|
29084
|
+
}
|
|
29085
|
+
body.sending_schedule = { timezone, days: parseWorkingDaysOption(days), start, end };
|
|
29086
|
+
}
|
|
29087
|
+
if (options.includeUnsubscribe !== undefined)
|
|
29088
|
+
body.include_unsubscribe = options.includeUnsubscribe;
|
|
29089
|
+
return body;
|
|
29090
|
+
}
|
|
28871
29091
|
/**
|
|
28872
29092
|
* A whole seat count. Unlike readPositiveNumber, ZERO IS VALID and meaningful:
|
|
28873
29093
|
* `--quantity 0` is how a customer gives back every seat of a channel, and
|
|
@@ -29123,8 +29343,6 @@ function readSequenceSettings(options) {
|
|
|
29123
29343
|
settings.bounce_protection = JSON.parse(options.bounceProtection);
|
|
29124
29344
|
if (options.includeUnsubscribeHeaders !== undefined)
|
|
29125
29345
|
settings.include_unsubscribe_headers = options.includeUnsubscribeHeaders;
|
|
29126
|
-
if (options.replyToStopText !== undefined)
|
|
29127
|
-
settings.reply_to_stop_text = options.replyToStopText;
|
|
29128
29346
|
if (options.includeUnsubscribeLink !== undefined) {
|
|
29129
29347
|
settings.include_unsubscribe_link = options.includeUnsubscribeLink;
|
|
29130
29348
|
}
|
|
@@ -29215,23 +29433,31 @@ process.stdout.on("error", (error) => {
|
|
|
29215
29433
|
maybeScheduleBackgroundUpdate();
|
|
29216
29434
|
const program = createProgram();
|
|
29217
29435
|
installCommanderExitOverride(program);
|
|
29436
|
+
const argv = withoutHelpForUnknownCommand(program, process.argv);
|
|
29218
29437
|
try {
|
|
29219
|
-
await program.parseAsync(
|
|
29438
|
+
await program.parseAsync(argv);
|
|
29220
29439
|
}
|
|
29221
29440
|
catch (error) {
|
|
29222
29441
|
if (error instanceof CommanderError) {
|
|
29223
|
-
const cliArgs =
|
|
29442
|
+
const cliArgs = argv.slice(2);
|
|
29443
|
+
// Every hint goes to stderr as it always has; the first, the most specific,
|
|
29444
|
+
// also becomes the --json envelope's details.next_step below.
|
|
29445
|
+
const hints = [];
|
|
29446
|
+
const writeHint = (hint) => {
|
|
29447
|
+
hints.push(hint);
|
|
29448
|
+
process.stderr.write(`${hint}\n`);
|
|
29449
|
+
};
|
|
29224
29450
|
if (error.code === "commander.unknownOption" &&
|
|
29225
29451
|
error.message.includes("unknown option '--json'") &&
|
|
29226
29452
|
cliArgs.slice(-2).join(" ") === "limits --json") {
|
|
29227
|
-
|
|
29453
|
+
writeHint(`Hint: --json belongs to the show subcommand. Run \`${program.name()} limits show --json\`.`);
|
|
29228
29454
|
}
|
|
29229
29455
|
// `error: unknown command 'get'` names what is wrong and nothing to try
|
|
29230
29456
|
// next; commander's edit-distance suggester cannot reach a synonym.
|
|
29231
29457
|
if (error.code === "commander.unknownCommand") {
|
|
29232
29458
|
const hint = unknownCommandHint(program, cliArgs);
|
|
29233
29459
|
if (hint)
|
|
29234
|
-
|
|
29460
|
+
writeHint(hint);
|
|
29235
29461
|
}
|
|
29236
29462
|
// `error: unknown option '--filter'` on `tables query` got no suggestion
|
|
29237
29463
|
// either (blind user eval, 2026-09-18): commander caps its suggester at
|
|
@@ -29239,7 +29465,7 @@ catch (error) {
|
|
|
29239
29465
|
if (error.code === "commander.unknownOption") {
|
|
29240
29466
|
const hint = unknownOptionHint(program, cliArgs, error.message);
|
|
29241
29467
|
if (hint)
|
|
29242
|
-
|
|
29468
|
+
writeHint(hint);
|
|
29243
29469
|
}
|
|
29244
29470
|
if (error.code === "commander.excessArguments" &&
|
|
29245
29471
|
cliArgs[0] === "skills" &&
|
|
@@ -29252,11 +29478,17 @@ catch (error) {
|
|
|
29252
29478
|
const skillArgument = /^[a-z0-9][a-z0-9_-]*$/i.test(requestedSkill)
|
|
29253
29479
|
? requestedSkill
|
|
29254
29480
|
: "<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
|
-
|
|
29481
|
+
writeHint(looksLikePath
|
|
29482
|
+
? `Hint: --project takes no value; it installs into the current directory. Did you mean \`${program.name()} skills install --project\`?`
|
|
29483
|
+
: `Hint: skill names use the --skill option. Did you mean \`${program.name()} skills install --skill ${skillArgument}\`?`);
|
|
29484
|
+
}
|
|
29485
|
+
// Commander refused before any action ran, so no handler printed the --json
|
|
29486
|
+
// envelope; agents parse stdout and branch on the exit code, and the exit
|
|
29487
|
+
// table classes a usage error as 2 in every mode.
|
|
29488
|
+
const usage = commanderUsageFailure(program, cliArgs, error, hints[0] ?? null);
|
|
29489
|
+
if (usage && argvRequestsJson(cliArgs))
|
|
29490
|
+
writeJson(usage.envelope);
|
|
29491
|
+
process.exitCode = usage?.exitCode ?? error.exitCode;
|
|
29260
29492
|
}
|
|
29261
29493
|
else {
|
|
29262
29494
|
throw error;
|