@oxygen-agent/cli 1.336.4 → 1.346.5

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
@@ -264,6 +264,79 @@ function writeAutomationProjection(data) {
264
264
  process.stderr.write(` allowance runs out ${exhaustion} at this pace — the schedule pauses until the window resets\n`);
265
265
  }
266
266
  }
267
+ // `workflows apply` and `workflows enable` can return a non-blocking `warnings`
268
+ // array (e.g. an aggressive cron cadence that still fits the plan's allowance).
269
+ // Mirror each to stderr as one `warning: <message>` line — matching
270
+ // writeAutomationProjection's stderr styling and keeping --json stdout clean.
271
+ function writeScheduleWarnings(data) {
272
+ if (!data || typeof data !== "object" || Array.isArray(data))
273
+ return;
274
+ const warnings = data.warnings;
275
+ if (!Array.isArray(warnings))
276
+ return;
277
+ for (const warning of warnings) {
278
+ if (typeof warning === "string" && warning.length > 0) {
279
+ process.stderr.write(`warning: ${warning}\n`);
280
+ }
281
+ }
282
+ }
283
+ // Shared human-output tail for the state-changing `workflows` commands (delete /
284
+ // rename and the bulk enable/disable/delete summaries): print the response's
285
+ // `next` action hints, then its deep link. Doctrine requires every state-changing
286
+ // response to surface an oxygen-agent.com link in the terminal.
287
+ function writeWorkflowActionTail(record) {
288
+ const next = Array.isArray(record.next) ? record.next : [];
289
+ for (const hint of next) {
290
+ if (typeof hint === "string" && hint.length > 0)
291
+ process.stdout.write(`${hint}\n`);
292
+ }
293
+ const link = typeof record.deepLink === "string"
294
+ ? record.deepLink
295
+ : typeof record.web_url === "string" ? record.web_url : null;
296
+ if (link)
297
+ process.stdout.write(`${link}\n`);
298
+ }
299
+ // Bulk `workflows enable | disable | delete` return a per-item results[] plus
300
+ // summary counts. In human (non --json) output there is no JSON dump, so render
301
+ // one readable line per item and a summary-count line to stdout. `okVerb` is the
302
+ // past-tense success verb (enabled / disabled / archived / purged).
303
+ function writeBulkWorkflowResults(data, okVerb) {
304
+ if (!data || typeof data !== "object" || Array.isArray(data))
305
+ return;
306
+ const record = data;
307
+ const results = Array.isArray(record.results) ? record.results : [];
308
+ for (const entry of results) {
309
+ if (!isRecord(entry))
310
+ continue;
311
+ const ref = typeof entry.ref === "string" ? entry.ref : "workflow";
312
+ const workflow = isRecord(entry.workflow) ? entry.workflow : null;
313
+ const slug = workflow && typeof workflow.slug === "string" ? workflow.slug : ref;
314
+ if (typeof entry.skipped_reason === "string") {
315
+ process.stdout.write(`skipped ${slug}: ${entry.skipped_reason}\n`);
316
+ }
317
+ else if (entry.ok === true) {
318
+ process.stdout.write(`${okVerb} ${slug}\n`);
319
+ }
320
+ else {
321
+ const error = isRecord(entry.error) ? entry.error : null;
322
+ const message = error && typeof error.message === "string"
323
+ ? error.message
324
+ : error && typeof error.code === "string"
325
+ ? error.code
326
+ : "failed";
327
+ process.stdout.write(`failed ${slug}: ${message}\n`);
328
+ }
329
+ }
330
+ const counts = [];
331
+ for (const key of ["archived", "purged", "enabled", "disabled", "skipped", "failed"]) {
332
+ const value = record[key];
333
+ if (typeof value === "number")
334
+ counts.push(`${value} ${key}`);
335
+ }
336
+ if (counts.length > 0)
337
+ process.stdout.write(`${counts.join(", ")}\n`);
338
+ writeWorkflowActionTail(record);
339
+ }
267
340
  // Paid live runs are refused server-side with typed spend-gate errors
268
341
  // (max_credits_required, approval_required, spend_cap_required,
269
342
  // spend_cap_too_low). Surface each as a one-line stderr hint with the concrete
@@ -759,6 +832,30 @@ function buildPublishingPostsListPath(options) {
759
832
  const suffix = query.toString();
760
833
  return suffix ? `/api/cli/publishing/posts?${suffix}` : "/api/cli/publishing/posts";
761
834
  }
835
+ function buildPublishingMentionsResolveBody(options) {
836
+ const text = readPublishingPostText(options, false);
837
+ const identifiers = (options.identifier ?? [])
838
+ .map((value) => readOption(value))
839
+ .filter((value) => Boolean(value));
840
+ const kind = readOption(options.kind);
841
+ if (kind && kind !== "person" && kind !== "company") {
842
+ throw new OxygenError("invalid_request", "--kind must be person or company.", { exitCode: 1 });
843
+ }
844
+ if (!text && identifiers.length === 0) {
845
+ throw new OxygenError("invalid_request", "Pass --text/--text-file or at least one --identifier.", { exitCode: 1 });
846
+ }
847
+ return {
848
+ account: options.account,
849
+ ...(text ? { text } : {}),
850
+ ...(identifiers.length > 0 ? {
851
+ identifiers: identifiers.map((value) => ({
852
+ value,
853
+ ...(kind ? { kind } : {}),
854
+ ...(options.asMention ? { as_mention: true } : {}),
855
+ })),
856
+ } : {}),
857
+ };
858
+ }
762
859
  function buildPublishingPostCreateBody(options) {
763
860
  const status = resolvePublishingCreateStatus(options);
764
861
  const body = {
@@ -1963,11 +2060,12 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
1963
2060
  await handleAsyncAction("support admin events", options, () => requestOxygen(withSupportEventsQuery("/api/cli/admin/support/events", options)));
1964
2061
  }))
1965
2062
  .addCommand(new Command("get")
1966
- .description("Show one support ticket with its message thread (staff only).")
2063
+ .description("Show one support ticket with a consistent message-thread snapshot (staff only).")
1967
2064
  .argument("<ticketId>", "Ticket UUID.")
2065
+ .option("--if-version <version>", "Require the exact decimal version returned by support admin events; stale versions return a conflict without exposing the thread.")
1968
2066
  .option("--json", "Print a JSON envelope.")
1969
2067
  .action(async (ticketId, options) => {
1970
- await handleAsyncAction("support admin get", options, () => requestOxygen(`/api/cli/admin/support/tickets/${encodeURIComponent(ticketId)}`));
2068
+ await handleAsyncAction("support admin get", options, () => requestOxygen(withSupportAdminGetQuery(`/api/cli/admin/support/tickets/${encodeURIComponent(ticketId)}`, options)));
1971
2069
  }))
1972
2070
  .addCommand(new Command("reply")
1973
2071
  .description("Add a staff message to a support ticket without resolving it.")
@@ -2245,6 +2343,23 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
2245
2343
  program
2246
2344
  .command("publishing")
2247
2345
  .description("Deterministic social post scheduling commands.")
2346
+ .addCommand(new Command("mentions")
2347
+ .description("Resolve LinkedIn identities for a publish-faithful post preview.")
2348
+ .addCommand(new Command("resolve")
2349
+ .description("Resolve @handles, LinkedIn profile/company links, provider ids, and URNs through a connected sender (notify=false).")
2350
+ .requiredOption("--account <account>", "LinkedIn sender id, connection id, or Unipile account id.")
2351
+ .option("--text <text>", "Post text containing @identifiers or LinkedIn URLs.")
2352
+ .option("--text-file <path>", "Read post text from a local file.")
2353
+ .option("--identifier <value...>", "Explicit provider id, public identifier, LinkedIn URL, or URN (repeat values after the flag).")
2354
+ .option("--kind <kind>", "Type for explicit identifiers: person or company.")
2355
+ .option("--as-mention", "Preview explicit identifiers as mentions instead of plain identity references.")
2356
+ .option("--json", "Print a JSON envelope.")
2357
+ .action(async (options) => {
2358
+ await handleAsyncAction("publishing mentions resolve", options, () => requestOxygen("/api/cli/publishing/mentions/resolve", {
2359
+ method: "POST",
2360
+ body: buildPublishingMentionsResolveBody(options),
2361
+ }));
2362
+ })))
2248
2363
  .addCommand(new Command("posts")
2249
2364
  .description("Manage scheduled Publishing posts.")
2250
2365
  .addCommand(new Command("list")
@@ -2267,9 +2382,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
2267
2382
  .option("--sender <sender_account_id>", "LinkedIn sender account id. Required for LinkedIn before the worker can publish.")
2268
2383
  .option("--provider-connection <connection_id>", "Oxygen integration connection id for Composio-backed providers.")
2269
2384
  .option("--title <title>", "Internal title for the queue.")
2270
- .option("--text <text>", "Post text.")
2385
+ .option("--text <text>", "Post text. For LinkedIn mentions, use @<public-identifier> directly.")
2271
2386
  .option("--text-file <path>", "Read post text from a local file.")
2272
- .option("--content-json <json>", "Optional structured provider content. For Composio providers, pass provider_arguments or composio.arguments.")
2387
+ .option("--content-json <json>", "Structured content. Attach Oxygen media with media_asset_ids; attachments is for provider-ready objects. Composio accepts provider_arguments or composio.arguments.")
2273
2388
  .option("--composio-action <slug>", "Override the Composio action slug for this scheduled post.")
2274
2389
  .option("--timezone <tz>", "Display timezone for the scheduled date. Defaults to UTC.")
2275
2390
  .option("--status <status>", "draft or scheduled. Defaults to scheduled.")
@@ -2283,7 +2398,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
2283
2398
  }));
2284
2399
  }))
2285
2400
  .addCommand(new Command("get")
2286
- .description("Get one scheduled post with attempt history.")
2401
+ .description("Get one scheduled post with resolved media previews and attempt history.")
2287
2402
  .argument("<post_id>", "Scheduled post id.")
2288
2403
  .option("--json", "Print a JSON envelope.")
2289
2404
  .action(async (postId, options) => {
@@ -2297,9 +2412,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
2297
2412
  .option("--sender <sender_account_id>", "LinkedIn sender account id.")
2298
2413
  .option("--provider-connection <connection_id>", "Oxygen integration connection id for Composio-backed providers.")
2299
2414
  .option("--title <title>", "Internal title for the queue.")
2300
- .option("--text <text>", "Post text.")
2415
+ .option("--text <text>", "Post text. For LinkedIn mentions, use @<public-identifier> directly.")
2301
2416
  .option("--text-file <path>", "Read post text from a local file.")
2302
- .option("--content-json <json>", "Optional structured provider content. For Composio providers, pass provider_arguments or composio.arguments.")
2417
+ .option("--content-json <json>", "Structured content. Attach Oxygen media with media_asset_ids; attachments is for provider-ready objects. This replaces the existing content object.")
2303
2418
  .option("--composio-action <slug>", "Override the Composio action slug for this scheduled post.")
2304
2419
  .option("--publish-at <iso>", "ISO date-time when the post should publish.")
2305
2420
  .option("--timezone <tz>", "Display timezone for the scheduled date.")
@@ -2505,11 +2620,11 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
2505
2620
  .addCommand(new Command("media")
2506
2621
  .description("Manage Publishing media uploads.")
2507
2622
  .addCommand(new Command("upload-url")
2508
- .description("Create a Publishing media asset and presigned object-storage upload URL.")
2623
+ .description("Create a Publishing media asset and presigned upload URL with required upload headers.")
2509
2624
  .requiredOption("--file-name <name>", "Original media filename.")
2510
- .requiredOption("--content-type <type>", "Media MIME type, such as image/png or video/mp4.")
2625
+ .requiredOption("--content-type <type>", "Media MIME type, such as image/png or video/mp4; use the returned upload_headers for PUT.")
2511
2626
  .requiredOption("--byte-length <bytes>", "Exact media byte length.")
2512
- .option("--scheduled-post <post_id>", "Optional scheduled post id to associate with the media.")
2627
+ .option("--scheduled-post <post_id>", "Optional provenance association; also set the post content.media_asset_ids to attach it.")
2513
2628
  .option("--metadata-json <json>", "Optional metadata object to store with the media asset.")
2514
2629
  .option("--json", "Print a JSON envelope.")
2515
2630
  .action(async (options) => {
@@ -3582,6 +3697,17 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
3582
3697
  method: "POST",
3583
3698
  body: { table, name: options.name },
3584
3699
  }));
3700
+ }))
3701
+ .addCommand(new Command("tag")
3702
+ .description("Replace a table's workspace tags (Tags primitive; \"\" clears). Tags link the table to sequences, wiki pages, and workflows carrying the same tag — browse with `oxygen tags get <tag>`.")
3703
+ .argument("<table>", "Table id or slug.")
3704
+ .requiredOption("--tags <csv>", "Comma-separated tags; \"\" clears them.")
3705
+ .option("--json", "Print a JSON envelope.")
3706
+ .action(async (table, options) => {
3707
+ await handleAsyncAction("tables tag", options, () => requestOxygen("/api/cli/tables/tags", {
3708
+ method: "POST",
3709
+ body: { table, tags: readCsvOption(options.tags) },
3710
+ }));
3585
3711
  }))
3586
3712
  .addCommand(new Command("archive")
3587
3713
  .description("Archive a workspace table without dropping physical data.")
@@ -4526,6 +4652,22 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
4526
4652
  .action(async (options) => {
4527
4653
  await handleAsyncAction("knowledge purge", options, () => runKnowledgeMirrorPurge());
4528
4654
  }));
4655
+ program
4656
+ .command("tags")
4657
+ .description("Workspace tags: one label vocabulary across knowledge pages, sequences, tables, workflows, and recipes — tag a campaign's sequence, its learnings wiki page, and its lead table alike, then browse everything that shares the label. Tags normalize to lowercase (trimmed, deduped, max 50 per item; any characters). WRITING TAGS happens on each primitive, always whole-set replace (\"\" clears): `sequences update <seq> --tags q3,launch` · `tables tag <table> --tags q3` · `workflows tag <wf> --tags q3` · `knowledge page upsert --slug <s> --tags q3 ...`. Then `tags get q3` returns everything carrying the label, deep-linked.")
4658
+ .addCommand(new Command("list")
4659
+ .description("List every workspace tag with per-primitive counts, most-used first.")
4660
+ .option("--json", "Print a JSON envelope.")
4661
+ .action(async (options) => {
4662
+ await handleAsyncAction("tags list", options, () => requestOxygen("/api/cli/tags"));
4663
+ }))
4664
+ .addCommand(new Command("get")
4665
+ .description("Everything carrying one tag — knowledge pages, sequences, tables, workflows, recipes — each with its deep-link.")
4666
+ .argument("<tag>", "The tag (case-insensitive).")
4667
+ .option("--json", "Print a JSON envelope.")
4668
+ .action(async (tag, options) => {
4669
+ await handleAsyncAction("tags get", options, () => requestOxygen(`/api/cli/tags/${encodeURIComponent(tag)}`));
4670
+ }));
4529
4671
  program
4530
4672
  .command("blueprints")
4531
4673
  .description("Scaffolding bundles: a workflow + tables + columns + prompts as shareable JSON. For guided GTM plays see `oxygen recipes`.")
@@ -6159,23 +6301,39 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6159
6301
  await handleAsyncAction("egress ips", options, () => requestOxygen("/api/cli/egress?view=ips"));
6160
6302
  }))
6161
6303
  .addCommand(new Command("dedicated")
6162
- .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.")
6304
+ .description("Dedicated sending-IP add-on (400,000 credits/mo): preview the quote, order with `request --approved`, cancel with `cancel --approve`, or check the add-on status.")
6163
6305
  .addCommand(new Command("request")
6164
- .description("Preview the dedicated-egress-IP add-on ($400/mo). PREVIEW ONLY — never charges; checkout is human via Stripe.")
6306
+ .description("Preview the dedicated sending-IP add-on (400,000 credits/mo, $400 face value; the org's mailboxes share the one dedicated IP). With --approved, ORDER it: the IP is provisioned automatically and the first month's credits are debited immediately, then monthly. Without --approved nothing is ordered or charged. While the platform vendor account is not provisioned yet, ordering fails closed and the preview says so.")
6307
+ // --approved (not --approve): the credit-spending approval flag,
6308
+ // which is also what derives spends_credits in the self-index.
6309
+ .option("--approved", "Execute the order (a real recurring credit charge). Omit for a no-side-effect preview.")
6165
6310
  .option("--json", "Print a JSON envelope.")
6166
6311
  .action(async (options) => {
6167
- await handleAsyncAction("egress dedicated request", options, () => requestOxygen("/api/cli/egress/dedicated", { method: "POST", body: {} }));
6312
+ await handleAsyncAction("egress dedicated request", options, () => requestOxygen("/api/cli/egress/dedicated", {
6313
+ method: "POST",
6314
+ body: { ...(options.approved ? { approve: true } : {}) },
6315
+ }));
6316
+ }))
6317
+ .addCommand(new Command("cancel")
6318
+ .description("Cancel the dedicated sending-IP add-on. Approval-gated: without --approve it previews. Canceling is immediate — the IP retires, mailboxes re-home to the shared pool, monthly credit billing stops; the current month is not refunded.")
6319
+ .option("--approve", "Execute the cancellation. Omit for a no-side-effect preview.")
6320
+ .option("--json", "Print a JSON envelope.")
6321
+ .action(async (options) => {
6322
+ await handleAsyncAction("egress dedicated cancel", options, () => requestOxygen("/api/cli/egress/dedicated", {
6323
+ method: "POST",
6324
+ body: { cancel: true, ...(options.approve ? { approve: true } : {}) },
6325
+ }));
6168
6326
  }))
6169
6327
  .addCommand(new Command("status")
6170
- .description("Show this org's dedicated-egress-IP add-on state (status, quantity, activation, price). Read-only, 0 credits.")
6328
+ .description("Show this org's dedicated sending-IP add-on state (status, quantity, activation, price). Read-only, 0 credits.")
6171
6329
  .option("--json", "Print a JSON envelope.")
6172
6330
  .action(async (options) => {
6173
6331
  await handleAsyncAction("egress dedicated status", options, () => requestOxygen("/api/cli/egress/dedicated"));
6174
6332
  })))
6175
6333
  .addCommand(new Command("rotate")
6176
- .description("Rotate a mailbox's egress IP. Approval-gated: without --approve it previews and makes no change.")
6334
+ .description("Rotate a mailbox's sending IP. Approval-gated: without --approve it previews and makes no change.")
6177
6335
  .requiredOption("--mailbox <id>", "Mailbox id to rotate.")
6178
- .option("--vendor <vendor>", "Egress vendor (pingproxies|decodo). Defaults to the current IP's vendor.")
6336
+ .option("--vendor <vendor>", "Staff-only vendor override. Defaults to the current IP's vendor.")
6179
6337
  .option("--approve", "Execute the rotation (paid vendor action). Omit for a no-side-effect preview.")
6180
6338
  .option("--json", "Print a JSON envelope.")
6181
6339
  .action(async (options) => {
@@ -6189,11 +6347,11 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6189
6347
  }));
6190
6348
  }))
6191
6349
  .addCommand(new Command("register")
6192
- .description("STAFF: register a manually-purchased egress IP (e.g. IPRoyal ISP-dedicated) into this environment's inventory. Secrets never pass here — --credential-ref names the Doppler family EGRESS_CRED_<REF>_USERNAME/_PASSWORD, set separately in Doppler. Register, then `egress assign`, then enable EGRESS_POOL_ENABLED (pool mode fails closed for unassigned mailboxes).")
6350
+ .description("STAFF: register a manually-purchased sending IP into this environment's inventory. Secrets never pass here — --credential-ref names the Doppler family EGRESS_CRED_<REF>_USERNAME/_PASSWORD, set separately in Doppler. Register, then `egress assign`, then enable EGRESS_POOL_ENABLED (pool mode fails closed for unassigned mailboxes).")
6193
6351
  .requiredOption("--host <host>", "Proxy hostname or IP — bare, no scheme/port/credentials.")
6194
6352
  .requiredOption("--port <port>", "Proxy port (1-65535).")
6195
- .requiredOption("--credential-ref <ref>", "Doppler credential-family name (A-Z, 0-9, _), e.g. IPROYAL.")
6196
- .option("--vendor <vendor>", "Vendor slug for inventory provenance.", "iproyal")
6353
+ .requiredOption("--credential-ref <ref>", "Doppler credential-family name (A-Z, 0-9, _).")
6354
+ .requiredOption("--vendor <vendor>", "Vendor slug for inventory provenance.")
6197
6355
  .option("--protocol <protocol>", "Proxy protocol: http (default), https, or socks5.")
6198
6356
  .option("--tier <tier>", "shared (default) or dedicated (requires --dedicated-org).")
6199
6357
  .option("--dedicated-org <orgId>", "Organization id a dedicated-tier IP is reserved for.")
@@ -6730,7 +6888,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6730
6888
  .command("custom-integrations")
6731
6889
  .description("Custom HTTP integration commands.")
6732
6890
  .addCommand(new Command("apply")
6733
- .description("Create or update a custom HTTP integration manifest. Attach credentials with `custom-integrations connect`.")
6891
+ .description(`Create or update a custom HTTP integration manifest. Attach credentials with \`custom-integrations connect\`. Manifest schema + worked examples: ${CUSTOM_HTTP_MANIFEST_DOCS_URL}`)
6734
6892
  .option("--manifest <path>", "Path to a custom HTTP manifest JSON file.")
6735
6893
  .option("--manifest-json <json>", "Custom HTTP manifest as a JSON object.")
6736
6894
  .option("--json", "Print a JSON envelope.")
@@ -8516,8 +8674,10 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8516
8674
  program.addCommand(new Command("sequences")
8517
8675
  .description("Multichannel outreach sequences: one enrollment per lead spans LinkedIn + email over a journey. LinkedIn steps dispatch natively (rate-limited, credit-capped); email steps send natively or place/move/stop the lead in a bound Instantly campaign (BYOK). Cross-channel reply-stop is intrinsic. A LinkedIn-only sequence behaves exactly like the original sequencer.")
8518
8676
  .addCommand(new Command("list")
8519
- .description("List sequences with status, channels, and credit usage.")
8677
+ .description("List sequences with status, channels, tags, and credit usage. --stats joins each row's LIFETIME funnel (enrolled, sent, replies, reply rate, email opens/clicks, opportunities) — the same shape `sequences stats` returns for one sequence.")
8520
8678
  .option("--status <status>", "Filter by status: draft, active, paused, or archived.")
8679
+ .option("--tag <tag>", "Only sequences carrying this workspace tag (see `oxygen tags list`).")
8680
+ .option("--stats", "Attach lifetime stats per sequence (a stats pass per row — slower on large workspaces).")
8521
8681
  .option("--json", "Print a JSON envelope.")
8522
8682
  .action(async (options) => {
8523
8683
  await handleAsyncAction("sequences list", options, () => {
@@ -8525,6 +8685,11 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8525
8685
  const status = readOption(options.status);
8526
8686
  if (status)
8527
8687
  params.set("status", status);
8688
+ const tag = readOption(options.tag);
8689
+ if (tag)
8690
+ params.set("tag", tag);
8691
+ if (options.stats)
8692
+ params.set("include", "stats");
8528
8693
  const suffix = params.toString();
8529
8694
  return requestOxygen(`/api/cli/sequences${suffix ? `?${suffix}` : ""}`);
8530
8695
  });
@@ -8585,6 +8750,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8585
8750
  .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.")
8586
8751
  .option("--exclude-contacted", "Default every enroll into this sequence to cross-campaign exclusion — skip leads any OTHER active sequence is already contacting. A per-enroll `sequences enroll --exclude-contacted` / `--no-exclude-contacted` always overrides this default. Off by default.")
8587
8752
  .option("--no-exclude-contacted", "Turn the sequence-level exclude-contacted default back OFF (enrollments then exclude cross-campaign only when a call opts in).")
8753
+ .option("--tags <csv>", "Comma-separated workspace tags (Tags primitive) — the labels that link this campaign to wiki pages, tables, and workflows carrying the same tag.")
8588
8754
  .option("--json", "Print a JSON envelope.")
8589
8755
  .action(async (options) => {
8590
8756
  await handleAsyncAction("sequences create", options, () => {
@@ -8594,6 +8760,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8594
8760
  const definition = readJsonFileValue(resolve(stepsPath), "--steps-file");
8595
8761
  const channels = readCsvOption(options.channels);
8596
8762
  const senders = readCsvOption(options.senders);
8763
+ const tags = readCsvOption(options.tags);
8597
8764
  const email = readCampaignEmailBinding(options);
8598
8765
  const maxCredits = readPositiveNumber(options.maxCredits);
8599
8766
  const maxLiveSends = readPositiveInt(options.maxLiveSends);
@@ -8606,6 +8773,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8606
8773
  definition,
8607
8774
  ...(channels.length > 0 ? { channels } : {}),
8608
8775
  ...(senders.length > 0 ? { senders } : {}),
8776
+ ...(tags.length > 0 ? { tags } : {}),
8609
8777
  ...(readOption(options.table) ? { source_table_id: readOption(options.table) } : {}),
8610
8778
  ...(readOption(options.urlColumn) ? { linkedin_url_column_key: readOption(options.urlColumn) } : {}),
8611
8779
  ...(email ? { email } : {}),
@@ -8676,6 +8844,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8676
8844
  .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).")
8677
8845
  .option("--tracking-clicks", "Turn click-link tracking ON for this sequence's native email sends (the default; same verified-tracking-domain gate as opens).")
8678
8846
  .option("--no-tracking-clicks", "Turn OFF click-link rewriting for this sequence's native email sends (links go out untouched).")
8847
+ .option("--tags <csv>", "Replace the sequence's workspace tags (comma-separated; \"\" clears them). The ONE field an ARCHIVED sequence still accepts — tag finished campaigns to link their learnings across the workspace.")
8679
8848
  .option("--json", "Print a JSON envelope.")
8680
8849
  .action(async (sequence, options) => {
8681
8850
  await handleAsyncAction("sequences update", options, () => {
@@ -8683,6 +8852,10 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8683
8852
  const name = readOption(options.name);
8684
8853
  if (name)
8685
8854
  body.name = name;
8855
+ // Whole-set replace; `--tags ""` clears. Distinct from the
8856
+ // omitted flag (undefined), which leaves tags untouched.
8857
+ if (options.tags !== undefined)
8858
+ body.tags = readCsvOption(options.tags);
8686
8859
  const stepsPath = readOption(options.stepsFile);
8687
8860
  if (stepsPath) {
8688
8861
  body.definition = readJsonFileValue(resolve(stepsPath), "--steps-file");
@@ -8717,7 +8890,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8717
8890
  body.email = email;
8718
8891
  }
8719
8892
  if (Object.keys(body).length === 0) {
8720
- 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, --no-stop-on-bounce, --[no-]exclude-contacted, or --[no-]tracking-opens / --[no-]tracking-clicks).");
8893
+ throw new Error("Provide at least one field to update (--name, --steps-file, --channels, --senders, --tags, --email-*, --clear-email, --max-credits, --max-live-sends, --max-emails-per-mailbox-per-day, --send-window-file, --no-stop-on-bounce, --[no-]exclude-contacted, or --[no-]tracking-opens / --[no-]tracking-clicks).");
8721
8894
  }
8722
8895
  return requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}`, {
8723
8896
  method: "PATCH",
@@ -8846,7 +9019,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8846
9019
  }));
8847
9020
  }))
8848
9021
  .addCommand(new Command("enrollments")
8849
- .description("List a sequence's per-lead enrollments and their state.")
9022
+ .description("List per-lead enrollment state plus the latest action status, error, attempts, and retry time.")
8850
9023
  .argument("<sequence>", "Sequence id or slug.")
8851
9024
  .option("--status <status>", "Filter by enrollment status (active, waiting_connection, replied, completed, stopped, failed).")
8852
9025
  .option("--limit <n>", "Maximum enrollments to return (1-500).")
@@ -8863,6 +9036,21 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8863
9036
  const suffix = params.toString();
8864
9037
  return requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/enrollments${suffix ? `?${suffix}` : ""}`);
8865
9038
  });
9039
+ }))
9040
+ .addCommand(new Command("reconcile")
9041
+ .description("Safely reconcile one ambiguous LinkedIn invite/message from provider reads. Preview-only unless --approved; never resends.")
9042
+ .argument("<sequence>", "Sequence id or slug. The sequence must be paused.")
9043
+ .requiredOption("--action <id>", "Failed/canceled live action UUID to reconcile.")
9044
+ .option("--approved", "Apply an exact provider match to local state. No provider write or resend occurs.")
9045
+ .option("--json", "Print a JSON envelope.")
9046
+ .action(async (sequence, options) => {
9047
+ await handleAsyncAction("sequences reconcile", options, () => requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/reconcile`, {
9048
+ method: "POST",
9049
+ body: {
9050
+ action_id: readOption(options.action),
9051
+ approved: options.approved === true,
9052
+ },
9053
+ }));
8866
9054
  }))
8867
9055
  .addCommand(new Command("stats")
8868
9056
  .description("Show the sequence funnel: enrolled, invites sent, connected, replied, with acceptance and reply rates.")
@@ -8929,7 +9117,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8929
9117
  });
8930
9118
  })));
8931
9119
  program.addCommand(new Command("suppressions")
8932
- .description("Do-not-contact + email blocklist. People/multichannel: lead provider ids the sequencer enroller skips at plan time (list | add | remove). Email blocklist (Instantly parity): bulk-import addresses + whole-domain blocks the native email dispatcher skips before sending (import | domains | remove-domain). Consumes 0 credits.")
9120
+ .description("Do-not-contact + email blocklist. People/multichannel: lead provider ids the sequencer enroller skips at plan time (list | add | remove); list rows carry a resolved identity (name / picture / profile URL) when the workspace knows the person. Email blocklist (Instantly parity): addresses + whole-domain blocks the native email dispatcher skips before sending (addresses | domains | remove-address | remove-domain). `import` bulk-loads a mixed file: emails, bare domains, LinkedIn profile URLs, and LinkedIn member ids. Consumes 0 credits.")
8933
9121
  .addCommand(new Command("list")
8934
9122
  .description("List the org's do-not-contact suppressions, newest first. Filter by --reason or by --search (case-insensitive substring of the lead provider id).")
8935
9123
  .option("--reason <reason>", "Filter by reason: manual, replied, unsubscribed, bounced, do_not_contact, friends.")
@@ -8994,10 +9182,11 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8994
9182
  });
8995
9183
  }))
8996
9184
  .addCommand(new Command("import")
8997
- .description("Bulk-import an email blocklist from a file of emails and/or bare domains (newline / comma / whitespace separated, max 5000). An entry with '@' goes on the per-address list; a bare domain (e.g. acme.com) blocks the WHOLE domain. A domain block is a sharp tool, so --reason is REQUIRED when the file contains any domains; a pure-email import defaults to manual. Idempotent. Consumes 0 credits.")
8998
- .requiredOption("--file <path>", "Path to a file of emails and/or bare domains, separated by newlines, commas, or whitespace.")
8999
- .option("--reason <reason>", "Suppression reason. Emails: hard_bounce | complaint | unsubscribe | manual. Domains: manual | complaint | policy. Required when the file contains any domains.")
9185
+ .description("Bulk-import a do-not-contact blocklist from a file (newline / comma / whitespace separated, max 5000). An entry with '@' goes on the per-address email list; a bare domain (e.g. acme.com) blocks the WHOLE domain; a LinkedIn profile URL or member id (ACo...) lands on the people do-not-contact list; any other URL is rejected. A domain block is a sharp tool, so --reason is REQUIRED when the file contains any domains; otherwise manual is the default. Idempotent. Consumes 0 credits.")
9186
+ .requiredOption("--file <path>", "Path to a file of emails, bare domains, LinkedIn profile URLs, and/or LinkedIn member ids, separated by newlines, commas, or whitespace.")
9187
+ .option("--reason <reason>", "Suppression reason. Emails: hard_bounce | complaint | unsubscribe | manual. Domains: manual | complaint | policy. LinkedIn contacts: manual | replied | unsubscribed | bounced | do_not_contact | friends. Required when the file contains any domains; must be valid for every entry type present (manual always is).")
9000
9188
  .option("--source <text>", "Optional provenance note stored on every imported row (e.g. instantly_export).")
9189
+ .option("--detail <text>", "Optional free-text note stored on every imported row.")
9001
9190
  .option("--json", "Print a JSON envelope.")
9002
9191
  .action(async (options) => {
9003
9192
  try {
@@ -9012,15 +9201,17 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9012
9201
  .filter((entry) => entry.length > 0)),
9013
9202
  ];
9014
9203
  if (entries.length === 0)
9015
- throw new Error(`No emails or domains found in ${file}.`);
9204
+ throw new Error(`No emails, domains, or LinkedIn ids found in ${file}.`);
9016
9205
  const reason = readOption(options.reason);
9017
9206
  const source = readOption(options.source);
9207
+ const detail = readOption(options.detail);
9018
9208
  const data = await requestOxygen("/api/cli/suppressions/import", {
9019
9209
  method: "POST",
9020
9210
  body: {
9021
9211
  entries,
9022
9212
  ...(reason ? { reason } : {}),
9023
9213
  ...(source ? { source } : {}),
9214
+ ...(detail ? { detail } : {}),
9024
9215
  },
9025
9216
  });
9026
9217
  if (options.json) {
@@ -9034,6 +9225,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9034
9225
  writeJson({
9035
9226
  imported_emails: parsed.imported_emails ?? 0,
9036
9227
  imported_domains: parsed.imported_domains ?? 0,
9228
+ imported_contacts: parsed.imported_contacts ?? 0,
9037
9229
  rejected_count: rejected.length,
9038
9230
  rejected: rejected.slice(0, 10),
9039
9231
  ...(rejected.length > 10
@@ -9073,6 +9265,32 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9073
9265
  const suffix = params.toString();
9074
9266
  return requestOxygen(`/api/cli/suppressions/domains${suffix ? `?${suffix}` : ""}`);
9075
9267
  });
9268
+ }))
9269
+ .addCommand(new Command("addresses")
9270
+ .description("List the org's blocked email ADDRESSES, newest first (bounces, unsubscribes, and `suppressions import` land here). Filter by --reason (hard_bounce | complaint | unsubscribe | manual) or --search (case-insensitive substring of the address). Rows carry a CRM person name when one matches.")
9271
+ .option("--reason <reason>", "Filter by reason: hard_bounce, complaint, unsubscribe, manual.")
9272
+ .option("--search <text>", "Case-insensitive substring match on the address.")
9273
+ .option("--limit <n>", "Maximum addresses to return (1-500; default 100).")
9274
+ .option("--offset <n>", "Pagination offset (0-based).")
9275
+ .option("--json", "Print a JSON envelope.")
9276
+ .action(async (options) => {
9277
+ await handleAsyncAction("suppressions addresses", options, () => {
9278
+ const params = new URLSearchParams();
9279
+ const reason = readOption(options.reason);
9280
+ if (reason)
9281
+ params.set("reason", reason);
9282
+ const search = readOption(options.search);
9283
+ if (search)
9284
+ params.set("search", search);
9285
+ const limit = readOption(options.limit);
9286
+ if (limit)
9287
+ params.set("limit", limit);
9288
+ const offset = readOption(options.offset);
9289
+ if (offset)
9290
+ params.set("offset", offset);
9291
+ const suffix = params.toString();
9292
+ return requestOxygen(`/api/cli/suppressions/addresses${suffix ? `?${suffix}` : ""}`);
9293
+ });
9076
9294
  }))
9077
9295
  .addCommand(new Command("remove-domain")
9078
9296
  .description("Remove a whole-domain email block (re-enable sending to the domain).")
@@ -9616,10 +9834,14 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9616
9834
  program.addCommand(new Command("domains")
9617
9835
  .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.")
9618
9836
  .addCommand(new Command("list")
9619
- .description("List the org's cached Cloudflare domains with cold-email metadata (age, mailboxes, warmup, sending volume, DNS health). Reads the cache only — run `domains sync` to refresh.")
9837
+ .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`). Reads the cache only — run `domains sync` to refresh.")
9620
9838
  .option("--status <status>", "Filter by zone status: unknown, initializing, pending, active, moved, or deleted.")
9621
9839
  .option("--registrar <registrar>", "Filter by registrar name.")
9622
9840
  .option("--q <text>", "Filter by domain substring.")
9841
+ .option("--dns <state>", "Filter by the derived DNS state: ok, issues, not_checked, check_failed, check_incomplete, or no_zone.")
9842
+ .option("--expiring", "Only non-deleted domains expiring within 60 days.")
9843
+ .option("--has-mailboxes <bool>", "true → only domains with sending mailboxes; false → only domains without.")
9844
+ .option("--include-archived", "Include archived domains (hidden by default).")
9623
9845
  .option("--json", "Print a JSON envelope.")
9624
9846
  .action(async (options) => {
9625
9847
  await handleAsyncAction("domains list", options, () => {
@@ -9633,9 +9855,39 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9633
9855
  const queryText = readOption(options.q);
9634
9856
  if (queryText)
9635
9857
  params.set("q", queryText);
9858
+ const dns = readOption(options.dns);
9859
+ if (dns)
9860
+ params.set("dns", dns);
9861
+ if (options.expiring)
9862
+ params.set("expiring", "true");
9863
+ const hasMailboxes = readOption(options.hasMailboxes);
9864
+ if (hasMailboxes)
9865
+ params.set("has_mailboxes", hasMailboxes);
9866
+ if (options.includeArchived)
9867
+ params.set("include_archived", "true");
9636
9868
  const suffix = params.toString();
9637
9869
  return requestOxygen(`/api/cli/domains${suffix ? `?${suffix}` : ""}`);
9638
9870
  });
9871
+ }))
9872
+ .addCommand(new Command("archive")
9873
+ .description("Hide a domain from the default list — an Oxygen-side declutter flag only. Nothing changes at Cloudflare, DNS and mailbox governance are untouched, syncs never resurrect it, and `domains unarchive` fully reverses it.")
9874
+ .argument("<domain>", "Domain name, such as acme.com.")
9875
+ .option("--json", "Print a JSON envelope.")
9876
+ .action(async (domain, options) => {
9877
+ await handleAsyncAction("domains archive", options, () => requestOxygen(`/api/cli/domains/${encodeURIComponent(domain)}/archive`, {
9878
+ method: "POST",
9879
+ body: { archived: true },
9880
+ }));
9881
+ }))
9882
+ .addCommand(new Command("unarchive")
9883
+ .description("Restore an archived domain to the default list.")
9884
+ .argument("<domain>", "Domain name, such as acme.com.")
9885
+ .option("--json", "Print a JSON envelope.")
9886
+ .action(async (domain, options) => {
9887
+ await handleAsyncAction("domains unarchive", options, () => requestOxygen(`/api/cli/domains/${encodeURIComponent(domain)}/archive`, {
9888
+ method: "POST",
9889
+ body: { archived: false },
9890
+ }));
9639
9891
  }))
9640
9892
  .addCommand(new Command("sync")
9641
9893
  .description("Refresh the domain cache from Cloudflare: zone pages, registrar metadata, RDAP age backfill, and DNS health. Partial (paginated) syncs auto-continue until complete.")
@@ -9924,18 +10176,21 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9924
10176
  .action(async (options) => {
9925
10177
  await handleAsyncAction("workflows apply", options, async () => {
9926
10178
  const manifest = await compileWorkflowFile(options.file);
9927
- return prepareWorkflowCliOutput(await requestOxygen("/api/cli/workflows/apply", {
10179
+ const data = prepareWorkflowCliOutput(await requestOxygen("/api/cli/workflows/apply", {
9928
10180
  method: "POST",
9929
10181
  body: { manifest },
9930
10182
  }), options);
10183
+ writeScheduleWarnings(data);
10184
+ return data;
9931
10185
  });
9932
10186
  }))
9933
10187
  .addCommand(new Command("list")
9934
- .description("List workflow automations.")
10188
+ .description("List workflow automations. Archived workflows are hidden unless you pass --include-archived.")
10189
+ .option("--include-archived", "Also list archived workflows, which are hidden by default.")
9935
10190
  .option("--json", "Print a JSON envelope.")
9936
10191
  .action(async (options) => {
9937
10192
  await handleAsyncAction("workflows list", options, async () => {
9938
- const data = await requestOxygen("/api/cli/workflows");
10193
+ const data = await requestOxygen(`/api/cli/workflows${options.includeArchived ? "?include_archived=true" : ""}`);
9939
10194
  writeDisabledWorkflowNotices(data);
9940
10195
  return data;
9941
10196
  });
@@ -9954,6 +10209,17 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9954
10209
  writeDisabledWorkflowNotices(data);
9955
10210
  return prepareWorkflowCliOutput(data, options);
9956
10211
  });
10212
+ }))
10213
+ .addCommand(new Command("tag")
10214
+ .description("Replace a workflow's workspace tags (Tags primitive; \"\" clears). Tags link the workflow to sequences, tables, and wiki pages carrying the same tag — browse with `oxygen tags get <tag>`. Org metadata only: no new revision is cut.")
10215
+ .argument("<workflow>", "Workflow id, slug, or name.")
10216
+ .requiredOption("--tags <csv>", "Comma-separated tags; \"\" clears them.")
10217
+ .option("--json", "Print a JSON envelope.")
10218
+ .action(async (workflow, options) => {
10219
+ await handleAsyncAction("workflows tag", options, () => requestOxygen("/api/cli/workflows/tags", {
10220
+ method: "POST",
10221
+ body: { workflow, tags: readCsvOption(options.tags) },
10222
+ }));
9957
10223
  }))
9958
10224
  .addCommand(new Command("duplicate")
9959
10225
  .description("Duplicate a workflow automation as a disabled copy by default.")
@@ -10148,36 +10414,140 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
10148
10414
  }));
10149
10415
  }))
10150
10416
  .addCommand(new Command("enable")
10151
- .description("Enable a workflow automation and its current trigger. Prints the schedule's projected automation-action burn; refuses a cron cadence the plan's monthly allowance cannot sustain.")
10152
- .argument("<workflow>", "Workflow id, slug, or name.")
10417
+ .description("Enable one or more workflow automations and their current triggers. Prints the schedule's projected automation-action burn; refuses a cron cadence the plan's monthly allowance cannot sustain. Pass several workflows to enable them in one bulk call.")
10418
+ .argument("<workflows...>", "One or more workflow ids, slugs, or names.")
10153
10419
  .option("--reason <text>", "Why you are enabling it. Recorded on the workflow and shown to the workspace.")
10154
10420
  .option("--json", "Print a JSON envelope.")
10155
- .action(async (workflow, options) => {
10156
- await handleAsyncAction("workflows enable", options, async () => {
10421
+ .action(async (workflows, options) => {
10422
+ if (workflows.length === 1) {
10423
+ await handleAsyncAction("workflows enable", options, async () => {
10424
+ const data = await requestOxygen("/api/cli/workflows/enable", {
10425
+ method: "POST",
10426
+ body: {
10427
+ workflow: workflows[0],
10428
+ ...(readOption(options.reason) ? { reason: readOption(options.reason) } : {}),
10429
+ },
10430
+ });
10431
+ writeAutomationProjection(data);
10432
+ writeScheduleWarnings(data);
10433
+ return data;
10434
+ });
10435
+ return;
10436
+ }
10437
+ try {
10157
10438
  const data = await requestOxygen("/api/cli/workflows/enable", {
10158
10439
  method: "POST",
10159
10440
  body: {
10160
- workflow,
10441
+ workflows,
10161
10442
  ...(readOption(options.reason) ? { reason: readOption(options.reason) } : {}),
10162
10443
  },
10163
10444
  });
10164
- writeAutomationProjection(data);
10165
- return data;
10166
- });
10445
+ if (options.json) {
10446
+ writeJson(success("workflows enable", data));
10447
+ }
10448
+ else {
10449
+ writeBulkWorkflowResults(data, "enabled");
10450
+ }
10451
+ writeScheduleWarnings(data);
10452
+ }
10453
+ catch (error) {
10454
+ emitCliFailure("workflows enable", error);
10455
+ }
10167
10456
  }))
10168
10457
  .addCommand(new Command("disable")
10169
- .description("Disable a workflow automation and its current trigger. Records who disabled it, from where, and why — the workspace sees it on the workflow.")
10170
- .argument("<workflow>", "Workflow id, slug, or name.")
10458
+ .description("Disable one or more workflow automations and their current triggers. Records who disabled it, from where, and why — the workspace sees it on the workflow. Pass several workflows to disable them in one bulk call.")
10459
+ .argument("<workflows...>", "One or more workflow ids, slugs, or names.")
10171
10460
  .option("--reason <text>", "Why you are disabling it. Recorded on the workflow and shown to the workspace.")
10172
10461
  .option("--json", "Print a JSON envelope.")
10173
- .action(async (workflow, options) => {
10174
- await handleAsyncAction("workflows disable", options, () => requestOxygen("/api/cli/workflows/disable", {
10175
- method: "POST",
10176
- body: {
10177
- workflow,
10178
- ...(readOption(options.reason) ? { reason: readOption(options.reason) } : {}),
10179
- },
10180
- }));
10462
+ .action(async (workflows, options) => {
10463
+ if (workflows.length === 1) {
10464
+ await handleAsyncAction("workflows disable", options, () => requestOxygen("/api/cli/workflows/disable", {
10465
+ method: "POST",
10466
+ body: {
10467
+ workflow: workflows[0],
10468
+ ...(readOption(options.reason) ? { reason: readOption(options.reason) } : {}),
10469
+ },
10470
+ }));
10471
+ return;
10472
+ }
10473
+ try {
10474
+ const data = await requestOxygen("/api/cli/workflows/disable", {
10475
+ method: "POST",
10476
+ body: {
10477
+ workflows,
10478
+ ...(readOption(options.reason) ? { reason: readOption(options.reason) } : {}),
10479
+ },
10480
+ });
10481
+ if (options.json) {
10482
+ writeJson(success("workflows disable", data));
10483
+ }
10484
+ else {
10485
+ writeBulkWorkflowResults(data, "disabled");
10486
+ }
10487
+ }
10488
+ catch (error) {
10489
+ emitCliFailure("workflows disable", error);
10490
+ }
10491
+ }))
10492
+ .addCommand(new Command("delete")
10493
+ .description("Archive (or, with --purge, permanently delete) one or more workflow automations. Archiving hides the workflow from `workflows list`, disarms its trigger, and is reversible with `workflows enable`/`disable` — see archived workflows again with `workflows list --include-archived`. --purge cannot be undone.")
10494
+ .argument("<workflows...>", "One or more workflow ids, slugs, or names.")
10495
+ .option("--purge", "Permanently remove the workflow instead of archiving it. Run history pages for this workflow become unreachable; this cannot be undone.")
10496
+ .option("--reason <text>", "Why you are deleting it. Recorded on the workflow and shown to the workspace.")
10497
+ .option("--json", "Print a JSON envelope.")
10498
+ .action(async (workflows, options) => {
10499
+ try {
10500
+ const single = workflows.length === 1;
10501
+ const data = await requestOxygen("/api/cli/workflows/delete", {
10502
+ method: "POST",
10503
+ body: {
10504
+ ...(single ? { workflow: workflows[0] } : { workflows }),
10505
+ ...(options.purge ? { purge: true } : {}),
10506
+ ...(readOption(options.reason) ? { reason: readOption(options.reason) } : {}),
10507
+ },
10508
+ });
10509
+ if (options.json) {
10510
+ writeJson(success("workflows delete", data));
10511
+ return;
10512
+ }
10513
+ if (single) {
10514
+ const workflow = isRecord(data.workflow) ? data.workflow : null;
10515
+ const slug = workflow && typeof workflow.slug === "string" ? workflow.slug : workflows[0];
10516
+ process.stdout.write(`${data.purged === true ? "purged" : "archived"} ${slug}\n`);
10517
+ writeWorkflowActionTail(data);
10518
+ }
10519
+ else {
10520
+ writeBulkWorkflowResults(data, options.purge ? "purged" : "archived");
10521
+ }
10522
+ }
10523
+ catch (error) {
10524
+ emitCliFailure("workflows delete", error);
10525
+ }
10526
+ }))
10527
+ .addCommand(new Command("rename")
10528
+ .description("Rename a workflow automation's display name only — its id and slug are unchanged, and the next `workflows apply` from source overwrites the name.")
10529
+ .argument("<workflow>", "Workflow id, slug, or name.")
10530
+ .argument("<name>", "New display name.")
10531
+ .option("--json", "Print a JSON envelope.")
10532
+ .action(async (workflow, name, options) => {
10533
+ try {
10534
+ const data = await requestOxygen("/api/cli/workflows/rename", {
10535
+ method: "POST",
10536
+ body: { workflow, name },
10537
+ });
10538
+ if (options.json) {
10539
+ writeJson(success("workflows rename", data));
10540
+ return;
10541
+ }
10542
+ const renamed = isRecord(data.workflow) ? data.workflow : null;
10543
+ const slug = renamed && typeof renamed.slug === "string" ? renamed.slug : workflow;
10544
+ const displayName = renamed && typeof renamed.name === "string" ? renamed.name : name;
10545
+ process.stdout.write(`renamed ${slug} to "${displayName}"\n`);
10546
+ writeWorkflowActionTail(data);
10547
+ }
10548
+ catch (error) {
10549
+ emitCliFailure("workflows rename", error);
10550
+ }
10181
10551
  }))
10182
10552
  .addCommand(new Command("mcp")
10183
10553
  .description("Publish workflows as dynamic MCP tools (oxygen_workflow_<slug>) — the 'Clay Functions' pattern.")
@@ -14327,18 +14697,22 @@ async function handleSequenceDuplicateAction(sequence, options) {
14327
14697
  const copySenders = options.senders !== false;
14328
14698
  const created = await requestOxygen("/api/cli/sequences", {
14329
14699
  method: "POST",
14700
+ // The create route reads snake_case keys (source_table_id, max_credits,
14701
+ // senders, …). This handler used to POST camelCase, so the copy silently
14702
+ // dropped the sender pool, table binding, URL column, and caps.
14330
14703
  body: {
14331
14704
  slug,
14332
14705
  name,
14333
14706
  definition: src.definition,
14334
14707
  ...(src.channels ? { channels: src.channels } : {}),
14335
- ...(src.sourceTableId ? { sourceTableId: src.sourceTableId } : {}),
14336
- ...(src.linkedinUrlColumnKey ? { linkedinUrlColumnKey: src.linkedinUrlColumnKey } : {}),
14708
+ ...(src.sourceTableId ? { source_table_id: src.sourceTableId } : {}),
14709
+ ...(src.linkedinUrlColumnKey ? { linkedin_url_column_key: src.linkedinUrlColumnKey } : {}),
14337
14710
  ...(src.settings ? { settings: src.settings } : {}),
14338
- ...(src.maxCredits != null ? { maxCredits: src.maxCredits } : {}),
14339
- ...(src.maxLiveSends != null ? { maxLiveSends: src.maxLiveSends } : {}),
14711
+ ...(src.maxCredits != null ? { max_credits: src.maxCredits } : {}),
14712
+ ...(src.maxLiveSends != null ? { max_live_sends: src.maxLiveSends } : {}),
14713
+ ...(Array.isArray(src.tags) && src.tags.length > 0 ? { tags: src.tags } : {}),
14340
14714
  ...(copySenders && src.senderAccountIds && src.senderAccountIds.length > 0
14341
- ? { senderRefs: src.senderAccountIds }
14715
+ ? { senders: src.senderAccountIds }
14342
14716
  : {}),
14343
14717
  },
14344
14718
  });
@@ -14944,6 +15318,13 @@ function withSupportEventsQuery(path, options) {
14944
15318
  const query = params.toString();
14945
15319
  return query ? `${path}?${query}` : path;
14946
15320
  }
15321
+ function withSupportAdminGetQuery(path, options) {
15322
+ const ifVersion = readOption(options.ifVersion);
15323
+ if (!ifVersion)
15324
+ return path;
15325
+ const params = new URLSearchParams({ if_version: ifVersion });
15326
+ return `${path}?${params.toString()}`;
15327
+ }
14947
15328
  // Workflow text flags (--verify-notes/--plan/--draft) bypass readOption: an
14948
15329
  // explicitly-passed empty string clears the field server-side, while an absent
14949
15330
  // flag leaves it untouched, so "" must survive to the request body.