@oxygen-agent/cli 1.1011.322 → 1.1011.405

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.
Files changed (48) hide show
  1. package/README.md +1 -1
  2. package/dist/command-manifest.js +7 -0
  3. package/dist/connectors-commands.d.ts +4 -1
  4. package/dist/connectors-commands.js +81 -10
  5. package/dist/contract-flags.d.ts +2 -0
  6. package/dist/contract-flags.js +4 -2
  7. package/dist/crm-support-flags.d.ts +11 -0
  8. package/dist/crm-support-flags.js +60 -0
  9. package/dist/help.js +10 -0
  10. package/dist/index.js +175 -65
  11. package/dist/settings-commands.d.ts +11 -0
  12. package/dist/settings-commands.js +72 -0
  13. package/node_modules/@oxygen/shared/dist/bounded-read-result.d.ts +4 -0
  14. package/node_modules/@oxygen/shared/dist/bounded-read-result.js +25 -0
  15. package/node_modules/@oxygen/shared/dist/capability-discovery.d.ts +30 -0
  16. package/node_modules/@oxygen/shared/dist/capability-discovery.js +277 -16
  17. package/node_modules/@oxygen/shared/dist/contracts/connectors.d.ts +32 -6
  18. package/node_modules/@oxygen/shared/dist/contracts/connectors.js +56 -4
  19. package/node_modules/@oxygen/shared/dist/contracts/crm-support.d.ts +117 -0
  20. package/node_modules/@oxygen/shared/dist/contracts/crm-support.js +43 -0
  21. package/node_modules/@oxygen/shared/dist/contracts/dashboard-tabs.d.ts +3 -0
  22. package/node_modules/@oxygen/shared/dist/contracts/dashboard-tabs.js +2 -0
  23. package/node_modules/@oxygen/shared/dist/contracts/integration-connect.d.ts +4 -0
  24. package/node_modules/@oxygen/shared/dist/contracts/integration-connect.js +7 -0
  25. package/node_modules/@oxygen/shared/dist/contracts/operations.js +8 -2
  26. package/node_modules/@oxygen/shared/dist/contracts/workspace-pages.d.ts +16 -0
  27. package/node_modules/@oxygen/shared/dist/contracts/workspace-pages.js +37 -0
  28. package/node_modules/@oxygen/shared/dist/decision-ledger-parser.d.ts +32 -0
  29. package/node_modules/@oxygen/shared/dist/decision-ledger-parser.js +303 -0
  30. package/node_modules/@oxygen/shared/dist/decision-ledger.d.ts +10 -0
  31. package/node_modules/@oxygen/shared/dist/decision-ledger.generated.d.ts +2 -0
  32. package/node_modules/@oxygen/shared/dist/decision-ledger.generated.js +717 -0
  33. package/node_modules/@oxygen/shared/dist/decision-ledger.js +20 -0
  34. package/node_modules/@oxygen/shared/dist/mcp-event-contract.d.ts +10 -7
  35. package/node_modules/@oxygen/shared/dist/mcp-event-contract.js +11 -7
  36. package/node_modules/@oxygen/shared/dist/mcp-table-widget.d.ts +50 -0
  37. package/node_modules/@oxygen/shared/dist/mcp-table-widget.js +24 -0
  38. package/node_modules/@oxygen/shared/dist/plan-band.d.ts +8 -0
  39. package/node_modules/@oxygen/shared/dist/plan-band.js +12 -0
  40. package/node_modules/@oxygen/shared/dist/plan-limits.d.ts +21 -0
  41. package/node_modules/@oxygen/shared/dist/plan-limits.js +41 -0
  42. package/node_modules/@oxygen/shared/dist/sequences.d.ts +33 -4
  43. package/node_modules/@oxygen/shared/dist/sequences.js +48 -13
  44. package/node_modules/@oxygen/shared/dist/user-capability-routing.js +17 -0
  45. package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
  46. package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
  47. package/node_modules/@oxygen/shared/package.json +5 -0
  48. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { registerCrmSupportFlags, readCrmNoteEditFields, readCrmPhotoFields, readCrmFileLabel, buildCrmObjectsPath, buildCrmConnectionsPath } from "./crm-support-flags.js";
1
2
  import { execFileSync } from "node:child_process";
2
3
  import { createHash, randomUUID } from "node:crypto";
3
4
  import { closeSync, createReadStream, createWriteStream, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
@@ -9,6 +10,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
9
10
  import { Command, CommanderError, Option } from "commander";
10
11
  import { registerUgcCommands } from "./ugc-commands.js";
11
12
  import { registerConnectorsCommands } from "./connectors-commands.js";
13
+ import { registerSettingsCommands } from "./settings-commands.js";
12
14
  import { registerKnowledgeRepositoryCommands } from "./knowledge-repository-commands.js";
13
15
  import { registerVisualCommands } from "./visual-commands.js";
14
16
  import { renderPrimaryProviderBoard } from "./admin-primary-providers-render.js";
@@ -25,7 +27,8 @@ import { LINKEDIN_SENDER_LIMIT_DEFAULTS, LINKEDIN_SENDER_LIMIT_MAXIMUMS, } from
25
27
  import { SEQUENCE_LIFECYCLES, sequenceDisplayStatus } from "@oxygen/shared/sequence-lifecycle";
26
28
  import { operationPath } from "@oxygen/shared/contracts/operations";
27
29
  import { SENDER_LIMITS_SET_OPERATION } from "@oxygen/shared/contracts/sender-limits";
28
- import { addContractFlags } from "./contract-flags.js";
30
+ import { addContractFlags, readContractFlags } from "./contract-flags.js";
31
+ import { INTEGRATION_CONNECT_ACCOUNT_CONTRACT } from "@oxygen/shared/contracts/integration-connect";
29
32
  import { buildSenderLimitsBody, parseWorkingDaysOption, SENDER_LIMITS_FLAG_OVERLAY } from "./sender-limits-flags.js";
30
33
  import { readColumnDecisionFlags } from "./column-decision-options.js";
31
34
  import { MANAGED_DATA_SUPPLIERS } from "@oxygen/shared/data-suppliers";
@@ -5641,7 +5644,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5641
5644
  .action(async (options) => {
5642
5645
  await handleAsyncAction("crm photos", options, () => requestOxygen("/api/cli/crm/photos", {
5643
5646
  method: "POST",
5644
- body: buildCrmPhotosBody(options),
5647
+ body: readCrmPhotoFields(options),
5645
5648
  }));
5646
5649
  }))
5647
5650
  .addCommand(new Command("fields")
@@ -5679,7 +5682,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5679
5682
  .description("List configured CRM objects, or create/extend custom objects.")
5680
5683
  .option("--json", "Print a JSON envelope.")
5681
5684
  .action(async (options) => {
5682
- await handleReadActionWithLens("crm objects", options, () => requestOxygen("/api/cli/crm/objects"), formatCrmObjects);
5685
+ await handleReadActionWithLens("crm objects", options, () => requestOxygen(buildCrmObjectsPath(options)), formatCrmObjects);
5683
5686
  })
5684
5687
  .addCommand(new Command("create")
5685
5688
  .description("Create or repair a custom CRM object. Defaults to dry-run.")
@@ -5807,14 +5810,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5807
5810
  .option("--json", "Print a JSON envelope.")
5808
5811
  .action(async (object, rowId, options) => {
5809
5812
  await handleAsyncAction("crm connections", options, () => {
5810
- const params = new URLSearchParams();
5811
- if (options.conversationLimit)
5812
- params.set("conversation_limit", options.conversationLimit);
5813
- if (options.sequenceLimit)
5814
- params.set("sequence_limit", options.sequenceLimit);
5815
- const query = params.toString();
5816
- const base = `/api/cli/crm/objects/${encodeURIComponent(object)}/records/${encodeURIComponent(rowId)}/connections`;
5817
- return requestOxygen(query ? `${base}?${query}` : base);
5813
+ return requestOxygen(buildCrmConnectionsPath(object, rowId, options));
5818
5814
  });
5819
5815
  }))
5820
5816
  .addCommand(new Command("files")
@@ -5876,9 +5872,13 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5876
5872
  if (!put.ok) {
5877
5873
  throw new OxygenError("attachment_upload_failed", `Object storage rejected the upload (${put.status}).`, { exitCode: 1 });
5878
5874
  }
5879
- return requestOxygen(completeUrl, {
5875
+ const fileId = readRecordString(ticket.file, "id");
5876
+ const recordRowId = readRecordString(ticket.record, "row_id");
5877
+ if (!fileId || !recordRowId)
5878
+ throw new OxygenError("attachment_upload_failed", "The upload reservation has no file or record id.", { exitCode: 1 });
5879
+ return requestOxygen(`/api/cli/crm/objects/${encodeURIComponent(object)}/records/${encodeURIComponent(recordRowId)}/files/${encodeURIComponent(fileId)}/uploaded`, {
5880
5880
  method: "POST",
5881
- body: readOption(options.label) ? { label: readOption(options.label) } : {},
5881
+ body: readCrmFileLabel(options),
5882
5882
  });
5883
5883
  });
5884
5884
  }))
@@ -6018,16 +6018,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6018
6018
  .option("--json", "Print a JSON envelope.")
6019
6019
  .action(async (noteId, options) => {
6020
6020
  await handleAsyncAction("crm notes update", options, () => {
6021
- // Absent --title leaves it; --title "" clears it.
6022
- const title = readClearableOption(options.title);
6023
6021
  return requestOxygen(`/api/cli/crm/notes/${encodeURIComponent(noteId)}`, {
6024
6022
  method: "PATCH",
6025
- body: {
6026
- ...(readOption(options.body) ? { body: readOption(options.body) } : {}),
6027
- ...(title !== undefined ? { title } : {}),
6028
- ...(options.pin ? { pinned: true } : {}),
6029
- ...(options.unpin ? { pinned: false } : {}),
6030
- },
6023
+ body: readCrmNoteEditFields(options),
6031
6024
  });
6032
6025
  });
6033
6026
  }))
@@ -6886,21 +6879,26 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6886
6879
  }));
6887
6880
  }))
6888
6881
  .addCommand(new Command("delete-row")
6889
- .description("Delete one workspace table row.")
6882
+ .description("Delete one workspace table row. A lead enrolled in a campaign stays enrolled; the response names those campaigns.")
6890
6883
  .argument("<table>", "Table id or slug.")
6891
6884
  .argument("<row_id>", "Workspace row UUID.")
6892
6885
  .option("--json", "Print a JSON envelope.")
6893
6886
  .action(async (table, rowId, options) => {
6894
- await handleAsyncAction("tables delete-row", options, () => requestOxygen("/api/cli/tables/rows/delete", {
6895
- method: "POST",
6896
- body: {
6897
- table,
6898
- row_id: rowId,
6899
- },
6900
- }));
6887
+ await handleAsyncAction("tables delete-row", options, async () => {
6888
+ const data = await requestOxygen("/api/cli/tables/rows/delete", {
6889
+ method: "POST",
6890
+ body: {
6891
+ table,
6892
+ row_id: rowId,
6893
+ },
6894
+ });
6895
+ if (!options.json)
6896
+ writeRowDeleteWarnings(data);
6897
+ return data;
6898
+ });
6901
6899
  }))
6902
6900
  .addCommand(new Command("delete-rows")
6903
- .description("Delete multiple workspace table rows.")
6901
+ .description("Delete multiple workspace table rows. Leads enrolled in a campaign stay enrolled; the response names those campaigns.")
6904
6902
  .argument("<table>", "Table id or slug.")
6905
6903
  .option("--row-ids-json <json>", "JSON array of workspace row UUIDs.")
6906
6904
  .option("--row-ids-file <path>", "File containing row UUIDs or rows with a _row_id column.")
@@ -6911,13 +6909,16 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6911
6909
  .action(async (table, options) => {
6912
6910
  await handleAsyncAction("tables delete-rows", options, async () => {
6913
6911
  const rowIds = await readDeleteRowIdsOption(options);
6914
- return requestOxygen("/api/cli/tables/rows/delete", {
6912
+ const data = await requestOxygen("/api/cli/tables/rows/delete", {
6915
6913
  method: "POST",
6916
6914
  body: {
6917
6915
  table,
6918
6916
  row_ids: rowIds,
6919
6917
  },
6920
6918
  });
6919
+ if (!options.json)
6920
+ writeRowDeleteWarnings(data);
6921
+ return data;
6921
6922
  });
6922
6923
  }))
6923
6924
  .addCommand(new Command("upsert")
@@ -6967,6 +6968,19 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6967
6968
  .option("--limit <n>", "Maximum rows to export. Defaults to 100; hard cap is 1000.")
6968
6969
  .option("--json", "Print a JSON envelope.")
6969
6970
  .action(async (table, options) => {
6971
+ if (!options.json && !options.output && options.format?.trim().toLowerCase() === "table") {
6972
+ // The human-readable table itself, not a JSON object holding it as one
6973
+ // escaped string (blind user evals, 2026-09-30). --json keeps the envelope.
6974
+ try {
6975
+ const { content } = await exportRows(table, options);
6976
+ const text = typeof content === "string" ? content : "";
6977
+ process.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
6978
+ }
6979
+ catch (error) {
6980
+ emitCliFailure("tables export", error);
6981
+ }
6982
+ return;
6983
+ }
6970
6984
  await handleAsyncAction("tables export", options, () => exportRows(table, options));
6971
6985
  }))
6972
6986
  .addCommand(new Command("export-bundle")
@@ -13336,21 +13350,24 @@ Examples:
13336
13350
  .action(async (options) => {
13337
13351
  await handleAsyncAction("integrations list", options, () => requestOxygen("/api/cli/integrations/composio/list"));
13338
13352
  }))
13339
- .addCommand(new Command("connect")
13353
+ .addCommand(addContractFlags(new Command("connect")
13340
13354
  .description("Connect an integration. For OAuth toolkits, running this command immediately creates a pending provider authorization and returns a redirect URL; account access starts only if you approve on the provider's page. This is not a price preview. On a repriced workspace, a connected X account reserves 1,000 credits ($10) monthly; inspect the current pricing schedule with `oxygen limits show --json` before connecting. API-key integrations accept --api-key. Run without credentials only for an API-key integration to get a 0-credit preview of its exact fields (mode: needs_api_key): no credential is submitted, nothing is stored, and no provider call is made. Use repeatable --credential name=value when a provider also requires a host, subdomain, or other named value. To keep a secret out of shell history and process arguments, open the preview's web_url and submit it in Connections; --api-key is for controlled noninteractive use. Credential submission creates or replaces the connection immediately and needs no separate --approved flag. LinkedIn and WhatsApp return a Unipile hosted-auth URL instead (`oxygen senders connect` / `oxygen whatsapp connect` are the canonical paths).")
13341
13355
  .argument("<integration_id>", "Integration id, such as 'slack' or 'serpapi'.")
13342
13356
  .option("--api-key <value>", "API key for controlled noninteractive use. Literal values can appear in shell history and process arguments; prefer the preview web_url for manual entry.")
13343
13357
  .option("--credential <name=value>", "Named provider credential field. Repeat for multi-field integrations (for example, PostHog: --credential subdomain=us).", collectRepeatable, [])
13344
- .option("--account-id <id>", "Provider account id for integrations that span multiple accounts (e.g. Cloudflare when the token can access more than one account).")
13345
13358
  .option("--country <code>", "ISO 3166-1 alpha-2 country where the LinkedIn account owner normally signs in (required for `connect linkedin`; picks the egress proxy).")
13346
13359
  .option("--pairing-phone-number <e164>", "WhatsApp only: pair by code sent to this E.164 number instead of showing a QR.")
13347
13360
  .option("--acknowledge-risk", "LinkedIn only, required for a new account: LinkedIn restricts automated activity; pacing reduces but does not remove the risk.")
13348
- .option("--json", "Print a JSON envelope.")
13361
+ .option("--json", "Print a JSON envelope."), INTEGRATION_CONNECT_ACCOUNT_CONTRACT, {
13362
+ account_id: {
13363
+ valueName: "<id>",
13364
+ help: "Provider account id for integrations that span multiple accounts (e.g. Cloudflare when the token can access more than one account).",
13365
+ },
13366
+ })
13349
13367
  .action(async (integrationId, options) => {
13350
13368
  await handleAsyncAction("integrations connect", options, () => {
13351
13369
  const apiKey = readOption(options.apiKey)?.trim();
13352
13370
  const credentials = readNamedCredentials(options.credential);
13353
- const accountId = readOption(options.accountId);
13354
13371
  const country = readOption(options.country)?.trim();
13355
13372
  const pairingPhoneNumber = readOption(options.pairingPhoneNumber)?.trim();
13356
13373
  return requestOxygen("/api/cli/integrations/connect", {
@@ -13359,7 +13376,7 @@ Examples:
13359
13376
  integration_id: integrationId,
13360
13377
  ...(apiKey ? { api_key: apiKey } : {}),
13361
13378
  ...(credentials ? { credentials } : {}),
13362
- ...(accountId ? { account_id: accountId } : {}),
13379
+ ...readContractFlags(options, INTEGRATION_CONNECT_ACCOUNT_CONTRACT),
13363
13380
  ...(country ? { country } : {}),
13364
13381
  ...(pairingPhoneNumber ? { pairing_phone_number: pairingPhoneNumber } : {}),
13365
13382
  ...(options.acknowledgeRisk ? { acknowledge_linkedin_risk: true } : {}),
@@ -15683,11 +15700,11 @@ Examples:
15683
15700
  }, formatSequenceAnalyticsHealth);
15684
15701
  }))
15685
15702
  .addCommand(new Command("create")
15686
- .description("Create a draft multichannel sequence from a steps JSON file. Supports LinkedIn, email, WhatsApp, human call_task, and connected-CRM crm_task journeys. Assign LinkedIn senders with --senders (optional at create — a draft can sit senderless, but enroll/start require at least one for LinkedIn journeys). Native email sends through connected Google/Microsoft mailboxes, including Microsoft SMTP transport; optional --email-* flags bind an Instantly campaign. Install/read the oxygen-sequencer skill for complete mapped CRM task examples.")
15687
- .addHelpText("after", "\nSenders: each new lead gets one of the --sender-profiles people, and its LinkedIn and email steps come from that person (email from one of their inboxes). Per step, in the steps file: email_send.sender_mailbox_id sends that email from one exact inbox; a LinkedIn step's sender_profile_id sends it from one selected person. Invite, message, inmail and withdraw_invite steps must share one sender_profile_id or none: that person then owns every lead's LinkedIn thread (LinkedIn allows one of your accounts per conversation). visit_profile, follow, like_post and comment_post may each name anyone selected. An email whose From changes, including the return to the lead's inbox, needs its own subject_template.\n")
15703
+ .description("Create a draft multichannel sequence from a steps JSON file. Supports LinkedIn, email, WhatsApp, human call_task, and connected-CRM crm_task journeys. Assign LinkedIn senders with --senders (optional at create — a draft can sit senderless, but enroll/start require at least one for LinkedIn journeys). Native email sends through connected Google/Microsoft mailboxes, including Microsoft SMTP transport; optional --email-* flags bind an Instantly campaign. Install/read the oxygen-sequencer skill for per-step senders, the launch preview and complete mapped CRM task examples.")
15704
+ .addHelpText("after", "\nSenders: each new lead gets one of the --sender-profiles people, the one with the fewest of this sequence's leads (a lead already talking to one of your LinkedIn accounts keeps that account's person), and its LinkedIn and email steps come from that person (email from one of their inboxes). Per step, in the steps file: email_send.sender_profile_id sends that email from any inbox of one selected person (the normal inbox rotation spreads leads across that person's inboxes and each lead keeps its one; a lead already assigned to that person keeps its own inbox; with none usable the lead waits, never using a colleague's inbox), and email_send.sender_mailbox_id from one exact inbox (set one or neither); a LinkedIn step's sender_profile_id sends it from one selected person. Invite, message, inmail and withdraw_invite steps must share one sender_profile_id or none: that person then owns every lead's LinkedIn thread (LinkedIn allows one of your accounts per conversation). visit_profile, follow, like_post and comment_post may each name anyone selected. An email whose From changes, including the return to the lead's inbox, needs its own subject_template. To check who sends each step, run `sequences start <sequence>` without --approved; nothing changes.\n")
15688
15705
  .requiredOption("--name <name>", "Human-readable sequence name.")
15689
15706
  .requiredOption("--slug <slug>", "Unique slug for the sequence.")
15690
- .requiredOption("--steps-file <path>", "Path to a JSON file: { \"steps\": [...] }. LinkedIn steps (visit_profile | invite | wait_for_connection | message | inmail | follow | like_post | comment_post | withdraw_invite), email steps (email_send | email_enroll | email_move | email_stop), WhatsApp (whatsapp_message), human call tasks (call_task), connected-CRM tasks (crm_task; channel crm; exact identity record_mappings + explicit create/update policy + record_links + dynamic task property_mappings/associations), and control steps (wait | wait_for_signal | branch | stop), each with an `id`. Copy templates support {{column}} interpolation from the lead's row, {{column|fallback}} inline fallbacks, deterministic spintax — {{RANDOM|a|b|c}} or industry-standard bare {Hi|Hey|Hello}, nestable like {Would {Tuesday|Thursday} work|next week?} — and {% if column %}…{% endif %} conditionals; a bare {…} region is spintax only when it contains a top-level |, so literal braces (CSS/JSON) pass through. Every step's copy can also use {{sender_first_name}}, {{sender_last_name}} and {{sender_full_name}} (alias {{sender_name}}): the name of the person who sends that step (their sender profile, else the account or inbox display name), with no table column needed; email steps add {{sender_email}} (the from address). A row column of the same name WINS on collision, and a missing name renders empty. comment_post takes text_template and/or ai_prompt — a KG-grounded comment generated at send time (a paid AI call) that falls back to text_template if generation fails. A `branch` routes on signals (then/else) or the legacy connection_accepted/already_connected/open_profile sugar (then_id/else_id). A signal condition can also branch on the LEAD'S DATA: a data leaf { has_column: \"email\" } is true when that row_values column has a non-empty value (add present:false for \"missing\") — e.g. route leads that have an email down an email arm and the rest down a LinkedIn arm. MINIMAL EMAIL STEP SHAPE: { \"id\": \"s1\", \"channel\": \"email\", \"kind\": \"email_send\", \"subject_template\": \"...\", \"body_template\": \"...\" } — body_template is REQUIRED on email_send; subject_template is OPTIONAL and is the one control that decides threading: give a step its own subject and it goes out as a NEW email, omit it and the step continues the lead's previous email in the same thread, going out as \"Re: <the previous email's subject>\" (what the retired email_reply kind used to be — still accepted on input, rewritten into this shape, and reported under `warnings` in the response). The FIRST email step of a sequence must carry a subject, since it has no earlier thread to continue. MINIMAL WHATSAPP STEP SHAPE: { \"id\": \"s1\", \"channel\": \"whatsapp\", \"kind\": \"whatsapp_message\", \"template\": \"Hi {{first_name|there}} …\" } — template is REQUIRED, and a live WhatsApp start also needs the sequence's --whatsapp-cold-initiate opt-in. A/B tests use an explicit `variants` array of copy partials on the step (up to 25 alternates, a–z; spintax varies wording INSIDE one variant and is not A/B-tracked). For the complete replay-safe crm_task upsert mapping shape, install/read the oxygen-sequencer skill.")
15707
+ .requiredOption("--steps-file <path>", "Path to a JSON file: { \"steps\": [...] }. LinkedIn steps (visit_profile | invite | wait_for_connection | message | inmail | follow | like_post | comment_post | withdraw_invite), email steps (email_send | email_enroll | email_move | email_stop), WhatsApp (whatsapp_message), human call tasks (call_task), connected-CRM tasks (crm_task; channel crm; exact identity record_mappings + explicit create/update policy + record_links + dynamic task property_mappings/associations), and control steps (wait | wait_for_signal | branch | stop), each with an `id`. Copy templates support {{column}} interpolation from the lead's row, {{column|fallback}} inline fallbacks, deterministic spintax — {{RANDOM|a|b|c}} or industry-standard bare {Hi|Hey|Hello}, nestable like {Would {Tuesday|Thursday} work|next week?} — and {% if column %}…{% endif %} conditionals; a bare {…} region is spintax only when it contains a top-level |, so literal braces (CSS/JSON) pass through. Every step's copy can also use {{sender_first_name}}, {{sender_last_name}} and {{sender_full_name}} (alias {{sender_name}}): the name of the person who sends that step (their sender profile, else the account or inbox display name), with no table column needed; email steps add {{sender_email}} (the from address). A row column of the same name WINS on collision, and a missing name renders empty. comment_post takes text_template and/or ai_prompt — a KG-grounded comment generated at send time (a paid AI call) that falls back to text_template if generation fails. A `branch` routes on signals (then/else) or the legacy connection_accepted/already_connected/open_profile sugar (then_id/else_id). A signal condition can also branch on the LEAD'S DATA: a data leaf { has_column: \"email\" } is true when that row_values column has a non-empty value (add present:false for \"missing\") — e.g. route leads that have an email down an email arm and the rest down a LinkedIn arm. MINIMAL EMAIL STEP SHAPE: { \"id\": \"s1\", \"channel\": \"email\", \"kind\": \"email_send\", \"subject_template\": \"...\", \"body_template\": \"...\" } — body_template is REQUIRED on email_send; subject_template is OPTIONAL and is the one control that decides threading: give a step its own subject and it goes out as a NEW email, omit it and the step continues the lead's previous email in the same thread, going out as \"Re: <the previous email's subject>\" (what the retired email_reply kind used to be — still accepted on input, rewritten into this shape, and reported under `warnings` in the response). The FIRST email step of a sequence must carry a subject, since it has no earlier thread to continue. MINIMAL WHATSAPP STEP SHAPE: { \"id\": \"s1\", \"channel\": \"whatsapp\", \"kind\": \"whatsapp_message\", \"template\": \"Hi {{first_name|there}} …\" } — template is REQUIRED, and a live WhatsApp start also needs the sequence's --whatsapp-cold-initiate opt-in. MINIMAL WAIT STEP SHAPE: { \"id\": \"w1\", \"kind\": \"wait\", \"days\": 3 } — days and/or hours, at least 1 hour in total. A/B tests use an explicit `variants` array of copy partials on the step (up to 25 alternates, a–z; spintax varies wording INSIDE one variant and is not A/B-tracked). For the complete replay-safe crm_task upsert mapping shape, install/read the oxygen-sequencer skill.")
15691
15708
  .option("--channels <list>", "Comma-separated channels: linkedin,email,whatsapp,call,crm. Defaults to the channels the journey touches. crm_task currently supports HubSpot.")
15692
15709
  .option("--whatsapp-cold-initiate", "WhatsApp: allow cold-initiating new chats (no prior conversation). Required to start a WhatsApp sequence live — WhatsApp via Unipile is unofficial WhatsApp Web, so cold-initiating is an explicit ban-risk opt-in.")
15693
15710
  .option("--phone-column-key <key>", "WhatsApp: row_values key holding each lead's phone number (else falls back to phone/phone_number/mobile).")
@@ -15715,7 +15732,7 @@ Examples:
15715
15732
  .option("--email-min-gap-minutes <n>", "Minimum same-mailbox gap, default 12 minutes (integer 0-720; 0 disables the extra gap). Sends are spread across the window and may be farther apart. Daily caps and windows still apply.")
15716
15733
  .option("--opportunity-value <usd>", "Estimated USD value of one positive-reply opportunity (>= 0). Analytics-only: sequence stats multiply it by the positive-reply count to report pipeline $ (stats.opportunities). Never gates a send or spends a credit.")
15717
15734
  .option("--mailboxes <ids>", "Per-sequence sending-account allowlist for native email (Instantly's 'Accounts to use'): comma-separated mailbox ids. Native email sends rotate ONLY over these mailboxes instead of the whole pool. Omit for the whole pool. Note: a narrow allowlist plus --esp-matching strict can starve sends when no allowed same-provider mailbox exists (surfaced as blocked defer reasons in stats).")
15718
- .option("--sender-profiles <ids>", "Send from these sender profiles (unified LinkedIn/WhatsApp/inbox identities): comma-separated profile ids. The server resolves each into its senders + inboxes. Live start is blocked if a selected profile lacks an account for a channel the journey uses.")
15735
+ .option("--sender-profiles <ids>", "Send from these sender profiles (unified LinkedIn/WhatsApp/inbox identities): comma-separated profile ids (`oxygen senders profiles list` lists them). The server resolves each into its senders + inboxes. Live start is blocked if a selected profile lacks an account for a channel the journey uses.")
15719
15736
  .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.")
15720
15737
  .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.")
15721
15738
  .option("--no-exclude-contacted", "Turn the sequence-level exclude-contacted default back OFF (enrollments then exclude cross-campaign only when a call opts in).")
@@ -15802,7 +15819,7 @@ Examples:
15802
15819
  }))
15803
15820
  .addCommand(new Command("update")
15804
15821
  .description("Update a sequence. Journey structure/channels/email binding are draft-only so revised scope receives a fresh launch approval; a started sequence may only re-time waits it already has (clear a delay by zeroing it, never by deleting the step — enrollments track position by index); sender pools can change while draft or paused; launch caps change through `sequences start` after first start. Name, throttles, the sending schedule, tags, the unsubscribe option (off by default), and open/click tracking toggles remain editable as allowed by the server. Pass only the fields you want to change.")
15805
- .addHelpText("after", "\nSenders: email_send.sender_mailbox_id sends one email from an exact inbox of a selected sender; a LinkedIn step's sender_profile_id sends it from a selected person (invite, message, inmail and withdraw_invite share one or none). Omit both to use the lead's assigned person. An email whose From changes needs its own subject_template.\n")
15822
+ .addHelpText("after", "\nSenders: email_send.sender_profile_id sends one email from any inbox of a selected person (a lead already assigned to that person keeps its own inbox), and email_send.sender_mailbox_id from one exact inbox of a selected sender; a LinkedIn step's sender_profile_id sends it from a selected person (invite, message, inmail and withdraw_invite share one or none). Omit both to use the lead's assigned person. An email whose From changes needs its own subject_template. `sequences start <sequence>` without --approved shows who sends each step; nothing changes.\n")
15806
15823
  .argument("<sequence>", "Sequence id or slug.")
15807
15824
  .option("--name <name>", "New human-readable sequence name.")
15808
15825
  .option("--steps-file <path>", "Draft only: path to a JSON file: { \"steps\": [...] } replacing the journey. Copy supports {{column}} interpolation; every step also takes {{sender_first_name}} / {{sender_last_name}} / {{sender_full_name}} for the person who sends it, and email adds {{sender_email}} (a same-named row column wins). A `branch` can also route on the lead's data with a data leaf { has_column: \"email\" } (true when that row_values column is non-empty; present:false for \"missing\").")
@@ -15838,7 +15855,7 @@ Examples:
15838
15855
  .option("--email-min-gap-minutes <n>", "Minimum same-mailbox gap, default 12 minutes (integer 0-720; 0 disables the extra gap). Sends are spread across the window and may be farther apart. Daily caps and windows still apply.")
15839
15856
  .option("--opportunity-value <usd>", "Estimated USD value of one positive-reply opportunity (>= 0). Analytics-only: sequence stats multiply it by the positive-reply count to report pipeline $ (stats.opportunities). Never gates a send or spends a credit.")
15840
15857
  .option("--mailboxes <ids>", "Per-sequence sending-account allowlist for native email (Instantly's 'Accounts to use'): comma-separated mailbox ids. Native email sends rotate ONLY over these mailboxes instead of the whole pool. Note: a narrow allowlist plus --esp-matching strict can starve sends when no allowed same-provider mailbox exists (surfaced as blocked defer reasons in stats).")
15841
- .option("--sender-profiles <ids>", "Send from these sender profiles (unified LinkedIn/WhatsApp/inbox identities): comma-separated profile ids. The server resolves each into its senders + inboxes. Live start is blocked if a selected profile lacks an account for a channel the journey uses.")
15858
+ .option("--sender-profiles <ids>", "Send from these sender profiles (unified LinkedIn/WhatsApp/inbox identities): comma-separated profile ids (`oxygen senders profiles list` lists them). The server resolves each into its senders + inboxes. Live start is blocked if a selected profile lacks an account for a channel the journey uses.")
15842
15859
  .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.")
15843
15860
  .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.")
15844
15861
  .option("--no-exclude-contacted", "Turn the sequence-level exclude-contacted default back OFF (enrollments then exclude cross-campaign only when a call opts in).")
@@ -15919,7 +15936,7 @@ Examples:
15919
15936
  });
15920
15937
  }))
15921
15938
  .addCommand(new Command("get")
15922
- .description("Get a sequence's definition, senders, status, and credit usage. --readiness adds the launch check — whether it CAN go live, why not, and which mailboxes it would send from — without touching it.")
15939
+ .description("Get a sequence's definition, senders, status, and credit usage. --readiness adds the launch check — whether it CAN go live, why not, and the mailbox pool it would send from — without touching it; `sequences start <sequence>` without --approved shows who sends each step (sender_journey).")
15923
15940
  .argument("<sequence>", "Sequence id or slug.")
15924
15941
  .option("--readiness", "Add launch_readiness: blockers (e.g. a missing send cap), warnings, and the resolved sending mailboxes. Read-only — sends nothing, changes nothing, spends nothing. Skips the LinkedIn rendered-copy scan, which stays in `sequences start` without --approved.")
15925
15942
  .option("--json", "Print a JSON envelope.")
@@ -16014,12 +16031,12 @@ Examples:
16014
16031
  }, formatSequencePushExternal);
16015
16032
  }))
16016
16033
  .addCommand(new Command("start")
16017
- .description("Preview or start a sequence. Without --approved this is the launch check: it returns launch_readiness (blockers such as a missing send cap or the WhatsApp cold-initiate opt-in, plus warnings), scans pending LinkedIn copy, and returns exact rendered recipient samples, character counts/limits, copy blockers, per-kind scope, sender/mailbox capacity, CRM readiness, and safety caps. Live start requires --approved; --max-credits is optional. Email/WhatsApp/CRM-task journeys additionally require --max-live-sends because their external actions cost 0 Oxygen credits. Use --dry-run to simulate without any send, provider campaign, CRM task, or credits. The first live start also switches on the standing reply → CRM automation for the workspace.")
16034
+ .description("Preview or start a sequence. Without --approved this is the launch check and changes nothing: it returns launch_readiness (blockers such as a missing send cap or the WhatsApp cold-initiate opt-in, plus warnings), who sends each step (sender_journey: each step's From, its candidate inboxes and how a lead's inbox is picked; no leads needed), scans pending LinkedIn copy, and returns exact rendered recipient samples, character counts/limits, copy blockers, per-kind scope, sender/mailbox capacity, CRM readiness, and safety caps. Live start requires --approved; --max-credits is optional. Email/WhatsApp/CRM-task journeys additionally require --max-live-sends because their external actions cost 0 Oxygen credits. --dry-run is not this preview: it starts the sequence in simulated mode (it leaves draft), with no send, provider campaign, CRM task, or credits. The first live start also switches on the standing reply → CRM automation for the workspace.")
16018
16035
  .argument("<sequence>", "Sequence id or slug.")
16019
16036
  .option("--approved", "Approve and activate live. Without this flag, returns a preview only, including copy_preview rendered samples and blockers.")
16020
16037
  .option("--max-credits <n>", "Optional credit ceiling for the LinkedIn track (omit for an unbounded run).")
16021
16038
  .option("--max-live-sends <n>", "Live external-action ceiling (positive integer). Required for email, WhatsApp, or crm_task steps.")
16022
- .option("--dry-run", "Activate the sequence (status draft → active, startedAt set) in dry-run mode: every step advances with simulated actions — no sends, CRM writes, provider calls, or credits. This is NOT the preview: `sequences start` with no flags is the side-effect-free launch preview, and a dry-run sequence must be paused or deleted to leave that state.")
16039
+ .option("--dry-run", "Activate the sequence (status draft → active, startedAt set) in dry-run mode: every step advances with simulated actions — no sends, CRM writes, provider calls, or credits. This is NOT the preview: `sequences start` with no flags is the side-effect-free launch preview. A later live start (--approved) restarts those leads from the first step so they are contacted for real, except deleted source rows, a person another live campaign contacts under exclude_contacted, and older duplicates of a person.")
16023
16040
  .option("--json", "Print a JSON envelope.")
16024
16041
  .action(async (sequence, options) => {
16025
16042
  await handleAsyncAction("sequences start", options, () => {
@@ -16083,7 +16100,7 @@ Examples:
16083
16100
  await handleAsyncAction("sequences delete", options, () => requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}${options.approved ? "?approved=true" : ""}`, { method: "DELETE" }));
16084
16101
  }))
16085
16102
  .addCommand(new Command("enrollments")
16086
- .description("List per-lead enrollment state plus the latest action status, error, attempts, and retry time.")
16103
+ .description("List per-lead enrollment state plus the latest action status, error, attempts, and retry time. missing_source_row is true for a lead whose source-table row was deleted: still enrolled and contacted (remove with `contacts-remove --missing-source-rows`); null when the campaign has no live source table to check against, or the check failed.")
16087
16104
  .argument("<sequence>", "Sequence id or slug.")
16088
16105
  .option("--status <status>", "Filter by enrollment status (active, waiting_connection, replied, completed, stopped, failed).")
16089
16106
  .option("--limit <n>", "Maximum enrollments to return (1-500).")
@@ -16102,7 +16119,7 @@ Examples:
16102
16119
  });
16103
16120
  }))
16104
16121
  .addCommand(new Command("contacts")
16105
- .description("List a launched campaign's table-backed contacts with full source variables, contact tags, and factual or assigned sender. A contact carries every tag it reached: contacted means an invite, message or email was sent (a profile visit is not), and a positive reply is also replied and contacted. Contacts removed from this campaign are hidden; source rows remain intact.")
16122
+ .description("List a launched campaign's table-backed contacts with full source variables, contact tags, and factual or assigned sender. A contact carries every tag it reached: contacted means an invite, message or email was sent (a profile visit is not), and a positive reply is also replied and contacted. Contacts removed from this campaign are hidden; source rows remain intact. missing_source_row_count (first page only) counts enrolled leads whose source-table row was deleted: still contacted, never listed (remove them with `contacts-remove --missing-source-rows`).")
16106
16123
  .argument("<sequence>", "Sequence id or slug.")
16107
16124
  .option("--contact-state <tags>", "Only contacts with any of these tags, comma-separated: not_contacted, contacted, connected, replied, positive_reply; or all.", "all")
16108
16125
  .option("--exclude-contact-state <tags>", "Hide contacts with any of these tags, comma-separated.")
@@ -16152,27 +16169,39 @@ Examples:
16152
16169
  });
16153
16170
  }))
16154
16171
  .addCommand(new Command("contacts-remove")
16155
- .description("Remove selected source-table rows from this campaign only. Preview by default; --approved stops pending enrollment work and queues delegated-provider cleanup while preserving source rows and historical analytics.")
16172
+ .description("Remove leads from this campaign only: selected source-table rows (--table-row-ids), or every enrolled lead whose source-table row was deleted (--missing-source-rows; deleting a row never removes its lead). Preview by default and names those leads; --approved stops pending enrollment work and queues delegated-provider cleanup while preserving source rows and historical analytics. Permanent: a removed lead cannot be re-enrolled in this campaign. Finished leads stay as they are, except one a dry run finished while the sequence is still in dry run, which a live start would otherwise restart.")
16156
16173
  .argument("<sequence>", "Sequence id or slug.")
16157
- .requiredOption("--table-row-ids <ids>", "Comma-separated source table row UUIDs (maximum 500).")
16174
+ .option("--table-row-ids <ids>", "Comma-separated source table row UUIDs (maximum 500).")
16175
+ .option("--missing-source-rows", "Select enrolled leads whose source-table row was deleted (up to 500 per call, oldest first).")
16158
16176
  .option("--approved", "Apply the previewed campaign-only removal.")
16159
16177
  .option("--json", "Print a JSON envelope.")
16160
16178
  .action(async (sequence, options) => {
16161
- await handleAsyncAction("sequences contacts-remove", options, () => {
16162
- const tableRowIds = options.tableRowIds
16163
- .split(",")
16164
- .map((value) => value.trim())
16165
- .filter(Boolean);
16166
- if (tableRowIds.length === 0 || tableRowIds.length > 500) {
16167
- throw new Error("--table-row-ids must contain between 1 and 500 row UUIDs.");
16179
+ await handleAsyncAction("sequences contacts-remove", options, async () => {
16180
+ const rowIdsOption = readOption(options.tableRowIds);
16181
+ if (Boolean(rowIdsOption) === (options.missingSourceRows === true)) {
16182
+ throw new OxygenError("invalid_request", "Pass exactly one of --table-row-ids <ids> or --missing-source-rows.", { exitCode: 2 });
16168
16183
  }
16169
- return requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/contacts`, {
16184
+ let selector = { missing_source_rows: true };
16185
+ if (rowIdsOption) {
16186
+ const tableRowIds = rowIdsOption
16187
+ .split(",")
16188
+ .map((value) => value.trim())
16189
+ .filter(Boolean);
16190
+ if (tableRowIds.length === 0 || tableRowIds.length > 500) {
16191
+ throw new Error("--table-row-ids must contain between 1 and 500 row UUIDs.");
16192
+ }
16193
+ selector = { table_row_ids: tableRowIds };
16194
+ }
16195
+ const data = await requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/contacts`, {
16170
16196
  method: "POST",
16171
16197
  body: {
16172
- table_row_ids: tableRowIds,
16198
+ ...selector,
16173
16199
  approved: options.approved === true,
16174
16200
  },
16175
16201
  });
16202
+ if (!options.json)
16203
+ writeContactsRemoveSummary(data);
16204
+ return data;
16176
16205
  });
16177
16206
  }))
16178
16207
  .addCommand(new Command("pause-company")
@@ -17275,7 +17304,7 @@ Examples:
17275
17304
  await handleAsyncAction("managed-inboxes registrant", options, () => requestOxygen("/api/cli/managed-inboxes/registrant"));
17276
17305
  }))
17277
17306
  .addCommand(new Command("subscribe")
17278
- .description("Subscribe a NEW domain + mailboxes as a managed monthly inbox subscription. WITHOUT --approved this prints a priced PREVIEW with a quote_id (nothing ordered, nothing charged); re-run with --approved --quote <id> to place the order. OXYGEN Warm-up defaults on for Google, Microsoft, and Azure when available: preview and approved responses carry `warmup_activation` with the exact `mailboxes` and `scope=warmup_only`; it is null when warmup is disabled or unavailable. For InboxKit, Google carries `transport=inboxkit_managed_google_handoff`; Microsoft/Azure carries `transport=inboxkit_sequencer_export`. Both carry `mailbox_credentials_required=false` and `native_send_oauth_separate=true`, so managed warm-up needs no customer-supplied OAuth or mailbox password. Google retrieves the existing managed credential only during activation and never persists or returns it; Microsoft/Azure uses native export. Native sending authorization is separate and may still be required before OXYGEN can send. Verify each exact address with `mailboxes oauth-health --json`; InboxKit's unattended Google domain approval usually lands within minutes and the worker keeps retrying, so a waiting row is not a reason to ask the customer to sign in. This order authorizes automatic OXYGEN Warm-up activation after provisioning, so no second `mailboxes warmup enable` approval is needed. The preview shows the price in force: from the 2026-09 repricing date a Google inbox is 500 credits a month all-in (a flat 400 mailbox line plus the 100-credit mailbox connection, warm-up included, which `connection_monthly_credits` shows and your balance reserves once the inbox connects); Microsoft and Azure mailboxes are priced from the vendor's LIVE rate card. Domains are priced from the vendor's LIVE domain price, and any price below vendor cost is refused. A domain you bought on its own through Oxygen (`domains buy`) takes its first inboxes here too: the preview says `domain_source: owned`, nothing is registered again and `one_off_credits` is 0 (Google or Microsoft; omit --years). To add mailboxes to a domain that already has inboxes use `managed-inboxes add-inboxes`.")
17307
+ .description("Buy managed monthly inboxes on a new domain or your existing Cloudflare domain. Use --existing-domain for Cloudflare: the read-only preview shows ownership, occupancy, DNS scope and blockers, with zero registration charge. --max-per-domain saves a stricter limit with the approved order, including existing addresses; later additions honor it. Preview does not save the limit or change workspace data. WITHOUT --approved this prints a priced PREVIEW with a quote_id (nothing ordered, nothing charged); re-run with --approved --quote <id> to place the order. OXYGEN Warm-up defaults on for Google, Microsoft, and Azure when available: preview and approved responses carry `warmup_activation` with the exact `mailboxes` and `scope=warmup_only`; it is null when warmup is disabled or unavailable. For InboxKit, Google carries `transport=inboxkit_managed_google_handoff`; Microsoft/Azure carries `transport=inboxkit_sequencer_export`. Both carry `mailbox_credentials_required=false` and `native_send_oauth_separate=true`, so managed warm-up needs no customer-supplied OAuth or mailbox password. Google retrieves the existing managed credential only during activation and never persists or returns it; Microsoft/Azure uses native export. Native sending authorization is separate and may still be required before OXYGEN can send. Verify each exact address with `mailboxes oauth-health --json`; InboxKit's unattended Google domain approval usually lands within minutes and the worker keeps retrying, so a waiting row is not a reason to ask the customer to sign in. This order authorizes automatic OXYGEN Warm-up activation after provisioning, so no second `mailboxes warmup enable` approval is needed. The preview shows the price in force: from the 2026-09 repricing date a Google inbox is 500 credits a month all-in (a flat 400 mailbox line plus the 100-credit mailbox connection, warm-up included, which `connection_monthly_credits` shows and your balance reserves once the inbox connects); Microsoft and Azure mailboxes are priced from the vendor's LIVE rate card. Domains are priced from the vendor's LIVE domain price, and any price below vendor cost is refused. A domain you bought on its own through Oxygen (`domains buy`) takes its first inboxes here too: the preview says `domain_source: owned`, nothing is registered again and `one_off_credits` is 0 (Google or Microsoft; omit --years). To add mailboxes to a domain that already has inboxes use `managed-inboxes add-inboxes`.")
17279
17308
  .argument("[domain]", "Sending domain to register + host the mailboxes (e.g. send.acme.com), or one you already bought through Oxygen. May also be passed as --domain.")
17280
17309
  .option("--domain <domain>", "Sending domain (alternative to the positional argument).")
17281
17310
  .requiredOption("--provider <provider>", "Mailbox PLATFORM: google, microsoft, or azure. (google/microsoft cap at 5 mailboxes per domain; azure allows 100.)")
@@ -17284,7 +17313,10 @@ Examples:
17284
17313
  .option("--locals <list>", "Comma-separated local parts to create for --sender, e.g. `--locals talia,talia.rosen,t.rosen` on send.acme.com orders talia@, talia.rosen@, and t.rosen@ — all owned by that one person. Requires --sender, because a bare local part carries no name and the vendor's stamp is permanent.")
17285
17314
  .option("--mailboxes <json>", "JSON array of mailboxes: [{\"username\",\"first_name\",\"last_name\",\"profile_picture_url\"}], or [{\"username\",\"sender_profile_id\"}] to take the identity from a sender profile per item (mix freely). profile_picture_url is optional and must be a PUBLIC, PERMANENT https URL — the vendor fetches it server-side at provisioning, so an expiring CDN link (e.g. a media.licdn.com URL with e=<epoch>) ships the mailbox faceless. Use `oxygen managed-inboxes upload-avatar <path>` to host one, or --sender / sender_profile_id, whose photo Oxygen already mirrors.")
17286
17315
  .option("--file <path>", "Path to a JSON file { \"mailboxes\": [...] } (alternative to --mailboxes).")
17287
- .option("--years <n>", "Years to register the domain for (1-10). Defaults to 1.")
17316
+ .addOption(new Option("--preview", "Explicit read-only quote: connects nothing, buys nothing and charges nothing. Cannot be combined with --approved.").conflicts("approved"))
17317
+ .option("--existing-domain", "Use an existing Cloudflare domain from oxygen domains list; never register or transfer it.")
17318
+ .option("--max-per-domain <n>", "Save a stricter mailbox limit with the approved order, including existing addresses (e.g. 2). Preview does not save it.")
17319
+ .option("--years <n>", "Years to register a new domain for (1-10). Omit for existing domains.")
17288
17320
  .option("--redirect-url <url>", "Where the domain's web root redirects. Defaults to your workspace's company website; editable later with `oxygen domains forwarding set`.")
17289
17321
  .option("--billing <path>", "Path to a JSON file with the registrant/WHOIS contact (first_name, last_name, phone, country, city, state, address_line_one, postal_code). Required on your FIRST order; later orders reuse the registrant already on file.")
17290
17322
  .option("--no-warmup", "Order WITHOUT automatic OXYGEN Warm-up. Warm-up is included by default and starts after provisioning under this order approval for the exact quoted Google/Microsoft/Azure addresses; opting out means using BYO warm-up or a later standalone warmup approval.")
@@ -17321,6 +17353,8 @@ Examples:
17321
17353
  mailboxes,
17322
17354
  ...(vendor ? { vendor } : {}),
17323
17355
  ...(years ? { years: Number(years) } : {}),
17356
+ ...(options.existingDomain ? { existing_domain: true } : {}),
17357
+ ...(options.maxPerDomain !== undefined ? { max_mailboxes_per_domain: Number(options.maxPerDomain) } : {}),
17324
17358
  ...(redirectUrl ? { redirect_url: redirectUrl } : {}),
17325
17359
  ...(billing ? { billing } : {}),
17326
17360
  // Warm-up is on unless --no-warmup. Placement is opt-in: it is sent only
@@ -19735,14 +19769,19 @@ Full trigger schema: oxygen workflows schema --subject trigger --json
19735
19769
  .action(async (options) => {
19736
19770
  await handleAsyncAction("skills install", options, () => installAgentSkills(options));
19737
19771
  }));
19738
- applyOxygenHelp(program, binaryName);
19739
19772
  registerVisualCommands(program, handleAsyncAction);
19740
19773
  registerFunctionsCommands(program, handleAsyncAction);
19741
19774
  registerUgcCommands(program, handleAsyncAction);
19742
19775
  registerKnowledgeRepositoryCommands(program, handleAsyncAction);
19743
19776
  registerConnectorsCommands(program, handleAsyncAction);
19744
- // Last, so every command registered above is covered — including the ones the
19745
- // register* helpers add after applyOxygenHelp.
19777
+ registerSettingsCommands(program, handleAsyncAction);
19778
+ registerCrmSupportFlags(program);
19779
+ // After the register* helpers: help groups are assigned to the commands that
19780
+ // exist when this runs, and a family registered later fell into commander's
19781
+ // trailing default "Commands:" list (the 2026-09-30 blind agent read the first
19782
+ // 80 lines of help and missed `connectors` there).
19783
+ applyOxygenHelp(program, binaryName);
19784
+ // Last, so every command registered above is covered.
19746
19785
  enableCommandSuggestions(program);
19747
19786
  return program;
19748
19787
  }
@@ -26315,7 +26354,15 @@ function formatSequenceListHealth(data) {
26315
26354
  lines.push("No sequences match these filters.");
26316
26355
  }
26317
26356
  else {
26318
- lines.push(...renderTextTable(["SEQUENCE", "PERSISTED", "OPERATIONAL", "OPEN WORK", "SENT / FAILED", "REASONS"], sequences.map((sequence) => sequenceHealthRow(sequence))));
26357
+ lines.push(...renderTextTable(["SEQUENCE", "PERSISTED", "OPERATIONAL", "OPEN WORK", "SENT / FAILED", "REASONS"], sequences.map((sequence) => {
26358
+ // Warnings print below the table, so the row points at them: a reader
26359
+ // skimming the rows missed the deleted-row warning (T-220 acceptance).
26360
+ const row = sequenceHealthRow(sequence);
26361
+ const warningCount = sequenceRowWarnings([sequence]).length;
26362
+ if (warningCount > 0)
26363
+ row[row.length - 1] = `${row[row.length - 1]}; ${warningCount} warning${warningCount === 1 ? "" : "s"} below`;
26364
+ return row;
26365
+ })));
26319
26366
  const warnings = sequenceRowWarnings(sequences);
26320
26367
  if (warnings.length > 0)
26321
26368
  lines.push("", ...warnings);
@@ -26466,7 +26513,10 @@ function formatSequenceStatsHealth(data) {
26466
26513
  if (failureMix !== "—")
26467
26514
  lines.push(`Failure mix: ${failureMix}`);
26468
26515
  lines.push(...formatActionKindBreakdown(operational.failureAnalytics));
26469
- lines.push("", `Enrolled: ${numberText(stats.enrolled)}`);
26516
+ // Every lead ever enrolled, removed and finished included, so name the split
26517
+ // beside it: "Enrolled: 6" next to 4 in progress read as a miscount (T-220 acceptance).
26518
+ const byStatus = formatCountRecord(recordValue(stats.byStatus));
26519
+ lines.push("", `Enrolled (all time): ${numberText(stats.enrolled)}${byStatus === "—" ? "" : ` — ${byStatus}`}`);
26470
26520
  const replyLine = formatSequenceReplyRollup(stats.replies);
26471
26521
  if (replyLine)
26472
26522
  lines.push(replyLine);
@@ -29215,6 +29265,66 @@ function readCompanyIdentityOptions(options, verb) {
29215
29265
  function readRepeatableIds(values) {
29216
29266
  return [...new Set((values ?? []).flatMap((value) => readCsvOption(value)))];
29217
29267
  }
29268
+ // `sequences contacts-remove` for a human reader: what the call selects, the
29269
+ // people it names (a --missing-source-rows preview exists so nobody approves a
29270
+ // removal by UUID), and the next step. stderr, like the other summaries.
29271
+ function writeContactsRemoveSummary(data) {
29272
+ const payload = asPayloadRecord(data);
29273
+ if (!payload)
29274
+ return;
29275
+ const count = (value) => (typeof value === "number" ? value : 0);
29276
+ const lines = [];
29277
+ const leads = asPayloadRecord(payload.leads);
29278
+ const approved = payload.approved === true;
29279
+ if (payload.mode === "missing_source_rows") {
29280
+ const total = count(leads?.total);
29281
+ // Counts recorded deletions only, so zero is never read as "none deleted".
29282
+ lines.push(`${total} enrolled lead${total === 1 ? " has" : "s have"} a recorded deletion from this campaign's source table.`);
29283
+ }
29284
+ lines.push(approved
29285
+ ? `Removed ${count(payload.removed)} from the campaign.`
29286
+ : `Preview: ${count(payload.removable)} removable, ${count(payload.contacted)} already contacted, ${count(payload.skippedTerminal)} already finished.`);
29287
+ // What approving costs and touches, stated where the reader decides; with
29288
+ // nothing removable there is nothing to approve, so no line invites it.
29289
+ if (!approved && count(payload.removable) > 0 && typeof payload.credits_used === "number") {
29290
+ lines.push(`Cost: ${payload.credits_used} credits. Approving stops only these campaign enrollments: nothing is sent, and table rows are kept.`);
29291
+ }
29292
+ const items = Array.isArray(leads?.items) ? leads.items : [];
29293
+ for (const entry of items) {
29294
+ const lead = asPayloadRecord(entry);
29295
+ if (!lead)
29296
+ continue;
29297
+ const name = typeof lead.name === "string" && lead.name.trim() ? lead.name.trim() : "(unnamed lead)";
29298
+ // After approval the server reports each lead's new state; "stopped" is this removal.
29299
+ const state = approved
29300
+ ? lead.status === "stopped" ? "removed" : `not removed (${String(lead.status ?? "unknown")})`
29301
+ : String(lead.status ?? "unknown");
29302
+ lines.push(` ${name} — ${state}${lead.contacted === true ? ", already contacted" : ""}`);
29303
+ }
29304
+ // One call covers at most per_call_limit leads (the oldest); the server's
29305
+ // next_step says to repeat for the rest, so count the unnamed ones in this call.
29306
+ const total = count(leads?.total);
29307
+ const covered = typeof payload.per_call_limit === "number" ? Math.min(total, payload.per_call_limit) : total;
29308
+ const unnamed = covered - items.length;
29309
+ if (unnamed > 0)
29310
+ lines.push(` …and ${unnamed} more${covered < total ? " in this call" : ""}`);
29311
+ if (typeof payload.next_step === "string")
29312
+ lines.push(payload.next_step);
29313
+ if (typeof payload.web_url === "string")
29314
+ lines.push(`Open: ${payload.web_url}`);
29315
+ process.stderr.write(`${lines.join("\n")}\n`);
29316
+ }
29317
+ // `tables delete-row|delete-rows` for a human reader: a deleted row's lead stays
29318
+ // enrolled in its campaigns, so name them where the delete was typed.
29319
+ function writeRowDeleteWarnings(data) {
29320
+ const payload = asPayloadRecord(data);
29321
+ const warnings = Array.isArray(payload?.warnings) ? payload.warnings : [];
29322
+ for (const entry of warnings) {
29323
+ const warning = asPayloadRecord(entry);
29324
+ if (warning && typeof warning.message === "string")
29325
+ process.stderr.write(`Warning: ${warning.message}\n`);
29326
+ }
29327
+ }
29218
29328
  // Human-mode summary of a company pause/resume/list on stderr (stdout keeps the
29219
29329
  // machine-readable envelope): what was held or resumed, per campaign, and where
29220
29330
  // to see it.
@@ -0,0 +1,11 @@
1
+ import { Command } from "commander";
2
+ import { type ContractFlagOverlay } from "./contract-flags.js";
3
+ type JsonOptions = {
4
+ json?: boolean;
5
+ };
6
+ type Handle = (command: string, options: JsonOptions, action: () => Promise<unknown>) => Promise<void>;
7
+ export declare const WORKSPACE_PAGES_FLAG_OVERLAY: ContractFlagOverlay;
8
+ /** The POST body for the flags given; --hide, --show or --show-all is required. */
9
+ export declare function buildWorkspacePagesBody(options: Record<string, unknown>): Record<string, unknown>;
10
+ export declare function registerSettingsCommands(program: Command, handle: Handle): void;
11
+ export {};
@@ -0,0 +1,72 @@
1
+ // `oxygen settings`: workspace settings every member reads and admins change.
2
+ // Its family today is `settings pages`, the web app's sidebar pages a workspace
3
+ // hides for everyone (Settings → Customise). The set command's flags come from
4
+ // the shared contract that also generates `oxygen_settings_pages_update` and
5
+ // validates the route, so the CLI and MCP cannot disagree about what can be set.
6
+ import { Command } from "commander";
7
+ import { OxygenError } from "@oxygen/shared/cli-result";
8
+ import { WORKSPACE_PAGES_UPDATE_OPERATION } from "@oxygen/shared/contracts/workspace-pages";
9
+ import { addContractFlags, readContractFlags } from "./contract-flags.js";
10
+ import { requestOxygen } from "./http-client.js";
11
+ const update = WORKSPACE_PAGES_UPDATE_OPERATION;
12
+ export const WORKSPACE_PAGES_FLAG_OVERLAY = {
13
+ hide: {
14
+ help: "Pages to hide from the sidebar for everyone, comma-separated, each by its path (/unibox) or name (Unibox) from `settings pages list`. Hiding a section hides its sub-pages.",
15
+ valueName: "<pages>",
16
+ },
17
+ show: {
18
+ help: "Hidden pages to show again, comma-separated, by path or name.",
19
+ valueName: "<pages>",
20
+ },
21
+ show_all: {
22
+ help: "Show every page again. With --hide, only those pages stay hidden.",
23
+ // `--no-show-all` would change nothing, and reads like "hide all".
24
+ noNegate: true,
25
+ },
26
+ };
27
+ function names(value) {
28
+ return Array.isArray(value) && value.length > 0;
29
+ }
30
+ /** The POST body for the flags given; --hide, --show or --show-all is required. */
31
+ export function buildWorkspacePagesBody(options) {
32
+ const body = readContractFlags(options, update.contract, WORKSPACE_PAGES_FLAG_OVERLAY);
33
+ if (!names(body.hide) && !names(body.show) && body.show_all !== true) {
34
+ throw new OxygenError("invalid_request", "Nothing to change: pass --hide <pages> to hide pages for everyone, --show <pages> to show hidden ones again, or --show-all. `settings pages list` names every page.", { exitCode: 1 });
35
+ }
36
+ return body;
37
+ }
38
+ export function registerSettingsCommands(program, handle) {
39
+ const settings = program
40
+ .command("settings")
41
+ .description("Workspace settings: what every member of this workspace sees. "
42
+ + "`settings pages` is Settings → Customise in the web app: hide or show pages in the sidebar navigation (the left menu) for everyone. "
43
+ + "`settings pages list` shows each page and whether it is hidden; an admin or owner changes it with `settings pages set --hide <pages>`, `--show <pages>` or `--show-all`. "
44
+ + "Nothing is deleted: a hidden page stays reachable by URL. Free.");
45
+ const pages = new Command("pages")
46
+ .description("The web app's sidebar pages this workspace hides for every member (Settings → Customise): page visibility in the navigation menu. "
47
+ + "`list` shows every page with its section and whether it is hidden or visible; `set` hides or shows pages (admins and owners only). "
48
+ + "Hiding a section hides its sub-pages. Presentation only: nothing is deleted, and a hidden page stays reachable by URL and deep-link. Every response carries its `web_url`.");
49
+ pages.addCommand(new Command("list")
50
+ .description("Page visibility in the web app's sidebar navigation menu (Settings → Customise): every page (path, name, section, parent), whether it is shown or hidden for everyone in this workspace, and whether you may customize it (can_change: admins and owners). Read-only, 0 credits.")
51
+ .option("--json", "Print a JSON envelope.")
52
+ .action(async (options) => {
53
+ await handle("settings pages list", options, () => requestOxygen(update.route));
54
+ }));
55
+ pages.addCommand(addContractFlags(new Command(update.cli.path.at(-1) ?? "set").description(update.cli.summary), update.contract, WORKSPACE_PAGES_FLAG_OVERLAY)
56
+ .option("--json", "Print a JSON envelope.")
57
+ .addHelpText("after", `
58
+ Examples:
59
+ oxygen settings pages list
60
+ oxygen settings pages set --hide Unibox,Publishing
61
+ oxygen settings pages set --show /unibox
62
+ oxygen settings pages set --show-all
63
+ oxygen settings pages set --show-all --hide Workflows
64
+ `)
65
+ .action(async (options) => {
66
+ await handle("settings pages set", options, () => requestOxygen(update.route, {
67
+ method: update.method,
68
+ body: buildWorkspacePagesBody(options),
69
+ }));
70
+ }));
71
+ settings.addCommand(pages);
72
+ }