@oxygen-agent/cli 1.246.0 → 1.256.13

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/dist/index.js CHANGED
@@ -7,7 +7,7 @@ import { createInterface } from "node:readline/promises";
7
7
  import { stdin as input, stdout as output } from "node:process";
8
8
  import { fileURLToPath, pathToFileURL } from "node:url";
9
9
  import { Command, Option } from "commander";
10
- import { formatCellForDisplay, formatPublicBudgetScopes, OXYGEN_VERSION, OxygenError, success, toFailure, } from "@oxygen/shared";
10
+ import { AGENCY_DIRECTORY_REGIONS, AGENCY_DIRECTORY_SERVICES, formatCellForDisplay, formatPublicBudgetScopes, OXYGEN_VERSION, OxygenError, success, toFailure, } from "@oxygen/shared";
11
11
  import { inferImportColumnLabels, inferRowsFileFormat, normalizeImportColumnKey, normalizeRowsForNewTable, normalizeRowsFormat, parseRowsFileBuffer, } from "@oxygen/shared/file-import";
12
12
  import { assertRecipeBundleSafe, assertWorkflowManifest, buildRecipeManifest, compileWorkflowDefinition, isAnyWorkflowManifest, isRecipeManifest, isWorkflowDefinition, isWorkflowManifest, } from "@oxygen/workflows";
13
13
  import { isRecipeDefinition } from "@oxygen/recipe-sdk";
@@ -1158,11 +1158,13 @@ export function createProgram() {
1158
1158
  .option("--body <body>", "What you were doing, what happened, and what you tried.")
1159
1159
  .option("--severity <severity>", "low | normal | high. Defaults to normal.")
1160
1160
  .option("--category <category>", "Optional category label.")
1161
+ .option("--source <source>", "Where the ticket originated. Only slack is supported today.")
1161
1162
  .option("--operation <operation>", "The operation/tool that failed, e.g. oxygen_columns_run.")
1162
1163
  .option("--error-code <code>", "The error envelope code you received.")
1163
1164
  .option("--run-ids <ids>", "Comma-separated run ids to attach.")
1164
1165
  .option("--table-ids <ids>", "Comma-separated table ids to attach.")
1165
1166
  .option("--deep-links <urls>", "Comma-separated https://oxygen-agent.com/... links.")
1167
+ .option("--slack-permalink <url>", "Slack message permalink (https://*.slack.com/...) to attach as context.")
1166
1168
  .option("--json", "Print a JSON envelope.")
1167
1169
  .action(async (options) => {
1168
1170
  await handleAsyncAction("support ticket create", options, () => requestOxygen("/api/cli/support/tickets", {
@@ -1244,6 +1246,30 @@ export function createProgram() {
1244
1246
  method: "POST",
1245
1247
  body: { resolution: readOption(options.resolution) },
1246
1248
  }));
1249
+ }))
1250
+ .addCommand(new Command("workflow")
1251
+ .description("Track verify/plan/draft triage progress on a support ticket (staff only).")
1252
+ .argument("<ticketId>", "Ticket UUID.")
1253
+ .option("--verify-status <status>", "Verify step: pending | in_progress | done | blocked | skipped.")
1254
+ .option("--verify-notes <text>", "Verification notes. Pass an empty string to clear.")
1255
+ .option("--plan-status <status>", "Plan step: pending | in_progress | done | blocked | skipped.")
1256
+ .option("--plan <text>", "Remediation plan. Pass an empty string to clear.")
1257
+ .option("--draft-status <status>", "Draft step: pending | in_progress | done | blocked | skipped.")
1258
+ .option("--draft <text>", "Draft reply to the opener. Pass an empty string to clear.")
1259
+ .option("--json", "Print a JSON envelope.")
1260
+ .action(async (ticketId, options) => {
1261
+ await handleSupportAdminWorkflowAction(ticketId, options);
1262
+ }))
1263
+ .addCommand(new Command("update")
1264
+ .description("Change a support ticket's status or assignee, with an optional staff note (staff only).")
1265
+ .argument("<ticketId>", "Ticket UUID.")
1266
+ .option("--status <status>", "open | triaging | waiting_on_user | closed. Use the resolve command to resolve.")
1267
+ .option("--assign <email>", "Assign the ticket to a staff email.")
1268
+ .option("--unassign", "Clear the ticket assignment.")
1269
+ .option("--note <text>", "Append a staff message to the ticket thread.")
1270
+ .option("--json", "Print a JSON envelope.")
1271
+ .action(async (ticketId, options) => {
1272
+ await handleSupportAdminUpdateAction(ticketId, options);
1247
1273
  })));
1248
1274
  program
1249
1275
  .command("feedback")
@@ -3835,8 +3861,8 @@ export function createProgram() {
3835
3861
  await handleAsyncAction("billing balance", options, () => requestOxygen("/api/cli/billing/balance"));
3836
3862
  }))
3837
3863
  .addCommand(new Command("usage")
3838
- .description("Show credit ledger events or automation action usage.")
3839
- .option("--meter <meter>", "credits or automation_actions. Defaults to credits.")
3864
+ .description("Show credit ledger events, automation action usage, or implied LinkedIn seat COGS plus a RECOMMENDED per-account price preview (provider_seats).")
3865
+ .option("--meter <meter>", "credits, automation_actions, or provider_seats (rolling-30d peak connected LinkedIn accounts, implied Unipile COGS, and a RECOMMENDED per-account charge preview + implied margin — preview only, not yet billed). Defaults to credits.")
3840
3866
  .option("--days <n>", "Lookback window in days. Defaults to 30.")
3841
3867
  .option("--from <iso>", "Only include ledger events at or after this ISO timestamp.")
3842
3868
  .option("--to <iso>", "Only include ledger events at or before this ISO timestamp.")
@@ -3983,6 +4009,116 @@ export function createProgram() {
3983
4009
  method: "DELETE",
3984
4010
  }));
3985
4011
  }));
4012
+ program.addCommand(new Command("egress")
4013
+ .description("Managed mailbox-egress IP pool: status, inventory, dedicated-IP add-on, and rotation.")
4014
+ .addCommand(new Command("status")
4015
+ .description("Show egress mode, this org's open mailbox->IP assignments, and IP health counts. Read-only, 0 credits.")
4016
+ .option("--json", "Print a JSON envelope.")
4017
+ .action(async (options) => {
4018
+ await handleAsyncAction("egress status", options, () => requestOxygen("/api/cli/egress"));
4019
+ }))
4020
+ .addCommand(new Command("ips")
4021
+ .description("List the egress IPs this org may send through (shared pool for this env + any dedicated IP). Read-only.")
4022
+ .option("--json", "Print a JSON envelope.")
4023
+ .action(async (options) => {
4024
+ await handleAsyncAction("egress ips", options, () => requestOxygen("/api/cli/egress?view=ips"));
4025
+ }))
4026
+ .addCommand(new Command("dedicated")
4027
+ .description("Dedicated-egress-IP add-on ($400/mo): request a preview quote or check the add-on status. PREVIEW ONLY — never charges; checkout is human via Stripe.")
4028
+ .addCommand(new Command("request")
4029
+ .description("Preview the dedicated-egress-IP add-on ($400/mo). PREVIEW ONLY — never charges; checkout is human via Stripe.")
4030
+ .option("--json", "Print a JSON envelope.")
4031
+ .action(async (options) => {
4032
+ await handleAsyncAction("egress dedicated request", options, () => requestOxygen("/api/cli/egress/dedicated", { method: "POST", body: {} }));
4033
+ }))
4034
+ .addCommand(new Command("status")
4035
+ .description("Show this org's dedicated-egress-IP add-on state (status, quantity, activation, price). Read-only, 0 credits.")
4036
+ .option("--json", "Print a JSON envelope.")
4037
+ .action(async (options) => {
4038
+ await handleAsyncAction("egress dedicated status", options, () => requestOxygen("/api/cli/egress/dedicated"));
4039
+ })))
4040
+ .addCommand(new Command("rotate")
4041
+ .description("Rotate a mailbox's egress IP. Approval-gated: without --approve it previews and makes no change.")
4042
+ .requiredOption("--mailbox <id>", "Mailbox id to rotate.")
4043
+ .option("--vendor <vendor>", "Egress vendor (pingproxies|decodo). Defaults to the current IP's vendor.")
4044
+ .option("--approve", "Execute the rotation (paid vendor action). Omit for a no-side-effect preview.")
4045
+ .option("--json", "Print a JSON envelope.")
4046
+ .action(async (options) => {
4047
+ await handleAsyncAction("egress rotate", options, () => requestOxygen("/api/cli/egress/rotate", {
4048
+ method: "POST",
4049
+ body: {
4050
+ mailbox_id: readOption(options.mailbox),
4051
+ ...(readOption(options.vendor) ? { vendor: readOption(options.vendor) } : {}),
4052
+ ...(options.approve ? { approve: true } : {}),
4053
+ },
4054
+ }));
4055
+ })));
4056
+ program
4057
+ .command("directory")
4058
+ .description("Manage this organization's public OXYGEN agency directory listing.")
4059
+ .addCommand(new Command("get")
4060
+ .description("Read the listing, its publish state, and its public/settings deep-links.")
4061
+ .option("--json", "Print a JSON envelope.")
4062
+ .action(async (options) => {
4063
+ await handleAsyncAction("directory get", options, () => requestOxygen("/api/cli/directory/listing"));
4064
+ }))
4065
+ .addCommand(new Command("update")
4066
+ .description("Update listing profile fields. Only invited organizations can edit.")
4067
+ .option("--name <text>", "Agency display name.")
4068
+ .option("--slug <slug>", "Public URL slug (lowercase letters, digits, dashes).")
4069
+ .option("--logo-url <url>", "Logo image URL.")
4070
+ .option("--tagline <text>", "One-line pitch shown on the directory card.")
4071
+ .option("--description <text>", "Full profile description (plain text; blank lines separate paragraphs).")
4072
+ .option("--description-file <path>", "Path to a plain-text file with the description.")
4073
+ .option("--website <url>", "Company website URL.")
4074
+ .option("--company-linkedin <url>", "Company LinkedIn page URL.")
4075
+ .option("--founders-json <json>", "JSON array of founding-team members: [{\"name\", \"title\", \"linkedin_url\"}].")
4076
+ .option("--booking-url <url>", "Book-a-call URL (Cal.com, Calendly, ...).")
4077
+ .option("--contact-email <email>", "Public contact email.")
4078
+ .option("--logo-file <path>", "Upload a local png/jpeg/webp/svg logo instead of passing --logo-url.")
4079
+ .option("--services <csv>", `Comma-separated services from the curated set: ${AGENCY_DIRECTORY_SERVICES.join(", ")}.`)
4080
+ .option("--regions <csv>", `Comma-separated regions from the curated set: ${AGENCY_DIRECTORY_REGIONS.join(", ")}.`)
4081
+ .option("--profile-json <json>", "JSON object with any listing fields; flags override it. Use null values to clear fields.")
4082
+ .option("--json", "Print a JSON envelope.")
4083
+ .action(async (options) => {
4084
+ await handleAsyncAction("directory update", options, async () => {
4085
+ const logoPath = readOption(options.logoFile);
4086
+ const uploaded = logoPath ? await uploadDirectoryLogo(logoPath) : null;
4087
+ const profile = await buildDirectoryProfileBody(options);
4088
+ if (Object.keys(profile).length === 0 && uploaded)
4089
+ return uploaded;
4090
+ return requestOxygen("/api/cli/directory/listing/update", {
4091
+ method: "POST",
4092
+ body: { profile },
4093
+ });
4094
+ });
4095
+ }))
4096
+ .addCommand(new Command("publish")
4097
+ .description("Publish the listing to the public directory at https://oxygen-agent.com/agencies.")
4098
+ .option("--json", "Print a JSON envelope.")
4099
+ .action(async (options) => {
4100
+ await handleAsyncAction("directory publish", options, () => requestOxygen("/api/cli/directory/listing/publish", { method: "POST", body: {} }));
4101
+ }))
4102
+ .addCommand(new Command("unpublish")
4103
+ .description("Remove the listing from the public directory (keeps the draft editable).")
4104
+ .option("--json", "Print a JSON envelope.")
4105
+ .action(async (options) => {
4106
+ await handleAsyncAction("directory unpublish", options, () => requestOxygen("/api/cli/directory/listing/unpublish", { method: "POST", body: {} }));
4107
+ }))
4108
+ .addCommand(new Command("apply")
4109
+ .description("Apply to be listed in the public agency directory (reviewed by the OXYGEN team).")
4110
+ .option("--message <text>", "What you do, who you serve, and how you use OXYGEN for client work.")
4111
+ .option("--website <url>", "Your agency website URL.")
4112
+ .option("--json", "Print a JSON envelope.")
4113
+ .action(async (options) => {
4114
+ await handleAsyncAction("directory apply", options, () => requestOxygen("/api/cli/directory/apply", {
4115
+ method: "POST",
4116
+ body: {
4117
+ ...(readOption(options.message) ? { message: readOption(options.message) } : {}),
4118
+ ...(readOption(options.website) ? { website: readOption(options.website) } : {}),
4119
+ },
4120
+ }));
4121
+ }));
3986
4122
  program
3987
4123
  .command("admin")
3988
4124
  .description("Staff-only commands.")
@@ -4004,7 +4140,55 @@ export function createProgram() {
4004
4140
  const suffix = params.toString() ? `?${params.toString()}` : "";
4005
4141
  return requestOxygen(`/api/cli/admin/costs${suffix}`);
4006
4142
  });
4007
- }));
4143
+ }))
4144
+ .addCommand(new Command("directory")
4145
+ .description("Curate the public agency directory (invite, prefill, delist). Staff only.")
4146
+ .addCommand(new Command("list")
4147
+ .description("List every agency directory listing across all organizations.")
4148
+ .option("--json", "Print a JSON envelope.")
4149
+ .action(async (options) => {
4150
+ await handleAsyncAction("admin directory list", options, () => requestOxygen("/api/cli/admin/directory"));
4151
+ }))
4152
+ .addCommand(new Command("enable")
4153
+ .description("Invite an organization to the directory, optionally prefilling its profile.")
4154
+ .argument("<organization>", "Organization id, slug, or Clerk org id.")
4155
+ .option("--name <text>", "Agency display name (defaults to the organization name).")
4156
+ .option("--slug <slug>", "Public URL slug (defaults to a slugified name).")
4157
+ .option("--logo-url <url>", "Logo image URL.")
4158
+ .option("--tagline <text>", "One-line pitch shown on the directory card.")
4159
+ .option("--description <text>", "Full profile description (plain text; blank lines separate paragraphs).")
4160
+ .option("--description-file <path>", "Path to a plain-text file with the description.")
4161
+ .option("--website <url>", "Company website URL.")
4162
+ .option("--company-linkedin <url>", "Company LinkedIn page URL.")
4163
+ .option("--founders-json <json>", "JSON array of founding-team members: [{\"name\", \"title\", \"linkedin_url\"}].")
4164
+ .option("--booking-url <url>", "Book-a-call URL (Cal.com, Calendly, ...).")
4165
+ .option("--contact-email <email>", "Public contact email.")
4166
+ .option("--services <csv>", `Comma-separated services from the curated set: ${AGENCY_DIRECTORY_SERVICES.join(", ")}.`)
4167
+ .option("--regions <csv>", `Comma-separated regions from the curated set: ${AGENCY_DIRECTORY_REGIONS.join(", ")}.`)
4168
+ .option("--featured", "Pin the listing to the top of the public directory.")
4169
+ .option("--profile-json <json>", "JSON object with any listing fields; flags override it.")
4170
+ .option("--json", "Print a JSON envelope.")
4171
+ .action(async (organization, options) => {
4172
+ await handleAsyncAction("admin directory enable", options, async () => {
4173
+ const profile = await buildDirectoryProfileBody(options);
4174
+ if (options.featured)
4175
+ profile.featured = true;
4176
+ return requestOxygen("/api/cli/admin/directory/enable", {
4177
+ method: "POST",
4178
+ body: { organization, profile },
4179
+ });
4180
+ });
4181
+ }))
4182
+ .addCommand(new Command("delist")
4183
+ .description("Remove an organization's listing from the public directory (kill switch).")
4184
+ .argument("<organization>", "Organization id, slug, or Clerk org id.")
4185
+ .option("--json", "Print a JSON envelope.")
4186
+ .action(async (organization, options) => {
4187
+ await handleAsyncAction("admin directory delist", options, () => requestOxygen("/api/cli/admin/directory/delist", {
4188
+ method: "POST",
4189
+ body: { organization },
4190
+ }));
4191
+ })));
4008
4192
  program
4009
4193
  .command("signup-leads")
4010
4194
  .description("Signup lead delivery queue commands.")
@@ -4765,20 +4949,29 @@ export function createProgram() {
4765
4949
  const suffix = params.toString();
4766
4950
  return requestOxygen(`/api/cli/senders${suffix ? `?${suffix}` : ""}`);
4767
4951
  });
4952
+ }))
4953
+ .addCommand(new Command("checkpoints")
4954
+ .description("List LinkedIn senders stuck on a security checkpoint (2FA / OTP / in-app validation), each with status, warm-up day, today's usage vs. caps, and the latest error — plus a fleet tally of every sender by status. Read-only; no provider call, no credits. Solve one with `senders checkpoint solve <id> <code>`.")
4955
+ .option("--json", "Print a JSON envelope.")
4956
+ .action(async (options) => {
4957
+ await handleAsyncAction("senders checkpoints", options, () => requestOxygen("/api/cli/senders/checkpoints"));
4768
4958
  }))
4769
4959
  .addCommand(new Command("connect")
4770
- .description("Get a Unipile hosted-auth URL to connect a new LinkedIn account (or reconnect with --reconnect). Open the URL in a browser to complete authentication.")
4960
+ .description("Get a Unipile hosted-auth URL to connect a new LinkedIn account (or reconnect with --reconnect). Use --count to mint several links at once for bulk onboarding. Open each URL in a browser to complete authentication.")
4771
4961
  .option("--reconnect <connection_id>", "Reconnect an existing connection instead of creating a new one. Accepts a connection id.")
4772
4962
  .option("--sales-nav", "Request Classic + Sales Navigator access during Unipile hosted authentication.")
4963
+ .option("--count <n>", "Mint N hosted-auth links in one call for bulk onboarding (1-25, default 1). Each link connects a different account. Ignored when reconnecting.")
4773
4964
  .option("--json", "Print a JSON envelope.")
4774
4965
  .action(async (options) => {
4775
4966
  await handleAsyncAction("senders connect", options, () => {
4776
4967
  const reconnect = readOption(options.reconnect);
4968
+ const count = readPositiveInt(options.count);
4777
4969
  return requestOxygen("/api/cli/senders/connect", {
4778
4970
  method: "POST",
4779
4971
  body: {
4780
4972
  ...(reconnect ? { reconnect_connection_id: reconnect } : {}),
4781
4973
  ...(options.salesNav ? { sales_nav: true } : {}),
4974
+ ...(count ? { count } : {}),
4782
4975
  },
4783
4976
  });
4784
4977
  });
@@ -4877,6 +5070,9 @@ export function createProgram() {
4877
5070
  .option("--warmup-restart", "Start (or restart) the warm-up ramp now — gradually raises this account's invite + message caps to full over ~2 weeks.")
4878
5071
  .option("--warmup-disable", "Turn off warm-up for this account (treat it as already warm and use its full configured caps).")
4879
5072
  .option("--warmup-start-date <date>", "Set the warm-up start date (ISO, e.g. 2026-01-31). A past date credits prior warming and advances the ramp.")
5073
+ .option("--warmup-preset <preset>", "Warm-up ramp curve: conservative (~3 weeks), standard (~2 weeks, default), or fast (~1 week for an aged account).")
5074
+ .option("--randomize-caps", "Randomise this account's daily send caps to 80-100% of configured so volume looks human day to day.")
5075
+ .option("--no-randomize-caps", "Turn off randomised daily caps (use the exact configured caps every day).")
4880
5076
  .option("--json", "Print a JSON envelope.")
4881
5077
  .action(async (id, options) => {
4882
5078
  await handleAsyncAction("senders limits set", options, () => {
@@ -4995,6 +5191,77 @@ export function createProgram() {
4995
5191
  return requestOxygen(`/api/cli/linkedin/viewers/status${suffix ? `?${suffix}` : ""}`);
4996
5192
  });
4997
5193
  })));
5194
+ program.addCommand(new Command("posts")
5195
+ .description("Read and publish LinkedIn posts through a connected account. `get` returns a post and the composite social_id that `comments`/`reactions` and `oxygen engagement harvest` need; `create` publishes a real post (approval-gated). All calls are metered against the sender account's daily quota.")
5196
+ .addCommand(new Command("get")
5197
+ .description("Read a LinkedIn post. Returns the post and its composite social_id (reuse that social_id for `posts comments`, `posts reactions`, and `engagement harvest --source unipile` — NOT the raw activity URN). Metered as an account read.")
5198
+ .requiredOption("--post <id>", "Numeric activity id, activity URL, or composite social_id.")
5199
+ .option("--account <ref>", "Sender account to read through (sender id, connection id, or Unipile account id). Omit for the org default.")
5200
+ .option("--json", "Print a JSON envelope.")
5201
+ .action(async (options) => {
5202
+ await handleAsyncAction("posts get", options, () => {
5203
+ const params = new URLSearchParams();
5204
+ params.set("post", readOption(options.post) ?? "");
5205
+ const account = readOption(options.account);
5206
+ if (account)
5207
+ params.set("account", account);
5208
+ return requestOxygen(`/api/cli/linkedin/posts?${params.toString()}`);
5209
+ });
5210
+ }))
5211
+ .addCommand(new Command("comments")
5212
+ .description("List a post's comments (or the replies to a comment with --comment-id). --post MUST be the composite social_id from `posts get`, not the activity URN. Metered as an account read.")
5213
+ .requiredOption("--post <social_id>", "Composite post social_id from `posts get`.")
5214
+ .option("--comment-id <id>", "List replies to this comment instead of top-level comments.")
5215
+ .option("--sort-by <sort>", "Comment sort order (provider-defined, e.g. recent|relevant).")
5216
+ .option("--cursor <cursor>", "Pagination cursor from a previous page.")
5217
+ .option("--limit <n>", "Max comments to return this page.")
5218
+ .option("--account <ref>", "Sender account to read through. Omit for the org default.")
5219
+ .option("--json", "Print a JSON envelope.")
5220
+ .action(async (options) => {
5221
+ await handleAsyncAction("posts comments", options, () => requestOxygen(`/api/cli/linkedin/posts/comments?${buildPostEngagementQuery(options)}`));
5222
+ }))
5223
+ .addCommand(new Command("reactions")
5224
+ .description("List a post's reactions (or a comment's reactions with --comment-id). --post MUST be the composite social_id from `posts get`. Metered as an account read.")
5225
+ .requiredOption("--post <social_id>", "Composite post social_id from `posts get`.")
5226
+ .option("--comment-id <id>", "List reactions to this comment instead of the post.")
5227
+ .option("--cursor <cursor>", "Pagination cursor from a previous page.")
5228
+ .option("--limit <n>", "Max reactions to return this page.")
5229
+ .option("--account <ref>", "Sender account to read through. Omit for the org default.")
5230
+ .option("--json", "Print a JSON envelope.")
5231
+ .action(async (options) => {
5232
+ await handleAsyncAction("posts reactions", options, () => requestOxygen(`/api/cli/linkedin/posts/reactions?${buildPostEngagementQuery(options)}`));
5233
+ }))
5234
+ .addCommand(new Command("create")
5235
+ .description("Publish a LinkedIn post from the connected account. A REAL public write, so it prints a preview by default and only publishes with --approved. Counts against the sender account's daily action quota. (Company-page posting is not supported — Unipile exposes it only via the fragile raw route.)")
5236
+ .option("--text <text>", "Post body text.")
5237
+ .option("--text-file <path>", "Read the post body from a file (alternative to --text).")
5238
+ .option("--account <ref>", "Sender account to post from. Omit for the org default.")
5239
+ .option("--as-organization <id>", "Publish as a company page the account administers (advanced; may require raw-route support).")
5240
+ .option("--external-link <url>", "Attach an external link to the post.")
5241
+ .option("--approved", "Publish for real. Without this flag, prints a preview only.")
5242
+ .option("--json", "Print a JSON envelope.")
5243
+ .action(async (options) => {
5244
+ await handleAsyncAction("posts create", options, () => {
5245
+ const inlineText = readOption(options.text);
5246
+ const textFile = readOption(options.textFile);
5247
+ const text = inlineText ?? (textFile ? readFileSync(resolve(textFile), "utf8") : undefined);
5248
+ if (!text || !text.trim())
5249
+ throw new Error("--text or --text-file is required to publish a post.");
5250
+ const account = readOption(options.account);
5251
+ const asOrganization = readOption(options.asOrganization);
5252
+ const externalLink = readOption(options.externalLink);
5253
+ return requestOxygen("/api/cli/linkedin/posts", {
5254
+ method: "POST",
5255
+ body: {
5256
+ text,
5257
+ ...(account ? { account } : {}),
5258
+ ...(asOrganization ? { as_organization: asOrganization } : {}),
5259
+ ...(externalLink ? { external_link: externalLink } : {}),
5260
+ ...(options.approved ? { approved: true } : {}),
5261
+ },
5262
+ });
5263
+ });
5264
+ })));
4998
5265
  program.addCommand(new Command("engagement")
4999
5266
  .description("Harvest a LinkedIn post's engagers (reactors + commenters) into an enrollable CRM static list. The harvest runs as a slow, durable background drip under a dedicated conservative read budget — it never bursts and never starves the sequencer's own reads.")
5000
5267
  .addCommand(new Command("harvest")
@@ -5112,7 +5379,100 @@ export function createProgram() {
5112
5379
  },
5113
5380
  });
5114
5381
  });
5115
- })));
5382
+ }))
5383
+ .addCommand(new Command("watch")
5384
+ .description("Declarative engagement watches (the signals wedge): stand up a watch on a post's engagers or 'who viewed my profile' that harvests people into a table and, under a standing approval, auto-enrolls them into a sequence. The watch materializes the harvest drip and enrolls newly-harvested people each cycle — the sequence still gates its own sends.")
5385
+ .addCommand(new Command("create")
5386
+ .description("Arm an engagement watch. `--kind post` watches a post's reactors + commenters (needs --post social_id; a cookieless post also needs --post-url); `--kind profile_viewers` watches 'who viewed my profile' (source unipile). With --auto-enroll it enrolls harvested people into --sequence, capped by --max-enrolls-per-day. --max-credits-per-cycle is the standing per-cycle spend cap (required for --auto-enroll and cookieless). No messages are sent by the watch itself.")
5387
+ .requiredOption("--kind <kind>", "post | profile_viewers.")
5388
+ .requiredOption("--account <ref>", "Reading LinkedIn sender (sender id, connection id, or Unipile account id).")
5389
+ .option("--post <social_id>", "Composite post social_id from `oxygen posts get` (required for --kind post).")
5390
+ .option("--post-url <url>", "Public LinkedIn post URL (required for a cookieless post watch).")
5391
+ .option("--source <source>", "cookieless | unipile. Defaults to cookieless for post, unipile for profile_viewers.")
5392
+ .option("--table <id_or_slug>", "Existing table to land harvested people into. Omit to create one.")
5393
+ .option("--sequence <id_or_slug>", "Sequence to auto-enroll harvested people into (required with --auto-enroll).")
5394
+ .option("--auto-enroll", "Auto-enroll newly harvested people into --sequence under the standing approval.")
5395
+ .option("--max-credits-per-cycle <credits>", "Standing per-cycle managed-spend cap (required for --auto-enroll and cookieless).")
5396
+ .option("--max-enrolls-per-day <n>", "Cap auto-enrollments per day.")
5397
+ .option("--recurrence <recurrence>", "once | every_6h | daily | weekly (default once).")
5398
+ .option("--json", "Print a JSON envelope.")
5399
+ .action(async (options) => {
5400
+ await handleAsyncAction("engagement watch create", options, () => {
5401
+ const kind = readOption(options.kind);
5402
+ const account = readOption(options.account);
5403
+ const post = readOption(options.post);
5404
+ const postUrl = readOption(options.postUrl);
5405
+ const source = readOption(options.source);
5406
+ const table = readOption(options.table);
5407
+ const sequence = readOption(options.sequence);
5408
+ const maxCreditsPerCycle = readPositiveNumber(options.maxCreditsPerCycle);
5409
+ const maxEnrollsPerDay = readPositiveNumber(options.maxEnrollsPerDay);
5410
+ const recurrence = readOption(options.recurrence);
5411
+ return requestOxygen("/api/cli/linkedin/engagement/watches", {
5412
+ method: "POST",
5413
+ body: {
5414
+ ...(kind ? { kind } : {}),
5415
+ ...(account ? { account } : {}),
5416
+ ...(post ? { post } : {}),
5417
+ ...(postUrl ? { post_url: postUrl } : {}),
5418
+ ...(source ? { source } : {}),
5419
+ ...(table ? { table } : {}),
5420
+ ...(sequence ? { sequence } : {}),
5421
+ ...(options.autoEnroll ? { auto_enroll: true } : {}),
5422
+ ...(maxCreditsPerCycle !== undefined ? { max_credits_per_cycle: maxCreditsPerCycle } : {}),
5423
+ ...(maxEnrollsPerDay !== undefined ? { max_enrolls_per_day: maxEnrollsPerDay } : {}),
5424
+ ...(recurrence ? { recurrence } : {}),
5425
+ },
5426
+ });
5427
+ });
5428
+ }))
5429
+ .addCommand(new Command("list")
5430
+ .description("List the org's engagement watches (status, kind, sender, sequence, per-day enroll count) with a table deep-link.")
5431
+ .option("--status <status>", "Filter by status: active | paused | exhausted | completed.")
5432
+ .option("--kind <kind>", "Filter by kind.")
5433
+ .option("--json", "Print a JSON envelope.")
5434
+ .action(async (options) => {
5435
+ await handleAsyncAction("engagement watch list", options, () => {
5436
+ const status = readOption(options.status);
5437
+ const kind = readOption(options.kind);
5438
+ const params = new URLSearchParams();
5439
+ if (status)
5440
+ params.set("status", status);
5441
+ if (kind)
5442
+ params.set("kind", kind);
5443
+ const suffix = params.toString();
5444
+ return requestOxygen(`/api/cli/linkedin/engagement/watches${suffix ? `?${suffix}` : ""}`);
5445
+ });
5446
+ }))
5447
+ .addCommand(new Command("pause")
5448
+ .description("Pause a watch (stop materializing its harvest + auto-enrolling).")
5449
+ .argument("<watchId>", "Watch id.")
5450
+ .option("--json", "Print a JSON envelope.")
5451
+ .action(async (watchId, options) => {
5452
+ await handleAsyncAction("engagement watch pause", options, () => requestOxygen(`/api/cli/linkedin/engagement/watches/${encodeURIComponent(watchId)}`, {
5453
+ method: "PATCH",
5454
+ body: { action: "pause" },
5455
+ }));
5456
+ }))
5457
+ .addCommand(new Command("resume")
5458
+ .description("Resume a paused watch.")
5459
+ .argument("<watchId>", "Watch id.")
5460
+ .option("--json", "Print a JSON envelope.")
5461
+ .action(async (watchId, options) => {
5462
+ await handleAsyncAction("engagement watch resume", options, () => requestOxygen(`/api/cli/linkedin/engagement/watches/${encodeURIComponent(watchId)}`, {
5463
+ method: "PATCH",
5464
+ body: { action: "resume" },
5465
+ }));
5466
+ }))
5467
+ .addCommand(new Command("delete")
5468
+ .description("Delete a watch (its harvested table + rows are kept).")
5469
+ .argument("<watchId>", "Watch id.")
5470
+ .option("--json", "Print a JSON envelope.")
5471
+ .action(async (watchId, options) => {
5472
+ await handleAsyncAction("engagement watch delete", options, () => requestOxygen(`/api/cli/linkedin/engagement/watches/${encodeURIComponent(watchId)}`, {
5473
+ method: "DELETE",
5474
+ }));
5475
+ }))));
5116
5476
  program.addCommand(new Command("linkedin")
5117
5477
  .description("LinkedIn read-into-workspace ingestion controls (Goal-3): the unified status of the connections / engagement / message-history drips and each account's remaining ingest read budget.")
5118
5478
  .addCommand(new Command("ingestion")
@@ -5406,12 +5766,14 @@ export function createProgram() {
5406
5766
  .requiredOption("--text <message>", "Reply text to send.")
5407
5767
  .option("--channel <channel>", "Inbox channel: linkedin (default), whatsapp (warm reply), or email.")
5408
5768
  .option("--approved", "Approve and send the message. Without this flag, returns a preview only.")
5409
- .option("--draft-id <id>", "Email only: when approving an AI reply-agent draft, its id (marks it sent on success).")
5769
+ .option("--draft-id <id>", "When approving an AI reply-agent draft (email or LinkedIn), its id (marks it sent on success). Use the matching --channel.")
5770
+ .option("--attach <url>", "LinkedIn only: attach a file by public URL (image/document). Repeatable, up to 5.", collectRepeatable, [])
5410
5771
  .option("--json", "Print a JSON envelope.")
5411
5772
  .action(async (conversation, options) => {
5412
5773
  await handleAsyncAction("inbox send", options, () => {
5413
5774
  const channel = readOption(options.channel);
5414
5775
  const draftId = readOption(options.draftId);
5776
+ const attachments = (options.attach ?? []).map((url) => url.trim()).filter(Boolean).map((url) => ({ url }));
5415
5777
  return requestOxygen(`/api/cli/inbox/${encodeURIComponent(conversation)}/send`, {
5416
5778
  method: "POST",
5417
5779
  body: {
@@ -5419,6 +5781,7 @@ export function createProgram() {
5419
5781
  ...(channel ? { channel } : {}),
5420
5782
  ...(options.approved ? { approved: true } : {}),
5421
5783
  ...(draftId ? { draft_id: draftId } : {}),
5784
+ ...(attachments.length > 0 ? { attachments } : {}),
5422
5785
  },
5423
5786
  });
5424
5787
  });
@@ -5579,32 +5942,51 @@ export function createProgram() {
5579
5942
  .argument("<name>", "Display name.")
5580
5943
  .option("--color <hex>", "Hex color (e.g. #22c55e).")
5581
5944
  .option("--bucket <bucket>", "primary (default) or others.")
5945
+ .option("--ai-settable", "Let the AI reply classifier assign this label.")
5946
+ .option("--description <text>", "What the label means / when it applies (used by the AI classifier).")
5582
5947
  .option("--json", "Print a JSON envelope.")
5583
5948
  .action(async (name, options) => {
5584
5949
  await handleAsyncAction("inbox label create", options, () => {
5585
5950
  const color = readOption(options.color);
5586
5951
  const bucket = readOption(options.bucket);
5952
+ const description = readOption(options.description);
5587
5953
  return requestOxygen("/api/cli/inbox/labels", {
5588
5954
  method: "POST",
5589
- body: { name, ...(color ? { color } : {}), ...(bucket ? { bucket } : {}) },
5955
+ body: {
5956
+ name,
5957
+ ...(color ? { color } : {}),
5958
+ ...(bucket ? { bucket } : {}),
5959
+ ...(options.aiSettable ? { ai_settable: true } : {}),
5960
+ ...(description ? { description } : {}),
5961
+ },
5590
5962
  });
5591
5963
  });
5592
5964
  }))
5593
5965
  .addCommand(new Command("update")
5594
- .description("Update a status label's name, color, or bucket.")
5966
+ .description("Update a status label's name, color, bucket, description, or (custom labels only) ai-settable flag.")
5595
5967
  .argument("<key>", "The label key.")
5596
5968
  .option("--name <name>", "New display name.")
5597
5969
  .option("--color <hex>", "New hex color.")
5598
5970
  .option("--bucket <bucket>", "New bucket (primary or others).")
5971
+ .option("--ai-settable", "Let the AI classifier assign this label (custom labels only).")
5972
+ .option("--no-ai-settable", "Stop the AI classifier assigning this label (custom labels only).")
5973
+ .option("--description <text>", "What the label means / when it applies (used by the AI classifier).")
5599
5974
  .option("--json", "Print a JSON envelope.")
5600
5975
  .action(async (key, options) => {
5601
5976
  await handleAsyncAction("inbox label update", options, () => {
5602
5977
  const name = readOption(options.name);
5603
5978
  const color = readOption(options.color);
5604
5979
  const bucket = readOption(options.bucket);
5980
+ const description = readOption(options.description);
5605
5981
  return requestOxygen(`/api/cli/inbox/labels/${encodeURIComponent(key)}`, {
5606
5982
  method: "PATCH",
5607
- body: { ...(name ? { name } : {}), ...(color ? { color } : {}), ...(bucket ? { bucket } : {}) },
5983
+ body: {
5984
+ ...(name ? { name } : {}),
5985
+ ...(color ? { color } : {}),
5986
+ ...(bucket ? { bucket } : {}),
5987
+ ...(options.aiSettable !== undefined ? { ai_settable: options.aiSettable } : {}),
5988
+ ...(description ? { description } : {}),
5989
+ },
5608
5990
  });
5609
5991
  });
5610
5992
  }))
@@ -5618,9 +6000,10 @@ export function createProgram() {
5618
6000
  .addCommand(new Command("drafts")
5619
6001
  .description("The AI reply-agent draft queue (the approve-before-send review queue).")
5620
6002
  .addCommand(new Command("list")
5621
- .description("List drafts awaiting review (queued + edited by default).")
6003
+ .description("List drafts awaiting review (queued + edited by default), across email + LinkedIn.")
5622
6004
  .option("--status <keys>", "Comma-separated draft statuses (queued,edited,sent,rejected).")
5623
- .option("--sequence-id <id>", "Filter to one campaign (sequence) id.")
6005
+ .option("--channel <channel>", "Filter to one channel: email or linkedin.")
6006
+ .option("--sequence-id <id>", "Filter to one campaign (sequence) id (email drafts only).")
5624
6007
  .option("--limit <n>", "Maximum drafts to return.")
5625
6008
  .option("--json", "Print a JSON envelope.")
5626
6009
  .action(async (options) => {
@@ -5629,6 +6012,9 @@ export function createProgram() {
5629
6012
  const status = readOption(options.status);
5630
6013
  if (status)
5631
6014
  params.set("status", status);
6015
+ const channel = readOption(options.channel);
6016
+ if (channel)
6017
+ params.set("channel", channel);
5632
6018
  const sequenceId = readOption(options.sequenceId);
5633
6019
  if (sequenceId)
5634
6020
  params.set("sequence_id", sequenceId);
@@ -5682,6 +6068,7 @@ export function createProgram() {
5682
6068
  .option("--signature <text>", "Signature appended to drafts.")
5683
6069
  .option("--target-statuses <keys>", "Comma-separated statuses to draft for (empty = all draftable).")
5684
6070
  .option("--target-sequence-ids <ids>", "Comma-separated campaign ids to draft for (empty = all).")
6071
+ .option("--channels <list>", "Comma-separated channels to draft for: a non-empty subset of email, linkedin (default email). LinkedIn drafts are short + signature-free.")
5685
6072
  .option("--max-drafts-per-day <n>", "Per-day draft cap.")
5686
6073
  .option("--json", "Print a JSON envelope.")
5687
6074
  .action(async (options) => {
@@ -5709,6 +6096,9 @@ export function createProgram() {
5709
6096
  if (options.targetSequenceIds !== undefined) {
5710
6097
  body.target_sequence_ids = options.targetSequenceIds.split(",").map((s) => s.trim()).filter(Boolean);
5711
6098
  }
6099
+ if (options.channels !== undefined) {
6100
+ body.channels = options.channels.split(",").map((c) => c.trim()).filter(Boolean);
6101
+ }
5712
6102
  if (options.maxDraftsPerDay !== undefined)
5713
6103
  body.max_drafts_per_day = Number(options.maxDraftsPerDay);
5714
6104
  return requestOxygen("/api/cli/inbox/reply-agent", { method: "POST", body });
@@ -5844,6 +6234,12 @@ export function createProgram() {
5844
6234
  .option("--max-live-sends <n>", "Send ceiling for the email track (positive integer). Required to start an email sequence live.")
5845
6235
  .option("--max-emails-per-mailbox-per-day <n>", "Per-mailbox daily email cap (positive integer) applied across the sending pool.")
5846
6236
  .option("--send-window-file <path>", "Path to a JSON file with the sequence-level email send window: { timezone, days?, start, end, timezone_mode?, recipient_timezone_column? }.")
6237
+ .option("--max-emails-per-day <n>", "Sequence-wide daily live-send fleet cap (positive integer) across every sender/mailbox.")
6238
+ .option("--max-new-enrollments-per-day <n>", "Daily drip cap on NEW first-touch leads the planner starts (positive integer).")
6239
+ .option("--sequence-prioritization <mode>", "Under a tight daily budget, serve 'followups' or 'new_leads' first.")
6240
+ .option("--schedule-template <name>", "Name of a saved schedule template (oxygen schedules) backing the sending window.")
6241
+ .option("--esp-matching <mode>", "Native-email ESP matching: 'off' (default) rotates mailboxes freely; 'prefer' biases toward a mailbox on the recipient's own provider; 'strict' requires a same-provider mailbox and defers the send when none exists.")
6242
+ .option("--no-stop-on-bounce", "Keep a lead's enrollment running after a hard bounce (default: stop it). The bounce is still recorded as an email_bounced signal either way.")
5847
6243
  .option("--json", "Print a JSON envelope.")
5848
6244
  .action(async (options) => {
5849
6245
  await handleAsyncAction("sequences create", options, () => {
@@ -5892,6 +6288,12 @@ export function createProgram() {
5892
6288
  .option("--max-live-sends <n>", "Send ceiling for the email track (positive integer). Required to start an email sequence live.")
5893
6289
  .option("--max-emails-per-mailbox-per-day <n>", "Per-mailbox daily email cap (positive integer) applied across the sending pool.")
5894
6290
  .option("--send-window-file <path>", "Path to a JSON file with the sequence-level email send window: { timezone, days?, start, end, timezone_mode?, recipient_timezone_column? }.")
6291
+ .option("--max-emails-per-day <n>", "Sequence-wide daily live-send fleet cap (positive integer) across every sender/mailbox.")
6292
+ .option("--max-new-enrollments-per-day <n>", "Daily drip cap on NEW first-touch leads the planner starts (positive integer).")
6293
+ .option("--sequence-prioritization <mode>", "Under a tight daily budget, serve 'followups' or 'new_leads' first.")
6294
+ .option("--schedule-template <name>", "Name of a saved schedule template (oxygen schedules) backing the sending window.")
6295
+ .option("--esp-matching <mode>", "Native-email ESP matching: 'off' (default) rotates mailboxes freely; 'prefer' biases toward a mailbox on the recipient's own provider; 'strict' requires a same-provider mailbox and defers the send when none exists.")
6296
+ .option("--no-stop-on-bounce", "Keep a lead's enrollment running after a hard bounce (default: stop it). The bounce is still recorded as an email_bounced signal either way.")
5895
6297
  .option("--json", "Print a JSON envelope.")
5896
6298
  .action(async (sequence, options) => {
5897
6299
  await handleAsyncAction("sequences update", options, () => {
@@ -5927,7 +6329,7 @@ export function createProgram() {
5927
6329
  body.email = email;
5928
6330
  }
5929
6331
  if (Object.keys(body).length === 0) {
5930
- throw new Error("Provide at least one field to update (--name, --steps-file, --channels, --senders, --email-*, --clear-email, --max-credits, --max-live-sends, --max-emails-per-mailbox-per-day, or --send-window-file).");
6332
+ throw new Error("Provide at least one field to update (--name, --steps-file, --channels, --senders, --email-*, --clear-email, --max-credits, --max-live-sends, --max-emails-per-mailbox-per-day, --send-window-file, or --no-stop-on-bounce).");
5931
6333
  }
5932
6334
  return requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}`, {
5933
6335
  method: "PATCH",
@@ -5943,11 +6345,12 @@ export function createProgram() {
5943
6345
  await handleAsyncAction("sequences get", options, () => requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}`));
5944
6346
  }))
5945
6347
  .addCommand(new Command("enroll")
5946
- .description("Enroll leads into a sequence from a JSON file of { leads: [...] }. 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. The org do-not-contact list is always enforced; --exclude-contacted and --suppress-list add further opt-in skips (reported under skipped_by_reason).")
6348
+ .description("Enroll leads into a sequence from a JSON file of { leads: [...] }. 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. 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.")
5947
6349
  .argument("<sequence>", "Sequence id or slug.")
5948
6350
  .requiredOption("--leads-file <path>", "Path to a JSON file: { \"leads\": [{ lead_provider_id, lead_name, table_row_id, row_values }] }.")
5949
6351
  .option("--exclude-contacted", "Also skip leads any OTHER active sequence is already contacting (cross-campaign exclusion). Off by default.")
5950
6352
  .option("--suppress-list <ids>", "Per-call do-not-enroll lead provider ids dropped for this enroll only: a comma-separated list, or @<path> to a file of ids (comma/whitespace separated).")
6353
+ .option("--ignore-sender-bindings", "Enroll leads even when they are already owned by a sender account outside this sequence's pool (overrides the one-person-one-sender guarantee). Off by default.")
5951
6354
  .option("--json", "Print a JSON envelope.")
5952
6355
  .action(async (sequence, options) => {
5953
6356
  await handleAsyncAction("sequences enroll", options, () => {
@@ -5962,6 +6365,7 @@ export function createProgram() {
5962
6365
  leads: parsed.leads ?? [],
5963
6366
  ...(options.excludeContacted ? { exclude_contacted: true } : {}),
5964
6367
  ...(suppressList.length > 0 ? { suppress_list: suppressList } : {}),
6368
+ ...(options.ignoreSenderBindings ? { ignore_sender_bindings: true } : {}),
5965
6369
  },
5966
6370
  });
5967
6371
  });
@@ -6053,12 +6457,50 @@ export function createProgram() {
6053
6457
  await handleAsyncAction("sequences stats", options, () => requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/stats`));
6054
6458
  }))
6055
6459
  .addCommand(new Command("variants")
6056
- .description("Break the sequence's email performance down by A/B variant (per step) and by sending mailbox: sent, replied, reply rate, bounced/failed, and credits used.")
6460
+ .description("A/B scoreboard for a sequence: per-step/per-variant sent, replied, reply rate, bounced, credits, plus the reversible auto-winner state (which variants are paused + evidence). Flags run the reply-metric auto-winner or manually override/reset a variant's pause.")
6057
6461
  .argument("<sequence>", "Sequence id or slug.")
6462
+ .option("--auto-optimize", "Run the reply-metric auto-winner now: pause the statistically-losing variant(s) of any step carrying an auto_optimize config, once every variant clears its thresholds. Reversible.")
6463
+ .option("--pause <variant>", "Manually pause one variant (requires --step). Overrides the auto-winner.")
6464
+ .option("--activate <variant>", "Manually reactivate one paused variant (requires --step).")
6465
+ .option("--reset [variant]", "Clear an auto/manual decision: one variant (with --step), a whole step (--step only), or the entire sequence (no value). Reactivates the affected variants.")
6466
+ .option("--step <stepId>", "Step id to scope --pause/--activate/--reset to.")
6058
6467
  .option("--json", "Print a JSON envelope.")
6059
6468
  .action(async (sequence, options) => {
6060
6469
  await handleSequenceVariantsAction(sequence, options);
6061
6470
  })));
6471
+ program.addCommand(new Command("schedules")
6472
+ .description("Named, reusable sending-schedule templates (timezone + weekdays + from/to). Reference one from a sequence via --schedule-template so a single 'US business hours' window backs many sequences.")
6473
+ .addCommand(new Command("list")
6474
+ .description("List the org's saved schedule templates.")
6475
+ .option("--json", "Print a JSON envelope.")
6476
+ .action(async (options) => {
6477
+ await handleAsyncAction("schedules list", options, () => requestOxygen("/api/cli/schedules"));
6478
+ }))
6479
+ .addCommand(new Command("create")
6480
+ .description("Create (or update by name) a schedule template. Partial windows get sensible defaults.")
6481
+ .requiredOption("--name <name>", "Unique template name (e.g. 'US business hours').")
6482
+ .requiredOption("--timezone <tz>", "IANA timezone (e.g. America/New_York).")
6483
+ .option("--start <HH:MM>", "Inclusive daily start time.", "09:00")
6484
+ .option("--end <HH:MM>", "Exclusive daily end time.", "17:00")
6485
+ .option("--days <csv>", "Allowed ISO weekdays, 1=Mon..7=Sun (e.g. 1,2,3,4,5). Omit → weekdays default.")
6486
+ .option("--json", "Print a JSON envelope.")
6487
+ .action(async (options) => {
6488
+ await handleAsyncAction("schedules create", options, () => {
6489
+ const days = readCsvOption(options.days).map((d) => Number(d)).filter((d) => Number.isInteger(d));
6490
+ return requestOxygen("/api/cli/schedules", {
6491
+ method: "POST",
6492
+ body: {
6493
+ name: readOption(options.name),
6494
+ definition: {
6495
+ timezone: readOption(options.timezone),
6496
+ ...(readOption(options.start) ? { start: readOption(options.start) } : {}),
6497
+ ...(readOption(options.end) ? { end: readOption(options.end) } : {}),
6498
+ ...(days.length > 0 ? { days } : {}),
6499
+ },
6500
+ },
6501
+ });
6502
+ });
6503
+ })));
6062
6504
  program.addCommand(new Command("suppressions")
6063
6505
  .description("Org do-not-contact list (people/multichannel): lead provider ids the sequencer enroller always skips at plan time. list | add | remove. Consumes 0 credits.")
6064
6506
  .addCommand(new Command("list")
@@ -6132,6 +6574,11 @@ export function createProgram() {
6132
6574
  .requiredOption("--subject <text>", "Subject line.")
6133
6575
  .option("--body <text>", "Plain-text body (sent verbatim, no templating).")
6134
6576
  .option("--body-file <path>", "Read the body from a file instead of --body.")
6577
+ .option("--html <html>", "Optional HTML body — sends multipart/alternative with --body as the plain-text part.")
6578
+ .option("--html-file <path>", "Read the HTML body from a file instead of --html.")
6579
+ .option("--display-name <name>", "From display name for this send (overrides the mailbox's stored display name).")
6580
+ .option("--cc <list>", "Comma-separated Cc addresses.")
6581
+ .option("--bcc <list>", "Comma-separated Bcc addresses.")
6135
6582
  .option("--from <mailbox>", "Sending mailbox (address or id). Defaults to the pool rotation.")
6136
6583
  .option("--approved", "Approve and send. Without this flag, returns a preview only.")
6137
6584
  .option("--json", "Print a JSON envelope.")
@@ -6141,12 +6588,19 @@ export function createProgram() {
6141
6588
  if (!bodyText || !bodyText.trim()) {
6142
6589
  throw new Error("Provide --body or --body-file.");
6143
6590
  }
6591
+ const htmlBody = readOption(options.html) ?? (options.htmlFile ? readFileSync(resolve(options.htmlFile), "utf8") : undefined);
6592
+ const cc = readCsvOption(options.cc);
6593
+ const bcc = readCsvOption(options.bcc);
6144
6594
  return requestOxygen("/api/cli/email/send", {
6145
6595
  method: "POST",
6146
6596
  body: {
6147
6597
  to: readOption(options.to),
6148
6598
  subject: readOption(options.subject),
6149
6599
  body: bodyText,
6600
+ ...(htmlBody && htmlBody.trim() ? { html: htmlBody } : {}),
6601
+ ...(readOption(options.displayName) ? { display_name: readOption(options.displayName) } : {}),
6602
+ ...(cc.length > 0 ? { cc } : {}),
6603
+ ...(bcc.length > 0 ? { bcc } : {}),
6150
6604
  ...(readOption(options.from) ? { from: readOption(options.from) } : {}),
6151
6605
  ...(options.approved ? { approved: true } : {}),
6152
6606
  },
@@ -6175,6 +6629,12 @@ export function createProgram() {
6175
6629
  .option("--json", "Print a JSON envelope.")
6176
6630
  .action(async (mailbox, options) => {
6177
6631
  await handleAsyncAction("mailboxes get", options, () => requestOxygen(`/api/cli/mailboxes/${encodeURIComponent(mailbox)}`));
6632
+ }))
6633
+ .addCommand(new Command("health")
6634
+ .description("Fleet email-health: the sending pool rolled up by external deliverability reputation (healthy/degraded/critical/unknown), per-mailbox scores, connected health providers, and DIRECTIONAL recommendations. Pure read — 0 credits; never pauses a mailbox.")
6635
+ .option("--json", "Print a JSON envelope.")
6636
+ .action(async (options) => {
6637
+ await handleAsyncAction("mailboxes health", options, () => requestOxygen("/api/cli/mailboxes/health"));
6178
6638
  }))
6179
6639
  .addCommand(new Command("import")
6180
6640
  .description("Register (or refresh) sending mailboxes in bulk. Provide --file (a JSON file: { \"mailboxes\": [{ email_address, provider, workspace_external_id? }] }) or --from zapmail to pull the connected Zapmail workspace's mailbox list. Upsert is keyed by address, so re-importing never duplicates a mailbox.")
@@ -6244,6 +6704,67 @@ export function createProgram() {
6244
6704
  });
6245
6705
  });
6246
6706
  })))
6707
+ .addCommand(new Command("ramp")
6708
+ .description("Set or clear a single mailbox's per-mailbox warm-up ramp override (start/step/cap), replacing the default age-ramp tiers on that inbox without touching the rest of the pool.")
6709
+ .addCommand(new Command("set")
6710
+ .description("Set one mailbox's warm-up ramp: --start-per-day (day-0 ceiling), --increase-every-days (>=1), --step (added each period), --cap (ceiling). Pass --clear to remove the override and restore the default tiers. Consumes no Oxygen credits. <mailbox> accepts a mailbox id or email address.")
6711
+ .argument("<mailbox>", "Mailbox id or email address.")
6712
+ .option("--start-per-day <n>", "Day-0 send ceiling (non-negative whole number).")
6713
+ .option("--increase-every-days <n>", "Days between each step up (whole number >= 1).")
6714
+ .option("--step <n>", "Sends added to the ceiling each period (non-negative whole number).")
6715
+ .option("--cap <n>", "The ramp's ceiling; once reached the ramp stops climbing (non-negative whole number).")
6716
+ .option("--clear", "Remove the ramp override and restore the default warm-up tiers.")
6717
+ .option("--json", "Print a JSON envelope.")
6718
+ .action(async (mailbox, options) => {
6719
+ await handleAsyncAction("mailboxes ramp set", options, () => {
6720
+ if (options.clear) {
6721
+ return requestOxygen(`/api/cli/mailboxes/${encodeURIComponent(mailbox)}/ramp`, {
6722
+ method: "PATCH",
6723
+ body: { ramp_config: null },
6724
+ });
6725
+ }
6726
+ const startPerDay = readPositiveInteger(options.startPerDay);
6727
+ const increaseEveryDays = readPositiveInteger(options.increaseEveryDays);
6728
+ const step = readPositiveInteger(options.step);
6729
+ const cap = readPositiveInteger(options.cap);
6730
+ if (startPerDay === undefined || increaseEveryDays === undefined || step === undefined || cap === undefined) {
6731
+ throw new Error("Provide --start-per-day, --increase-every-days, --step, and --cap (or --clear to remove the override).");
6732
+ }
6733
+ return requestOxygen(`/api/cli/mailboxes/${encodeURIComponent(mailbox)}/ramp`, {
6734
+ method: "PATCH",
6735
+ body: { ramp_config: { start_per_day: startPerDay, increase_every_days: increaseEveryDays, step, cap } },
6736
+ });
6737
+ });
6738
+ })))
6739
+ .addCommand(new Command("update")
6740
+ .description("Update a sending mailbox's rich-content profile: the From display name and/or the HTML signature appended to its sends. Pass an empty string ('') to clear a field. Consumes no Oxygen credits. <mailbox> accepts a mailbox id or email address.")
6741
+ .argument("<mailbox>", "Mailbox id or email address.")
6742
+ .option("--display-name <name>", "From display name (e.g. \"Ada from Acme\"). Pass '' to clear it.")
6743
+ .option("--signature-html <html>", "HTML signature appended to every send from this mailbox. Pass '' to clear it.")
6744
+ .option("--signature-file <path>", "Read the HTML signature from a file instead of --signature-html.")
6745
+ .option("--json", "Print a JSON envelope.")
6746
+ .action(async (mailbox, options) => {
6747
+ await handleAsyncAction("mailboxes update", options, () => {
6748
+ // Detect PRESENCE (options.x !== undefined), not truthiness, so passing
6749
+ // an empty string clears the field rather than being dropped.
6750
+ const patch = {};
6751
+ if (options.displayName !== undefined)
6752
+ patch.display_name = options.displayName;
6753
+ if (options.signatureHtml !== undefined) {
6754
+ patch.signature_html = options.signatureHtml;
6755
+ }
6756
+ else if (options.signatureFile) {
6757
+ patch.signature_html = readFileSync(resolve(options.signatureFile), "utf8");
6758
+ }
6759
+ if (Object.keys(patch).length === 0) {
6760
+ throw new Error("Provide --display-name and/or --signature-html (or --signature-file).");
6761
+ }
6762
+ return requestOxygen(`/api/cli/mailboxes/${encodeURIComponent(mailbox)}`, {
6763
+ method: "PATCH",
6764
+ body: patch,
6765
+ });
6766
+ });
6767
+ }))
6247
6768
  .addCommand(new Command("connect-oauth")
6248
6769
  .description("Provision Zapmail Custom OAuth across the mailbox pool: registers Oxygen's own OAuth app with Zapmail, which authorizes it across the Workspaces so native send needs no per-Workspace delegation. BYOK — Zapmail bills your account, 0 Oxygen credits. Pass --status <export_id> to poll a run.")
6249
6770
  .option("--provider <provider>", "Mailbox provider to provision: google or microsoft.")
@@ -6276,6 +6797,12 @@ export function createProgram() {
6276
6797
  },
6277
6798
  });
6278
6799
  });
6800
+ }))
6801
+ .addCommand(new Command("oauth-health")
6802
+ .description("Show the delegation-stuck sending mailboxes: Zapmail-linked google inboxes still on auth_mode=delegation with no OAuth grant, each with its Custom OAuth budget (attempts in the last 7 days, remaining re-export posts before Zapmail's 3-per-mailbox-per-7-day cap, last error, and next safe retry time). Read-only — 0 Oxygen credits.")
6803
+ .option("--json", "Print a JSON envelope.")
6804
+ .action(async (options) => {
6805
+ await handleAsyncAction("mailboxes oauth-health", options, () => requestOxygen("/api/cli/mailboxes/oauth-health"));
6279
6806
  }))
6280
6807
  .addCommand(new Command("warmup")
6281
6808
  .description("Mailbox warmup via Instantly (BYOK — Instantly bills your account, 0 Oxygen credits).")
@@ -6319,6 +6846,96 @@ export function createProgram() {
6319
6846
  const suffix = params.toString();
6320
6847
  return requestOxygen(`/api/cli/mailboxes/warmup/status${suffix ? `?${suffix}` : ""}`);
6321
6848
  });
6849
+ })))
6850
+ .addCommand(new Command("order")
6851
+ .description("Order NEW sending mailboxes (domain + inboxes + warmup) on the OXYGEN-managed Zapmail wallet, billed to Oxygen credits. Without --approved returns a priced preview + quote_id. FAILS CLOSED (409) until managed ordering is enabled.")
6852
+ .requiredOption("--provider <provider>", "Mailbox provider: google or microsoft.")
6853
+ .requiredOption("--domains <domains>", "Comma-separated sending domains (e.g. send.acme.com,mail.acme.io).")
6854
+ .option("--mailboxes-per-domain <n>", "Mailboxes to provision per domain (positive whole number). Defaults to 1.")
6855
+ .option("--workspace <id>", "Optional Zapmail workspace key to provision into.")
6856
+ .option("--approved", "Place the order (requires --quote from a fresh preview).")
6857
+ .option("--quote <id>", "The quote_id from a fresh preview (required with --approved).")
6858
+ .option("--json", "Print a JSON envelope.")
6859
+ .action(async (options) => {
6860
+ await handleAsyncAction("mailboxes order", options, () => {
6861
+ const domains = readOption(options.domains)?.split(",").map((d) => d.trim()).filter(Boolean) ?? [];
6862
+ const perDomain = readOption(options.mailboxesPerDomain);
6863
+ const workspace = readOption(options.workspace);
6864
+ const quote = readOption(options.quote);
6865
+ return requestOxygen("/api/cli/mailboxes/order", {
6866
+ method: "POST",
6867
+ body: {
6868
+ provider: readOption(options.provider),
6869
+ domains,
6870
+ ...(perDomain ? { mailboxes_per_domain: Number(perDomain) } : {}),
6871
+ ...(workspace ? { workspace_id: workspace } : {}),
6872
+ ...(options.approved ? { approved: true } : {}),
6873
+ ...(quote ? { quote_id: quote } : {}),
6874
+ },
6875
+ });
6876
+ });
6877
+ }))
6878
+ .addCommand(new Command("orders")
6879
+ .description("List the org's managed mailbox provisioning orders (newest first).")
6880
+ .option("--status <status>", "Comma-separated status filter (e.g. provisioning,warming).")
6881
+ .option("--json", "Print a JSON envelope.")
6882
+ .action(async (options) => {
6883
+ await handleAsyncAction("mailboxes orders", options, () => {
6884
+ const params = new URLSearchParams();
6885
+ const status = readOption(options.status);
6886
+ if (status)
6887
+ params.set("status", status);
6888
+ const suffix = params.toString();
6889
+ return requestOxygen(`/api/cli/mailboxes/orders${suffix ? `?${suffix}` : ""}`);
6890
+ });
6891
+ }))
6892
+ .addCommand(new Command("order-status")
6893
+ .description("Show one managed mailbox order's status (provider, domains, status, quote, held credits, Zapmail refs).")
6894
+ .argument("<order>", "Mailbox order id.")
6895
+ .option("--json", "Print a JSON envelope.")
6896
+ .action(async (order, options) => {
6897
+ await handleAsyncAction("mailboxes order-status", options, () => requestOxygen(`/api/cli/mailboxes/orders/${encodeURIComponent(order)}`));
6898
+ })));
6899
+ program.addCommand(new Command("deliverability")
6900
+ .description("External email deliverability: fleet reputation health and DIRECTIONAL inbox-placement (spam) tests. Placement tests are approval-gated paid runs (managed = 2 credits, BYOK = 0); fail closed with 409 when no health provider is connected.")
6901
+ .addCommand(new Command("placement-test")
6902
+ .description("Directional inbox-placement (spam) tests: run, list, and get. Results estimate inbox-vs-spam landing from seed inboxes.")
6903
+ .addCommand(new Command("run")
6904
+ .description("Start a placement test for one sending mailbox. Without --approved this returns a PREVIEW (billing mode + credits_required). Re-run with --approved and --max-credits >= credits_required to send.")
6905
+ .argument("<mailbox>", "Sending mailbox address to test (e.g. ada@send.acme.com).")
6906
+ .option("--subject <subject>", "Optional subject for the seed message.")
6907
+ .option("--body <text>", "Optional plain-text body for the seed message.")
6908
+ .option("--approved", "Actually run the test (otherwise a preview is returned).")
6909
+ .option("--max-credits <n>", "Credit cap the caller accepts (must be >= credits_required for managed runs).")
6910
+ .option("--json", "Print a JSON envelope.")
6911
+ .action(async (mailbox, options) => {
6912
+ await handleAsyncAction("deliverability placement-test run", options, () => requestOxygen("/api/cli/deliverability/placement-tests", {
6913
+ method: "POST",
6914
+ body: {
6915
+ mailbox,
6916
+ ...(readOption(options.subject) ? { subject: readOption(options.subject) } : {}),
6917
+ ...(readOption(options.body) ? { body_text: readOption(options.body) } : {}),
6918
+ ...(options.approved ? { approved: true } : {}),
6919
+ ...(readOption(options.maxCredits) ? { max_credits: Number(readOption(options.maxCredits)) } : {}),
6920
+ },
6921
+ }));
6922
+ }))
6923
+ .addCommand(new Command("list")
6924
+ .description("List recent directional inbox-placement tests with status, billing mode, seed set, score, and credits used.")
6925
+ .option("--limit <n>", "Max rows (default 50, cap 200).")
6926
+ .option("--json", "Print a JSON envelope.")
6927
+ .action(async (options) => {
6928
+ await handleAsyncAction("deliverability placement-test list", options, () => {
6929
+ const limit = readOption(options.limit);
6930
+ return requestOxygen(`/api/cli/deliverability/placement-tests${limit ? `?limit=${encodeURIComponent(limit)}` : ""}`);
6931
+ });
6932
+ }))
6933
+ .addCommand(new Command("get")
6934
+ .description("Get one directional inbox-placement test by id — status, seed inboxes, per-provider results, and score.")
6935
+ .argument("<id>", "Placement test id.")
6936
+ .option("--json", "Print a JSON envelope.")
6937
+ .action(async (id, options) => {
6938
+ await handleAsyncAction("deliverability placement-test get", options, () => requestOxygen(`/api/cli/deliverability/placement-tests/${encodeURIComponent(id)}`));
6322
6939
  }))));
6323
6940
  program.addCommand(new Command("domains")
6324
6941
  .description("Cold-email domain management on the org's own Cloudflare account (BYOK): sync zones, inspect age/warmup/DNS health, check availability and pricing, and buy domains. Purchases bill your Cloudflare payment method, never Oxygen credits.")
@@ -6359,11 +6976,70 @@ export function createProgram() {
6359
6976
  await handleAsyncAction("domains get", options, () => requestOxygen(`/api/cli/domains/${encodeURIComponent(domain)}`));
6360
6977
  }))
6361
6978
  .addCommand(new Command("dns")
6362
- .description("Run a live read-only DNS health check (SPF, DKIM, DMARC, MX) against the domain's Cloudflare zone and persist the result.")
6979
+ .description("Run a live read-only DNS health check (SPF, DKIM, DMARC, MX) against the domain's Cloudflare zone and persist the result. Add --deep for content-level checks (SPF DNS-lookup budget, DMARC enforcement, MX provider) plus an external DNS-over-HTTPS nameserver-authority check. Use `dns plan`/`dns apply` to fix records.")
6980
+ .argument("[domain]", "Domain name, such as acme.com.")
6981
+ .option("--deep", "Add content-level DNS analysis (SPF lookup budget, DMARC enforcement, MX provider) and an external DoH nameserver-authority check.")
6982
+ .option("--json", "Print a JSON envelope.")
6983
+ .action(async (domain, options) => {
6984
+ if (!domain) {
6985
+ throw new Error("Provide a domain (oxygen domains dns <domain>) or use a subcommand: `dns plan` / `dns apply`.");
6986
+ }
6987
+ const suffix = options.deep ? "?deep=true" : "";
6988
+ await handleAsyncAction("domains dns", options, () => requestOxygen(`/api/cli/domains/${encodeURIComponent(domain)}/dns${suffix}`));
6989
+ })
6990
+ .addCommand(new Command("plan")
6991
+ .description("Preview the cold-email DNS records a domain should hold for its mailbox provider (SPF, per-provider DKIM, DMARC, MX, optional tracking) as a create/update/keep/conflict diff plus a plan_hash. FREE — nothing is written.")
6992
+ .argument("<domain>", "Domain name, such as acme.com.")
6993
+ .requiredOption("--provider <provider>", "Mailbox provider: google or microsoft (determines SPF/MX/DKIM).")
6994
+ .option("--dmarc-policy <policy>", "DMARC policy: none (default), quarantine, or reject.")
6995
+ .option("--dmarc-rua <mailbox>", "DMARC aggregate-report mailbox (rua=mailto:...).")
6996
+ .option("--tracking-host <host>", "Open/click tracking CNAME host (only planned when tracking is configured).")
6997
+ .option("--no-auto-dkim", "Skip auto-deriving Microsoft DKIM selector1/selector2 CNAMEs (default: auto-derive them for microsoft when no DKIM is supplied).")
6998
+ .option("--json", "Print a JSON envelope.")
6999
+ .action(async (domain, options) => {
7000
+ await handleAsyncAction("domains dns plan", options, () => runDomainsDnsPlan(domain, options));
7001
+ }))
7002
+ .addCommand(new Command("apply")
7003
+ .description("Write the planned cold-email DNS records into the domain's Cloudflare zone. Requires a fresh plan_hash from `dns plan`. No Oxygen credits (Cloudflare does not charge for DNS writes). Without --approved, re-previews the plan.")
6363
7004
  .argument("<domain>", "Domain name, such as acme.com.")
7005
+ .requiredOption("--provider <provider>", "Mailbox provider: google or microsoft (must match the plan).")
7006
+ .option("--approved", "Write the records. Without this flag, returns a plan preview only.")
7007
+ .option("--plan <hash>", "plan_hash from a fresh `dns plan` (required with --approved).")
7008
+ .option("--dmarc-policy <policy>", "DMARC policy: none (default), quarantine, or reject.")
7009
+ .option("--dmarc-rua <mailbox>", "DMARC aggregate-report mailbox (rua=mailto:...).")
7010
+ .option("--tracking-host <host>", "Open/click tracking CNAME host (only applied when tracking is configured).")
7011
+ .option("--no-auto-dkim", "Skip auto-deriving Microsoft DKIM (must match the value used in `dns plan` so the plan_hash matches).")
6364
7012
  .option("--json", "Print a JSON envelope.")
6365
7013
  .action(async (domain, options) => {
6366
- await handleAsyncAction("domains dns", options, () => requestOxygen(`/api/cli/domains/${encodeURIComponent(domain)}/dns`));
7014
+ await handleAsyncAction("domains dns apply", options, () => runDomainsDnsApply(domain, options));
7015
+ })))
7016
+ .addCommand(new Command("tracking")
7017
+ .description("Open/click tracking domain for native cold email: provision the tracking CNAME on the domain's Cloudflare zone and check its verification status. 0 Oxygen credits.")
7018
+ .addCommand(new Command("setup")
7019
+ .description("Provision the open/click tracking CNAME (<track-host> CNAME <tracking target>) on the domain's Cloudflare zone. Without --approve, previews the exact record. GATE: needs EMAIL_TRACKING_CNAME_TARGET configured + the tracking hostname attached on Vercel; send-time injection stays off until EMAIL_TRACKING_SECRET is set.")
7020
+ .argument("<domain>", "Sending domain, such as acme.com.")
7021
+ .option("--host <host>", "Tracking subdomain (default track.<domain>); must be a subdomain of the domain.")
7022
+ .option("--approve", "Write the CNAME. Without this flag, returns a preview only.")
7023
+ .option("--json", "Print a JSON envelope.")
7024
+ .action(async (domain, options) => {
7025
+ await handleAsyncAction("domains tracking setup", options, () => requestOxygen(`/api/cli/domains/${encodeURIComponent(domain)}/tracking`, {
7026
+ method: "POST",
7027
+ body: { ...(options.host ? { host: options.host } : {}), ...(options.approve ? { approved: true } : {}) },
7028
+ }));
7029
+ }))
7030
+ .addCommand(new Command("status")
7031
+ .description("Show the open/click tracking status for a domain (configured host, verification status, CNAME target, whether the tracking secret is set). Reads the cache — 0 Oxygen credits.")
7032
+ .argument("<domain>", "Sending domain, such as acme.com.")
7033
+ .option("--json", "Print a JSON envelope.")
7034
+ .action(async (domain, options) => {
7035
+ await handleAsyncAction("domains tracking status", options, () => requestOxygen(`/api/cli/domains/${encodeURIComponent(domain)}/tracking`));
7036
+ })))
7037
+ .addCommand(new Command("add")
7038
+ .description("Onboard a domain registered elsewhere by creating a Cloudflare zone for it, so its DNS can be managed here. Returns the nameservers to set at your registrar. Idempotent.")
7039
+ .argument("<domain>", "Apex domain to add, such as acme.com.")
7040
+ .option("--json", "Print a JSON envelope.")
7041
+ .action(async (domain, options) => {
7042
+ await handleAsyncAction("domains add", options, () => requestOxygen("/api/cli/domains/add", { method: "POST", body: { domain } }));
6367
7043
  }))
6368
7044
  .addCommand(new Command("search")
6369
7045
  .description("Search Cloudflare Registrar for available domains with registration/renewal pricing. Free — nothing is purchased.")
@@ -8674,6 +9350,44 @@ function domainsBuyRerunCommand(domains, quoteId, options, data) {
8674
9350
  ];
8675
9351
  return `${resolveCliBinaryName()} domains buy ${domains.join(" ")} ${flags.join(" ")}`;
8676
9352
  }
9353
+ function dnsPlanBody(options) {
9354
+ const provider = readOption(options.provider);
9355
+ if (!provider) {
9356
+ throw new Error("--provider <google|microsoft> is required so the correct SPF/MX/DKIM records can be planned.");
9357
+ }
9358
+ return {
9359
+ provider,
9360
+ ...(readOption(options.dmarcPolicy) ? { dmarc_policy: readOption(options.dmarcPolicy) } : {}),
9361
+ ...(readOption(options.dmarcRua) ? { dmarc_rua: readOption(options.dmarcRua) } : {}),
9362
+ ...(readOption(options.trackingHost) ? { tracking_host: readOption(options.trackingHost) } : {}),
9363
+ ...(options.autoDkim === false ? { auto_dkim: false } : {}),
9364
+ };
9365
+ }
9366
+ // Preview the cold-email DNS plan for a domain (free — no writes). Appends a
9367
+ // copy-paste rerun command carrying the plan_hash, mirroring the buy quote flow.
9368
+ async function runDomainsDnsPlan(domain, options) {
9369
+ const data = await requestOxygen(`/api/cli/domains/${encodeURIComponent(domain)}/dns/plan`, { method: "POST", body: dnsPlanBody(options) });
9370
+ const planHash = readRecordString(data, "plan_hash");
9371
+ const provider = readOption(options.provider);
9372
+ return planHash && provider
9373
+ ? { ...data, rerun_command: `${resolveCliBinaryName()} domains dns apply ${domain} --provider ${provider} --approved --plan ${planHash}` }
9374
+ : data;
9375
+ }
9376
+ // Apply an approved DNS plan (writes records). Requires --plan <hash> with
9377
+ // --approved, mirroring `domains buy --quote`.
9378
+ async function runDomainsDnsApply(domain, options) {
9379
+ const planHash = readOption(options.plan);
9380
+ if (options.approved && !planHash) {
9381
+ throw new Error("--plan <hash> is required with --approved. Run `domains dns plan` first to get the plan and its hash.");
9382
+ }
9383
+ return requestOxygen(`/api/cli/domains/${encodeURIComponent(domain)}/dns/apply`, {
9384
+ method: "POST",
9385
+ body: {
9386
+ ...dnsPlanBody(options),
9387
+ ...(options.approved ? { approved: true, plan_hash: planHash } : {}),
9388
+ },
9389
+ });
9390
+ }
8677
9391
  // Staff import of pre-existing managed-account domains. Exactly one of an explicit
8678
9392
  // list or --all; without --yes the server returns a preview and the CLI appends a
8679
9393
  // copy-paste rerun command to execute it.
@@ -10359,11 +11073,32 @@ async function handleSequenceSignalAction(sequence, options) {
10359
11073
  }
10360
11074
  async function handleSequenceVariantsAction(sequence, options) {
10361
11075
  try {
10362
- const data = await requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/variants`);
11076
+ const path = `/api/cli/sequences/${encodeURIComponent(sequence)}/variants`;
11077
+ let body = null;
11078
+ if (options.autoOptimize)
11079
+ body = { action: "auto_optimize" };
11080
+ else if (options.pause !== undefined)
11081
+ body = { action: "pause", step_id: options.step, variant: options.pause };
11082
+ else if (options.activate !== undefined)
11083
+ body = { action: "activate", step_id: options.step, variant: options.activate };
11084
+ else if (options.reset !== undefined)
11085
+ body = {
11086
+ action: "reset",
11087
+ ...(options.step ? { step_id: options.step } : {}),
11088
+ ...(typeof options.reset === "string" ? { variant: options.reset } : {}),
11089
+ };
11090
+ const data = body
11091
+ ? await requestOxygen(path, { method: "POST", body })
11092
+ : await requestOxygen(path);
10363
11093
  if (options.json) {
10364
11094
  writeJson(success("sequences variants", data));
10365
11095
  return;
10366
11096
  }
11097
+ if (body) {
11098
+ const link = data.deepLink ?? data.web_url;
11099
+ process.stdout.write(`${data.note ?? "Done."}${link ? ` ${link}` : ""}\n`);
11100
+ return;
11101
+ }
10367
11102
  process.stdout.write(formatSequenceVariants(data));
10368
11103
  }
10369
11104
  catch (error) {
@@ -10709,6 +11444,33 @@ function ansi(enabled) {
10709
11444
  function readOption(value) {
10710
11445
  return value?.trim() ? value.trim() : null;
10711
11446
  }
11447
+ // Commander collector for a repeatable option (e.g. --attach <url> --attach <url>):
11448
+ // accumulates each value into an array.
11449
+ function collectRepeatable(value, previous) {
11450
+ return [...previous, value];
11451
+ }
11452
+ // Shared query string for `posts comments` / `posts reactions` (post social_id +
11453
+ // optional comment scope, pagination, sort, and account selection).
11454
+ function buildPostEngagementQuery(options) {
11455
+ const params = new URLSearchParams();
11456
+ params.set("post", readOption(options.post) ?? "");
11457
+ const commentId = readOption(options.commentId);
11458
+ if (commentId)
11459
+ params.set("comment_id", commentId);
11460
+ const sortBy = readOption(options.sortBy);
11461
+ if (sortBy)
11462
+ params.set("sort_by", sortBy);
11463
+ const cursor = readOption(options.cursor);
11464
+ if (cursor)
11465
+ params.set("cursor", cursor);
11466
+ const limit = readOption(options.limit);
11467
+ if (limit)
11468
+ params.set("limit", limit);
11469
+ const account = readOption(options.account);
11470
+ if (account)
11471
+ params.set("account", account);
11472
+ return params.toString();
11473
+ }
10712
11474
  // Assemble the POST body for `oxygen feedback`. Reads the local chat transcript
10713
11475
  // (unless --no-transcript) and attaches a non-sensitive environment snapshot so
10714
11476
  // the Oxygen team can triage. The transcript read happens here, inside the
@@ -10759,11 +11521,15 @@ function buildSupportTicketBody(options) {
10759
11521
  body.severity = readOption(options.severity);
10760
11522
  if (readOption(options.category))
10761
11523
  body.category = readOption(options.category);
11524
+ if (readOption(options.source))
11525
+ body.source = readOption(options.source);
10762
11526
  const context = {};
10763
11527
  if (readOption(options.operation))
10764
11528
  context.operation = readOption(options.operation);
10765
11529
  if (readOption(options.errorCode))
10766
11530
  context.error_code = readOption(options.errorCode);
11531
+ if (readOption(options.slackPermalink))
11532
+ context.slack_permalink = readOption(options.slackPermalink);
10767
11533
  const runIds = readCsvOption(options.runIds);
10768
11534
  const tableIds = readCsvOption(options.tableIds);
10769
11535
  const deepLinks = readCsvOption(options.deepLinks);
@@ -10788,6 +11554,116 @@ function withSupportListQuery(path, options) {
10788
11554
  const query = params.toString();
10789
11555
  return query ? `${path}?${query}` : path;
10790
11556
  }
11557
+ // Workflow text flags (--verify-notes/--plan/--draft) bypass readOption: an
11558
+ // explicitly-passed empty string clears the field server-side, while an absent
11559
+ // flag leaves it untouched, so "" must survive to the request body.
11560
+ function buildSupportAdminWorkflowBody(options) {
11561
+ const body = {};
11562
+ const verifyStatus = readOption(options.verifyStatus);
11563
+ if (verifyStatus)
11564
+ body.verify_status = verifyStatus;
11565
+ if (options.verifyNotes !== undefined)
11566
+ body.verify_notes = options.verifyNotes;
11567
+ const planStatus = readOption(options.planStatus);
11568
+ if (planStatus)
11569
+ body.plan_status = planStatus;
11570
+ if (options.plan !== undefined)
11571
+ body.plan = options.plan;
11572
+ const draftStatus = readOption(options.draftStatus);
11573
+ if (draftStatus)
11574
+ body.draft_status = draftStatus;
11575
+ if (options.draft !== undefined)
11576
+ body.draft_reply = options.draft;
11577
+ if (!Object.keys(body).length) {
11578
+ throw new OxygenError("invalid_request", "Pass at least one of --verify-status, --verify-notes, --plan-status, --plan, --draft-status, --draft.", { exitCode: 1 });
11579
+ }
11580
+ return body;
11581
+ }
11582
+ function buildSupportAdminUpdateBody(options) {
11583
+ if (options.assign !== undefined && options.unassign) {
11584
+ throw new OxygenError("invalid_request", "Pass either --assign <email> or --unassign, not both.", {
11585
+ exitCode: 1,
11586
+ });
11587
+ }
11588
+ const body = {};
11589
+ const status = readOption(options.status);
11590
+ if (status)
11591
+ body.status = status;
11592
+ const assign = readOption(options.assign);
11593
+ if (assign)
11594
+ body.assignee_email = assign;
11595
+ if (options.unassign)
11596
+ body.assignee_email = null;
11597
+ const note = readOption(options.note);
11598
+ if (note)
11599
+ body.note = note;
11600
+ if (!Object.keys(body).length) {
11601
+ throw new OxygenError("invalid_request", "Pass at least one of --status, --assign, --unassign, --note.", { exitCode: 1 });
11602
+ }
11603
+ return body;
11604
+ }
11605
+ async function handleSupportAdminWorkflowAction(ticketId, options) {
11606
+ try {
11607
+ const body = buildSupportAdminWorkflowBody(options);
11608
+ const data = await requestOxygen(`/api/cli/admin/support/tickets/${encodeURIComponent(ticketId)}/workflow`, { method: "POST", body });
11609
+ if (options.json) {
11610
+ writeJson(success("support admin workflow", data));
11611
+ return;
11612
+ }
11613
+ process.stdout.write(formatSupportAdminWorkflowTicket(data));
11614
+ }
11615
+ catch (error) {
11616
+ const failure = toFailure("support admin workflow", error);
11617
+ writeJson(failure);
11618
+ process.exitCode = error instanceof OxygenError ? error.exitCode : 1;
11619
+ }
11620
+ }
11621
+ async function handleSupportAdminUpdateAction(ticketId, options) {
11622
+ try {
11623
+ const body = buildSupportAdminUpdateBody(options);
11624
+ const data = await requestOxygen(`/api/cli/admin/support/tickets/${encodeURIComponent(ticketId)}/update`, { method: "POST", body });
11625
+ if (options.json) {
11626
+ writeJson(success("support admin update", data));
11627
+ return;
11628
+ }
11629
+ process.stdout.write(formatSupportAdminUpdateTicket(data));
11630
+ }
11631
+ catch (error) {
11632
+ const failure = toFailure("support admin update", error);
11633
+ writeJson(failure);
11634
+ process.exitCode = error instanceof OxygenError ? error.exitCode : 1;
11635
+ }
11636
+ }
11637
+ function formatSupportAdminWorkflowStep(styles, label, status, artifactLabel, artifact) {
11638
+ const artifactNote = artifact?.trim() ? `${artifactLabel} set` : `no ${artifactLabel}`;
11639
+ return ` ${label.padEnd(6)} ${status ?? "pending"} ${styles.dim(`(${artifactNote})`)}`;
11640
+ }
11641
+ function formatSupportAdminWorkflowTicket(data) {
11642
+ const styles = ansi(output.isTTY === true && !process.env.NO_COLOR);
11643
+ const ticket = data.ticket ?? {};
11644
+ const workflow = ticket.workflow ?? {};
11645
+ const lines = [
11646
+ `${styles.bold("Workflow updated")}${ticket.subject ? styles.dim(` — ${ticket.subject}`) : ""}`,
11647
+ formatSupportAdminWorkflowStep(styles, "verify", workflow.verify_status, "notes", workflow.verify_notes),
11648
+ formatSupportAdminWorkflowStep(styles, "plan", workflow.plan_status, "plan", workflow.plan),
11649
+ formatSupportAdminWorkflowStep(styles, "draft", workflow.draft_status, "draft reply", workflow.draft_reply),
11650
+ ];
11651
+ if (ticket.admin_web_url)
11652
+ lines.push(`View ticket: ${ticket.admin_web_url}`);
11653
+ return `${lines.join("\n")}\n`;
11654
+ }
11655
+ function formatSupportAdminUpdateTicket(data) {
11656
+ const styles = ansi(output.isTTY === true && !process.env.NO_COLOR);
11657
+ const ticket = data.ticket ?? {};
11658
+ const lines = [
11659
+ `${styles.bold("Ticket updated")}${ticket.subject ? styles.dim(` — ${ticket.subject}`) : ""}`,
11660
+ ` Status: ${ticket.status ?? "?"}`,
11661
+ ` Assignee: ${ticket.assignee_email ?? "(unassigned)"}`,
11662
+ ];
11663
+ if (ticket.admin_web_url)
11664
+ lines.push(`View ticket: ${ticket.admin_web_url}`);
11665
+ return `${lines.join("\n")}\n`;
11666
+ }
10791
11667
  function readCsvOption(value) {
10792
11668
  const option = readOption(value);
10793
11669
  if (!option)
@@ -10797,6 +11673,79 @@ function readCsvOption(value) {
10797
11673
  .map((entry) => entry.trim())
10798
11674
  .filter(Boolean);
10799
11675
  }
11676
+ // Assemble the directory listing profile payload shared by `directory update`
11677
+ // and `admin directory enable`. --profile-json provides the base object (and
11678
+ // the only way to null-clear fields); explicit flags override it.
11679
+ async function buildDirectoryProfileBody(options) {
11680
+ const profile = options.profileJson
11681
+ ? parseJsonObject(options.profileJson)
11682
+ : {};
11683
+ const stringFlags = [
11684
+ [options.name, "name"],
11685
+ [options.slug, "slug"],
11686
+ [options.logoUrl, "logo_url"],
11687
+ [options.tagline, "tagline"],
11688
+ [options.description, "description"],
11689
+ [options.website, "website_url"],
11690
+ [options.companyLinkedin, "linkedin_company_url"],
11691
+ [options.bookingUrl, "booking_url"],
11692
+ [options.contactEmail, "contact_email"],
11693
+ ];
11694
+ for (const [value, key] of stringFlags) {
11695
+ const normalized = readOption(value);
11696
+ if (normalized)
11697
+ profile[key] = normalized;
11698
+ }
11699
+ if (!profile.description) {
11700
+ const path = readOption(options.descriptionFile);
11701
+ if (path) {
11702
+ const fs = await import("node:fs/promises");
11703
+ profile.description = await fs.readFile(path, "utf8");
11704
+ }
11705
+ }
11706
+ if (readOption(options.foundersJson)) {
11707
+ const founders = parseJsonValue(options.foundersJson ?? "[]", "founders-json");
11708
+ if (!Array.isArray(founders)) {
11709
+ throw new OxygenError("invalid_request", "--founders-json must be a JSON array of {name, title, linkedin_url} objects.", { exitCode: 1 });
11710
+ }
11711
+ profile.founders = founders;
11712
+ }
11713
+ const csvFlags = [
11714
+ [options.services, "services"],
11715
+ [options.regions, "regions"],
11716
+ ];
11717
+ for (const [value, key] of csvFlags) {
11718
+ if (readOption(value))
11719
+ profile[key] = readCsvOption(value);
11720
+ }
11721
+ return profile;
11722
+ }
11723
+ function directoryLogoMimeType(extension) {
11724
+ switch (extension.toLowerCase()) {
11725
+ case ".png":
11726
+ return "image/png";
11727
+ case ".jpg":
11728
+ case ".jpeg":
11729
+ return "image/jpeg";
11730
+ case ".webp":
11731
+ return "image/webp";
11732
+ case ".svg":
11733
+ return "image/svg+xml";
11734
+ default:
11735
+ throw new OxygenError("invalid_request", "--logo-file must be a .png, .jpg, .jpeg, .webp, or .svg image.", { exitCode: 1 });
11736
+ }
11737
+ }
11738
+ async function uploadDirectoryLogo(logoPath) {
11739
+ const fs = await import("node:fs/promises");
11740
+ const path = await import("node:path");
11741
+ const buffer = await fs.readFile(logoPath);
11742
+ const form = new FormData();
11743
+ form.append("file", new File([buffer], path.basename(logoPath), { type: directoryLogoMimeType(path.extname(logoPath)) }));
11744
+ return await requestOxygen("/api/cli/directory/listing/logo", {
11745
+ method: "POST",
11746
+ formData: form,
11747
+ });
11748
+ }
10800
11749
  // Assemble the optional campaign email binding from the --email-* flags. The
10801
11750
  // content spec (--email-definition-file) is the author-provided email sequence
10802
11751
  // that the API compiles to an Instantly campaign on start; provider/connection
@@ -11050,16 +11999,22 @@ options) {
11050
11999
  else if (options.warmupRestart) {
11051
12000
  warmup = { enabled: true };
11052
12001
  }
12002
+ // Opinionated-safety knobs (P3): the warm-up preset curve + randomised daily caps.
12003
+ const warmupPreset = readOption(options.warmupPreset);
12004
+ const hasPreset = warmupPreset !== null && warmupPreset !== undefined;
12005
+ const hasRandomize = options.randomizeCaps !== undefined;
11053
12006
  const hasLimits = Object.keys(limits).length > 0;
11054
12007
  const hasWorkingHours = Object.keys(workingHours).length > 0;
11055
12008
  const hasWarmup = warmup !== undefined;
11056
- if (!hasLimits && !hasWorkingHours && !hasWarmup) {
11057
- throw new OxygenError("invalid_request", "Pass at least one limit flag (e.g. --invites-per-day), --timezone, or a warm-up flag (--warmup-restart / --warmup-disable / --warmup-start-date).", { exitCode: 1 });
12009
+ if (!hasLimits && !hasWorkingHours && !hasWarmup && !hasPreset && !hasRandomize) {
12010
+ throw new OxygenError("invalid_request", "Pass at least one limit flag (e.g. --invites-per-day), --timezone, a warm-up flag (--warmup-restart / --warmup-disable / --warmup-start-date), --warmup-preset, or --randomize-caps.", { exitCode: 1 });
11058
12011
  }
11059
12012
  return {
11060
12013
  ...(hasLimits ? { limits } : {}),
11061
12014
  ...(hasWorkingHours ? { working_hours: workingHours } : {}),
11062
12015
  ...(hasWarmup ? { warmup } : {}),
12016
+ ...(hasPreset ? { warmup_preset: warmupPreset } : {}),
12017
+ ...(hasRandomize ? { randomize_daily_caps: options.randomizeCaps } : {}),
11063
12018
  };
11064
12019
  }
11065
12020
  function readPositiveInt(value) {
@@ -11137,6 +12092,27 @@ function readSequenceSettings(options) {
11137
12092
  if (phoneColumnKey) {
11138
12093
  settings.phone_column_key = phoneColumnKey;
11139
12094
  }
12095
+ // stop_on_bounce defaults ON server-side; commander's `--no-stop-on-bounce`
12096
+ // sets this to false, so only send the key when the user explicitly opts OUT.
12097
+ // Leaving it absent keeps the safe default and a settings PATCH clean.
12098
+ if (options.stopOnBounce === false) {
12099
+ settings.stop_on_bounce = false;
12100
+ }
12101
+ const maxEmailsPerDay = readPositiveInteger(options.maxEmailsPerDay);
12102
+ if (maxEmailsPerDay !== undefined)
12103
+ settings.max_emails_per_day = maxEmailsPerDay;
12104
+ const maxNewEnrollmentsPerDay = readPositiveInteger(options.maxNewEnrollmentsPerDay);
12105
+ if (maxNewEnrollmentsPerDay !== undefined)
12106
+ settings.max_new_enrollments_per_day = maxNewEnrollmentsPerDay;
12107
+ const prioritization = readOption(options.sequencePrioritization);
12108
+ if (prioritization)
12109
+ settings.sequence_prioritization = prioritization;
12110
+ const scheduleTemplate = readOption(options.scheduleTemplate);
12111
+ if (scheduleTemplate)
12112
+ settings.schedule_template = scheduleTemplate;
12113
+ const espMatching = readOption(options.espMatching);
12114
+ if (espMatching)
12115
+ settings.esp_matching = espMatching;
11140
12116
  return Object.keys(settings).length > 0 ? settings : undefined;
11141
12117
  }
11142
12118
  function muteTokenEcho() {