@oxygen-agent/cli 1.177.1 → 1.184.3

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
@@ -9,7 +9,7 @@ import { createInterface } from "node:readline/promises";
9
9
  import { stdin as input, stdout as output } from "node:process";
10
10
  import { fileURLToPath, pathToFileURL } from "node:url";
11
11
  import { Command, Option } from "commander";
12
- import { formatCellForDisplay, OXYGEN_VERSION, OxygenError, success, toFailure } from "@oxygen/shared";
12
+ import { formatCellForDisplay, formatPublicBudgetScopes, OXYGEN_VERSION, OxygenError, success, toFailure, } from "@oxygen/shared";
13
13
  import { inferImportColumnLabels, inferRowsFileFormat, normalizeImportColumnKey, normalizeRowsForNewTable, normalizeRowsFormat, parseRowsFileBuffer, } from "@oxygen/shared/file-import";
14
14
  import { assertRecipeBundleSafe, assertWorkflowManifest, buildRecipeManifest, compileWorkflowDefinition, isAnyWorkflowManifest, isRecipeManifest, isWorkflowDefinition, isWorkflowManifest, } from "@oxygen/workflows";
15
15
  import { isRecipeDefinition } from "@oxygen/recipe-sdk";
@@ -382,6 +382,15 @@ function buildCrmAssertBody(object, options) {
382
382
  mode: resolveCrmSetupMode(options),
383
383
  };
384
384
  }
385
+ function buildCrmSearchBody(query, options) {
386
+ const objects = readCsvOption(options.objects);
387
+ const limit = readPositiveInt(options.limit);
388
+ return {
389
+ query,
390
+ ...(objects.length > 0 ? { objects } : {}),
391
+ ...(limit !== undefined ? { limit } : {}),
392
+ };
393
+ }
385
394
  function buildCrmRelationshipUpsertBody(object, rowId, options) {
386
395
  return {
387
396
  object,
@@ -1025,6 +1034,18 @@ export function createProgram() {
1025
1034
  .option("--json", "Print a JSON envelope.")
1026
1035
  .action(async (options) => {
1027
1036
  await handleAsyncAction("crm objects", options, () => requestOxygen("/api/cli/crm/objects"));
1037
+ }))
1038
+ .addCommand(new Command("search")
1039
+ .description("Search CRM records by identity or record label.")
1040
+ .argument("<query>", "Domain, email, LinkedIn URL, or record name to search for.")
1041
+ .option("--objects <objects>", "Comma-separated CRM object slugs to search. Defaults to all configured objects.")
1042
+ .option("--limit <limit>", "Maximum records to return.")
1043
+ .option("--json", "Print a JSON envelope.")
1044
+ .action(async (query, options) => {
1045
+ await handleAsyncAction("crm search", options, () => requestOxygen("/api/cli/crm/records/search", {
1046
+ method: "POST",
1047
+ body: buildCrmSearchBody(query, options),
1048
+ }));
1028
1049
  }))
1029
1050
  .addCommand(new Command("assert")
1030
1051
  .description("Create or update one CRM record by object identity. Defaults to dry-run.")
@@ -2017,6 +2038,7 @@ export function createProgram() {
2017
2038
  .option("--force", "Run even when the target cell already has a value.")
2018
2039
  .option("--connection-id <connection_id>", "Optional provider integration connection id.")
2019
2040
  .option("--background", "Create a durable background table action run instead of executing synchronously.")
2041
+ .option("--approved", "Confirm the paid background run after inspecting a dry run or preview.")
2020
2042
  .option("--max-credits <n>", "Maximum managed/provider credits to reserve for a background run.")
2021
2043
  .option("--max-concurrency <n>", "Maximum concurrent row items for a background run. Defaults to 250 for AI columns and 50 otherwise.")
2022
2044
  .option("--local", "Run a custom HTTP column in this CLI process so env-var secrets stay local.")
@@ -2075,6 +2097,7 @@ export function createProgram() {
2075
2097
  ...(options.force ? { force: true } : {}),
2076
2098
  ...(readOption(options.connectionId) ? { connection_id: readOption(options.connectionId) } : {}),
2077
2099
  ...(options.background ? { background: true } : {}),
2100
+ ...(options.approved ? { approved: true } : {}),
2078
2101
  ...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
2079
2102
  ...(maxConcurrency ? { max_concurrency: maxConcurrency } : {}),
2080
2103
  ...(options.dryRun ? { dry_run: true } : {}),
@@ -2262,6 +2285,7 @@ export function createProgram() {
2262
2285
  .option("--filter-json <json>", "Filter object or array for server-side row selection.")
2263
2286
  .option("--force", "Run even when the target cell already has a value.")
2264
2287
  .option("--connection-id <connection_id>", "Optional provider integration connection id.")
2288
+ .option("--approved", "Confirm the paid table action run after inspecting a dry run or preview.")
2265
2289
  .option("--max-credits <n>", "Maximum managed/provider credits to reserve for this run.")
2266
2290
  .option("--max-concurrency <n>", "Maximum concurrent row items for this run.")
2267
2291
  .option("--metadata-json <json>", "Optional metadata object to attach to the run.")
@@ -2279,6 +2303,7 @@ export function createProgram() {
2279
2303
  selection: readTableRunSelection(options),
2280
2304
  ...(options.force ? { force: true } : {}),
2281
2305
  ...(readOption(options.connectionId) ? { connection_id: readOption(options.connectionId) } : {}),
2306
+ ...(options.approved ? { approved: true } : {}),
2282
2307
  ...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
2283
2308
  ...(maxConcurrency ? { max_concurrency: maxConcurrency } : {}),
2284
2309
  ...(options.metadataJson ? { metadata: parseJsonObject(options.metadataJson) } : {}),
@@ -2975,7 +3000,7 @@ export function createProgram() {
2975
3000
  .description("Standing credit caps (org/table daily/monthly hard-blocks) beyond per-run max_credits.")
2976
3001
  .addCommand(new Command("list")
2977
3002
  .description("List the organization's standing budget policies.")
2978
- .option("--scope <scope>", "Filter by scope: org, table, api_key, workflow_trigger, monitor.")
3003
+ .option("--scope <scope>", `Filter by scope: ${formatPublicBudgetScopes()}.`)
2979
3004
  .option("--status <status>", "Filter by status. Defaults to all.")
2980
3005
  .option("--json", "Print a JSON envelope.")
2981
3006
  .action(async (options) => {
@@ -2991,7 +3016,7 @@ export function createProgram() {
2991
3016
  }))
2992
3017
  .addCommand(new Command("set")
2993
3018
  .description("Set, raise, or lower a standing credit cap (idempotent per scope+window).")
2994
- .requiredOption("--scope <scope>", "org, table, api_key, workflow_trigger, or monitor.")
3019
+ .requiredOption("--scope <scope>", formatPublicBudgetScopes())
2995
3020
  .requiredOption("--window <window>", "per_run, daily, or monthly.")
2996
3021
  .requiredOption("--max-credits <credits>", "Cap in Oxygen credits.")
2997
3022
  .option("--scope-id <id>", "Table id/slug for --scope table (or other scope id). Omit for a scope-wide cap.")
@@ -3732,6 +3757,8 @@ export function createProgram() {
3732
3757
  .option("--live", "Execute the action live. Default is dry-run.")
3733
3758
  .option("--dry-run", "Force dry-run (no provider call).")
3734
3759
  .option("--mode <mode>", "'live' or 'dry_run'. Overridden by --live/--dry-run if provided.")
3760
+ .option("--approved", "Required for live actions after inspecting dry-run output.")
3761
+ .option("--max-credits <n>", "Required positive credit cap for live actions.")
3735
3762
  .option("--json", "Print a JSON envelope.")
3736
3763
  .action(async (integrationId, actionSlug, options) => {
3737
3764
  await handleAsyncAction("integrations run", options, () => {
@@ -3739,6 +3766,7 @@ export function createProgram() {
3739
3766
  ? parseJsonObject(readOption(options.input))
3740
3767
  : {};
3741
3768
  const mode = resolveComposioRunMode(options);
3769
+ const maxCredits = readPositiveNumber(options.maxCredits);
3742
3770
  return requestOxygen("/api/cli/integrations/composio/run", {
3743
3771
  method: "POST",
3744
3772
  body: {
@@ -3746,6 +3774,8 @@ export function createProgram() {
3746
3774
  action_slug: actionSlug,
3747
3775
  arguments: args,
3748
3776
  mode,
3777
+ ...(options.approved ? { approved: true } : {}),
3778
+ ...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
3749
3779
  },
3750
3780
  });
3751
3781
  });
@@ -3857,10 +3887,18 @@ export function createProgram() {
3857
3887
  program.addCommand(new Command("inbox")
3858
3888
  .description("Unified inbox (unibox): LinkedIn conversations and (--channel email) the fleet's email conversations synced from Zapmail Zapbox. Scan, read threads, and reply.")
3859
3889
  .addCommand(new Command("list")
3860
- .description("List conversations across all connected accounts, newest first. --channel email lists the Zapbox-synced email inbox.")
3890
+ .description("List conversations across all connected accounts, newest first. --channel email lists the Zapbox-synced email inbox; filter it by status, campaign, mailbox provider/domain, and Primary/Others bucket.")
3861
3891
  .option("--channel <channel>", "Inbox channel: linkedin (default) or email.")
3862
3892
  .option("--account <id>", "LinkedIn only: filter to one sender account (sender id, connection id, or Unipile account id).")
3863
3893
  .option("--unread", "Only show conversations with unread messages.")
3894
+ .option("--responses-only", "Email only: only conversations with an inbound reply (never sent-only threads).")
3895
+ .option("--bucket <bucket>", "Email only: primary or others.")
3896
+ .option("--status <keys>", "Email only: comma-separated status keys (e.g. interested,meeting_booked).")
3897
+ .option("--sequence-id <ids>", "Email only: comma-separated campaign (sequence) ids.")
3898
+ .option("--provider <providers>", "Email only: comma-separated providers (google,microsoft).")
3899
+ .option("--domain <domains>", "Email only: comma-separated counterpart domains to include.")
3900
+ .option("--exclude-domain <domains>", "Email only: comma-separated counterpart domains to exclude.")
3901
+ .option("--mailbox-id <ids>", "Email only: comma-separated mailbox ids.")
3864
3902
  .option("--search <text>", "Filter by attendee name or last-message text.")
3865
3903
  .option("--include-archived", "Include archived conversations.")
3866
3904
  .option("--limit <n>", "Maximum conversations to return (1-200). Defaults to 50.")
@@ -3876,6 +3914,21 @@ export function createProgram() {
3876
3914
  params.set("account", account);
3877
3915
  if (options.unread)
3878
3916
  params.set("unread", "true");
3917
+ if (options.responsesOnly)
3918
+ params.set("responses_only", "true");
3919
+ for (const [flag, key] of [
3920
+ ["bucket", "bucket"],
3921
+ ["status", "status"],
3922
+ ["sequenceId", "sequence_id"],
3923
+ ["provider", "provider"],
3924
+ ["domain", "domain"],
3925
+ ["excludeDomain", "exclude_domain"],
3926
+ ["mailboxId", "mailbox_id"],
3927
+ ]) {
3928
+ const value = readOption(options[flag]);
3929
+ if (value)
3930
+ params.set(key, value);
3931
+ }
3879
3932
  const search = readOption(options.search);
3880
3933
  if (search)
3881
3934
  params.set("search", search);
@@ -3913,16 +3966,19 @@ export function createProgram() {
3913
3966
  .requiredOption("--text <message>", "Reply text to send.")
3914
3967
  .option("--channel <channel>", "Inbox channel: linkedin (default) or email.")
3915
3968
  .option("--approved", "Approve and send the message. Without this flag, returns a preview only.")
3969
+ .option("--draft-id <id>", "Email only: when approving an AI reply-agent draft, its id (marks it sent on success).")
3916
3970
  .option("--json", "Print a JSON envelope.")
3917
3971
  .action(async (conversation, options) => {
3918
3972
  await handleAsyncAction("inbox send", options, () => {
3919
3973
  const channel = readOption(options.channel);
3974
+ const draftId = readOption(options.draftId);
3920
3975
  return requestOxygen(`/api/cli/inbox/${encodeURIComponent(conversation)}/send`, {
3921
3976
  method: "POST",
3922
3977
  body: {
3923
3978
  text: readOption(options.text),
3924
3979
  ...(channel ? { channel } : {}),
3925
3980
  ...(options.approved ? { approved: true } : {}),
3981
+ ...(draftId ? { draft_id: draftId } : {}),
3926
3982
  },
3927
3983
  });
3928
3984
  });
@@ -3977,7 +4033,167 @@ export function createProgram() {
3977
4033
  body.message_limit = Number(messageLimit);
3978
4034
  return requestOxygen("/api/cli/inbox/sync", { method: "POST", body });
3979
4035
  });
3980
- })));
4036
+ }))
4037
+ .addCommand(new Command("status")
4038
+ .description("Set an email conversation's status (the Instantly-style tier). A manual override that locks out AI re-classification.")
4039
+ .argument("<conversation>", "Conversation id or Zapbox thread id.")
4040
+ .argument("<status>", "A status label key (e.g. interested, meeting_booked, won, not_interested).")
4041
+ .option("--json", "Print a JSON envelope.")
4042
+ .action(async (conversation, status, options) => {
4043
+ await handleAsyncAction("inbox status", options, () => requestOxygen(`/api/cli/inbox/${encodeURIComponent(conversation)}/status`, {
4044
+ method: "POST",
4045
+ body: { status },
4046
+ }));
4047
+ }))
4048
+ .addCommand(new Command("labels")
4049
+ .description("Manage inbox status labels (the Instantly-style system set + custom labels).")
4050
+ .addCommand(new Command("list")
4051
+ .description("List status labels.")
4052
+ .option("--include-archived", "Include archived custom labels.")
4053
+ .option("--json", "Print a JSON envelope.")
4054
+ .action(async (options) => {
4055
+ await handleAsyncAction("inbox labels list", options, () => requestOxygen(`/api/cli/inbox/labels${options.includeArchived ? "?include_archived=true" : ""}`));
4056
+ }))
4057
+ .addCommand(new Command("create")
4058
+ .description("Create a custom status label.")
4059
+ .argument("<name>", "Display name.")
4060
+ .option("--color <hex>", "Hex color (e.g. #22c55e).")
4061
+ .option("--bucket <bucket>", "primary (default) or others.")
4062
+ .option("--json", "Print a JSON envelope.")
4063
+ .action(async (name, options) => {
4064
+ await handleAsyncAction("inbox label create", options, () => {
4065
+ const color = readOption(options.color);
4066
+ const bucket = readOption(options.bucket);
4067
+ return requestOxygen("/api/cli/inbox/labels", {
4068
+ method: "POST",
4069
+ body: { name, ...(color ? { color } : {}), ...(bucket ? { bucket } : {}) },
4070
+ });
4071
+ });
4072
+ }))
4073
+ .addCommand(new Command("update")
4074
+ .description("Update a status label's name, color, or bucket.")
4075
+ .argument("<key>", "The label key.")
4076
+ .option("--name <name>", "New display name.")
4077
+ .option("--color <hex>", "New hex color.")
4078
+ .option("--bucket <bucket>", "New bucket (primary or others).")
4079
+ .option("--json", "Print a JSON envelope.")
4080
+ .action(async (key, options) => {
4081
+ await handleAsyncAction("inbox label update", options, () => {
4082
+ const name = readOption(options.name);
4083
+ const color = readOption(options.color);
4084
+ const bucket = readOption(options.bucket);
4085
+ return requestOxygen(`/api/cli/inbox/labels/${encodeURIComponent(key)}`, {
4086
+ method: "PATCH",
4087
+ body: { ...(name ? { name } : {}), ...(color ? { color } : {}), ...(bucket ? { bucket } : {}) },
4088
+ });
4089
+ });
4090
+ }))
4091
+ .addCommand(new Command("delete")
4092
+ .description("Archive a custom status label (system labels and in-use labels are rejected).")
4093
+ .argument("<key>", "The custom label key.")
4094
+ .option("--json", "Print a JSON envelope.")
4095
+ .action(async (key, options) => {
4096
+ await handleAsyncAction("inbox label archive", options, () => requestOxygen(`/api/cli/inbox/labels/${encodeURIComponent(key)}`, { method: "DELETE" }));
4097
+ })))
4098
+ .addCommand(new Command("drafts")
4099
+ .description("The AI reply-agent draft queue (the approve-before-send review queue).")
4100
+ .addCommand(new Command("list")
4101
+ .description("List drafts awaiting review (queued + edited by default).")
4102
+ .option("--status <keys>", "Comma-separated draft statuses (queued,edited,sent,rejected).")
4103
+ .option("--sequence-id <id>", "Filter to one campaign (sequence) id.")
4104
+ .option("--limit <n>", "Maximum drafts to return.")
4105
+ .option("--json", "Print a JSON envelope.")
4106
+ .action(async (options) => {
4107
+ await handleAsyncAction("inbox drafts list", options, () => {
4108
+ const params = new URLSearchParams();
4109
+ const status = readOption(options.status);
4110
+ if (status)
4111
+ params.set("status", status);
4112
+ const sequenceId = readOption(options.sequenceId);
4113
+ if (sequenceId)
4114
+ params.set("sequence_id", sequenceId);
4115
+ const limit = readOption(options.limit);
4116
+ if (limit)
4117
+ params.set("limit", limit);
4118
+ const suffix = params.toString();
4119
+ return requestOxygen(`/api/cli/inbox/drafts${suffix ? `?${suffix}` : ""}`);
4120
+ });
4121
+ }))
4122
+ .addCommand(new Command("edit")
4123
+ .description("Edit an open draft's body before approval. Does not send.")
4124
+ .argument("<draft>", "The draft id.")
4125
+ .requiredOption("--text <message>", "The new draft body.")
4126
+ .option("--json", "Print a JSON envelope.")
4127
+ .action(async (draft, options) => {
4128
+ await handleAsyncAction("inbox draft update", options, () => requestOxygen(`/api/cli/inbox/drafts/${encodeURIComponent(draft)}`, {
4129
+ method: "PATCH",
4130
+ body: { text: readOption(options.text) },
4131
+ }));
4132
+ }))
4133
+ .addCommand(new Command("reject")
4134
+ .description("Reject an open draft so it leaves the queue. Does not send.")
4135
+ .argument("<draft>", "The draft id.")
4136
+ .option("--reason <text>", "Optional reason.")
4137
+ .option("--json", "Print a JSON envelope.")
4138
+ .action(async (draft, options) => {
4139
+ await handleAsyncAction("inbox draft reject", options, () => {
4140
+ const reason = readOption(options.reason);
4141
+ return requestOxygen(`/api/cli/inbox/drafts/${encodeURIComponent(draft)}/reject`, {
4142
+ method: "POST",
4143
+ body: reason ? { reason } : {},
4144
+ });
4145
+ });
4146
+ })))
4147
+ .addCommand(new Command("reply-agent")
4148
+ .description("The draft-only AI reply agent (the 'AI Sales Agent'): auto-classifies + auto-drafts replies into the review queue. It NEVER auto-sends.")
4149
+ .addCommand(new Command("get")
4150
+ .description("Show the reply-agent config.")
4151
+ .option("--json", "Print a JSON envelope.")
4152
+ .action(async (options) => {
4153
+ await handleAsyncAction("inbox reply-agent get", options, () => requestOxygen("/api/cli/inbox/reply-agent"));
4154
+ }))
4155
+ .addCommand(new Command("set")
4156
+ .description("Configure the reply agent (enable, persona/tone, targets). It drafts into the queue; a human approves+sends.")
4157
+ .option("--enabled", "Enable the agent.")
4158
+ .option("--disabled", "Disable the agent.")
4159
+ .option("--persona <text>", "Who the agent is.")
4160
+ .option("--tone <text>", "Reply tone.")
4161
+ .option("--instructions <text>", "Extra drafting instructions.")
4162
+ .option("--signature <text>", "Signature appended to drafts.")
4163
+ .option("--target-statuses <keys>", "Comma-separated statuses to draft for (empty = all draftable).")
4164
+ .option("--target-sequence-ids <ids>", "Comma-separated campaign ids to draft for (empty = all).")
4165
+ .option("--max-drafts-per-day <n>", "Per-day draft cap.")
4166
+ .option("--json", "Print a JSON envelope.")
4167
+ .action(async (options) => {
4168
+ await handleAsyncAction("inbox reply-agent set", options, () => {
4169
+ const body = {};
4170
+ if (options.enabled)
4171
+ body.enabled = true;
4172
+ if (options.disabled)
4173
+ body.enabled = false;
4174
+ // Presence (flag passed) — not readOption — decides inclusion, so an
4175
+ // absent flag leaves the field untouched rather than clearing it.
4176
+ const opts = options;
4177
+ for (const [flag, key] of [
4178
+ ["persona", "persona"],
4179
+ ["tone", "tone"],
4180
+ ["instructions", "instructions"],
4181
+ ["signature", "signature"],
4182
+ ]) {
4183
+ if (opts[flag] !== undefined)
4184
+ body[key] = opts[flag];
4185
+ }
4186
+ if (options.targetStatuses !== undefined) {
4187
+ body.target_statuses = options.targetStatuses.split(",").map((s) => s.trim()).filter(Boolean);
4188
+ }
4189
+ if (options.targetSequenceIds !== undefined) {
4190
+ body.target_sequence_ids = options.targetSequenceIds.split(",").map((s) => s.trim()).filter(Boolean);
4191
+ }
4192
+ if (options.maxDraftsPerDay !== undefined)
4193
+ body.max_drafts_per_day = Number(options.maxDraftsPerDay);
4194
+ return requestOxygen("/api/cli/inbox/reply-agent", { method: "POST", body });
4195
+ });
4196
+ }))));
3981
4197
  program.addCommand(new Command("sequences")
3982
4198
  .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.")
3983
4199
  .addCommand(new Command("list")
@@ -4033,6 +4249,9 @@ export function createProgram() {
4033
4249
  .option("--email-connection <id>", "Instantly connection id for the email track. Defaults to the org's active Instantly connection.")
4034
4250
  .option("--email-definition-file <path>", "Path to a JSON file with the email content spec (subjects/bodies/delays/subsequences) compiled to an Instantly campaign on start.")
4035
4251
  .option("--max-credits <n>", "Credit cap for the LinkedIn track (also set when starting).")
4252
+ .option("--max-live-sends <n>", "Send ceiling for the email track (positive integer). Required to start an email sequence live.")
4253
+ .option("--max-emails-per-mailbox-per-day <n>", "Per-mailbox daily email cap (positive integer) applied across the sending pool.")
4254
+ .option("--send-window-file <path>", "Path to a JSON file with the sequence-level email send window: { timezone, days?, start, end, timezone_mode?, recipient_timezone_column? }.")
4036
4255
  .option("--json", "Print a JSON envelope.")
4037
4256
  .action(async (options) => {
4038
4257
  await handleAsyncAction("sequences create", options, () => {
@@ -4044,6 +4263,8 @@ export function createProgram() {
4044
4263
  const senders = readCsvOption(options.senders);
4045
4264
  const email = readCampaignEmailBinding(options);
4046
4265
  const maxCredits = readPositiveNumber(options.maxCredits);
4266
+ const maxLiveSends = readPositiveInteger(options.maxLiveSends);
4267
+ const settings = readSequenceSettings(options);
4047
4268
  return requestOxygen("/api/cli/sequences", {
4048
4269
  method: "POST",
4049
4270
  body: {
@@ -4056,6 +4277,8 @@ export function createProgram() {
4056
4277
  ...(readOption(options.urlColumn) ? { linkedin_url_column_key: readOption(options.urlColumn) } : {}),
4057
4278
  ...(email ? { email } : {}),
4058
4279
  ...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
4280
+ ...(maxLiveSends !== undefined ? { max_live_sends: maxLiveSends } : {}),
4281
+ ...(settings ? { settings } : {}),
4059
4282
  },
4060
4283
  });
4061
4284
  });
@@ -4072,6 +4295,9 @@ export function createProgram() {
4072
4295
  .option("--email-definition-file <path>", "Path to a JSON file with the email content spec (subjects/bodies/delays/subsequences).")
4073
4296
  .option("--clear-email", "Remove the email binding from the sequence (draft only).")
4074
4297
  .option("--max-credits <n>", "Credit cap for the LinkedIn track (draft only).")
4298
+ .option("--max-live-sends <n>", "Send ceiling for the email track (positive integer). Required to start an email sequence live.")
4299
+ .option("--max-emails-per-mailbox-per-day <n>", "Per-mailbox daily email cap (positive integer) applied across the sending pool.")
4300
+ .option("--send-window-file <path>", "Path to a JSON file with the sequence-level email send window: { timezone, days?, start, end, timezone_mode?, recipient_timezone_column? }.")
4075
4301
  .option("--json", "Print a JSON envelope.")
4076
4302
  .action(async (sequence, options) => {
4077
4303
  await handleAsyncAction("sequences update", options, () => {
@@ -4092,6 +4318,12 @@ export function createProgram() {
4092
4318
  const maxCredits = readPositiveNumber(options.maxCredits);
4093
4319
  if (maxCredits !== undefined)
4094
4320
  body.max_credits = maxCredits;
4321
+ const maxLiveSends = readPositiveInteger(options.maxLiveSends);
4322
+ if (maxLiveSends !== undefined)
4323
+ body.max_live_sends = maxLiveSends;
4324
+ const settings = readSequenceSettings(options);
4325
+ if (settings)
4326
+ body.settings = settings;
4095
4327
  if (options.clearEmail) {
4096
4328
  body.email = null;
4097
4329
  }
@@ -4101,7 +4333,7 @@ export function createProgram() {
4101
4333
  body.email = email;
4102
4334
  }
4103
4335
  if (Object.keys(body).length === 0) {
4104
- throw new Error("Provide at least one field to update (--name, --steps-file, --channels, --senders, --email-*, --clear-email, or --max-credits).");
4336
+ throw new Error("Provide at least one field to update (--name, --steps-file, --channels, --senders, --email-*, --clear-email, --max-credits, --max-live-sends, --max-emails-per-mailbox-per-day, or --send-window-file).");
4105
4337
  }
4106
4338
  return requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}`, {
4107
4339
  method: "PATCH",
@@ -4138,20 +4370,33 @@ export function createProgram() {
4138
4370
  .argument("<sequence>", "Sequence id or slug.")
4139
4371
  .option("--approved", "Approve and activate live. Without this flag, returns a preview only.")
4140
4372
  .option("--max-credits <n>", "Credit cap (required with --approved).")
4373
+ .option("--max-live-sends <n>", "Email send ceiling (positive integer). Required to start a sequence with email steps live.")
4141
4374
  .option("--dry-run", "Activate in dry-run mode: advance every step with simulated sends, no LinkedIn actions, no credits.")
4142
4375
  .option("--json", "Print a JSON envelope.")
4143
4376
  .action(async (sequence, options) => {
4144
4377
  await handleAsyncAction("sequences start", options, () => {
4145
4378
  const maxCredits = readPositiveNumber(options.maxCredits);
4379
+ const maxLiveSends = readPositiveInteger(options.maxLiveSends);
4146
4380
  return requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/start`, {
4147
4381
  method: "POST",
4148
4382
  body: {
4149
4383
  ...(options.dryRun ? { dry_run: true } : {}),
4150
4384
  ...(options.approved ? { approved: true } : {}),
4151
4385
  ...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
4386
+ ...(maxLiveSends !== undefined ? { max_live_sends: maxLiveSends } : {}),
4152
4387
  },
4153
4388
  });
4154
4389
  });
4390
+ }))
4391
+ .addCommand(new Command("signal")
4392
+ .description("Record an external GTM signal (e.g. linkedin_connected, email_replied, company_raised_funds, job_change, web_visit, intent) onto a running sequence's enrollment(s) to drive signal-triggered branch/wait control. Target an enrollment by --enrollment or --lead (at least one is required); the server validates the signal name.")
4393
+ .argument("<sequence>", "Sequence id or slug.")
4394
+ .requiredOption("--signal <name>", "Signal to record: linkedin_connected, linkedin_replied, email_sent, email_opened, email_clicked, email_replied, email_bounced, company_hiring, company_raised_funds, job_change, new_hire, web_visit, intent.")
4395
+ .option("--enrollment <id>", "Target enrollment id.")
4396
+ .option("--lead <provider_id>", "Target enrollment by its lead provider id.")
4397
+ .option("--json", "Print a JSON envelope.")
4398
+ .action(async (sequence, options) => {
4399
+ await handleSequenceSignalAction(sequence, options);
4155
4400
  }))
4156
4401
  .addCommand(new Command("pause")
4157
4402
  .description("Pause an active sequence (stops new dispatches; enrollments resume on un-pause).")
@@ -4205,6 +4450,13 @@ export function createProgram() {
4205
4450
  .option("--json", "Print a JSON envelope.")
4206
4451
  .action(async (sequence, options) => {
4207
4452
  await handleAsyncAction("sequences stats", options, () => requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/stats`));
4453
+ }))
4454
+ .addCommand(new Command("variants")
4455
+ .description("Break the sequence's email performance down by A/B variant (per step) and by sending mailbox: sent, replied, reply rate, bounced/failed, and credits used.")
4456
+ .argument("<sequence>", "Sequence id or slug.")
4457
+ .option("--json", "Print a JSON envelope.")
4458
+ .action(async (sequence, options) => {
4459
+ await handleSequenceVariantsAction(sequence, options);
4208
4460
  })));
4209
4461
  program.addCommand(new Command("email")
4210
4462
  .description("Ad-hoc email from the org's sending fleet: one-off sends with no sequence, enrollment, or contact sync. Zapbox-connected mailboxes send through Zapmail's API (no Google/Microsoft consent); BYOK — 0 Oxygen credits.")
@@ -4459,6 +4711,16 @@ export function createProgram() {
4459
4711
  .option("--json", "Print a JSON envelope.")
4460
4712
  .action(async (domains, options) => {
4461
4713
  await handleAsyncAction("domains buy", options, () => runDomainsBuy(domains, options));
4714
+ }))
4715
+ .addCommand(new Command("adopt")
4716
+ .description("STAFF: Import domains that already exist in the shared Oxygen-managed Cloudflare account into a workspace, then mirror them into the domain cache. FREE — no Oxygen credits and no Cloudflare purchase (the domains are already registered); this differs from `domains buy`. Idempotent. Without --yes, prints a preview of what would be adopted/skipped.")
4717
+ .argument("[domains...]", "Specific domains to adopt. Omit and pass --all to adopt every zone in the managed account.")
4718
+ .option("--all", "Adopt every zone discovered in the managed Cloudflare account.")
4719
+ .option("--organization-id <id>", "STAFF override: adopt into this workspace instead of the calling org.")
4720
+ .option("--yes", "Execute the adoption. Without this flag, prints a preview only.")
4721
+ .option("--json", "Print a JSON envelope.")
4722
+ .action(async (domains, options) => {
4723
+ await handleAsyncAction("domains adopt", options, () => runDomainsAdopt(domains, options));
4462
4724
  }))
4463
4725
  .addCommand(new Command("registrations")
4464
4726
  .description("List the org's domain purchase ledger: every registration attempt with status and price snapshot.")
@@ -4765,6 +5027,44 @@ export function createProgram() {
4765
5027
  method: "POST",
4766
5028
  body: { run_id: runId },
4767
5029
  }));
5030
+ }))
5031
+ .addCommand(new Command("approvals")
5032
+ .description("List workflow runs awaiting a human approval decision (the mid-run approval inbox).")
5033
+ .option("--status <status>", "Filter: pending (default), approved, rejected, expired, or all.")
5034
+ .option("--run-id <run_id>", "Only approvals for this workflow run.")
5035
+ .option("--limit <n>", "Maximum approvals to return. Defaults to 50.")
5036
+ .option("--json", "Print a JSON envelope.")
5037
+ .action(async (options) => {
5038
+ await handleAsyncAction("workflows approvals", options, () => {
5039
+ const query = new URLSearchParams();
5040
+ if (readOption(options.status))
5041
+ query.set("status", readOption(options.status) ?? "");
5042
+ if (readOption(options.runId))
5043
+ query.set("run_id", readOption(options.runId) ?? "");
5044
+ const limit = readPositiveInt(options.limit);
5045
+ if (limit)
5046
+ query.set("limit", String(limit));
5047
+ const suffix = query.toString() ? `?${query.toString()}` : "";
5048
+ return requestOxygen(`/api/cli/workflows/approvals${suffix}`);
5049
+ });
5050
+ }))
5051
+ .addCommand(new Command("resume")
5052
+ .description("Approve or reject a paused mid-run workflow approval, resuming the run.")
5053
+ .argument("<run_id>", "Workflow run UUID.")
5054
+ .requiredOption("--decision <decision>", "approve or reject.")
5055
+ .option("--step-id <step_id>", "Approval step id, when a run has more than one pending gate.")
5056
+ .option("--plan-hash <plan_hash>", "Reviewed plan hash; the decision is refused if the plan changed since review.")
5057
+ .option("--json", "Print a JSON envelope.")
5058
+ .action(async (runId, options) => {
5059
+ await handleAsyncAction("workflows resume", options, () => requestOxygen("/api/cli/workflows/resume", {
5060
+ method: "POST",
5061
+ body: {
5062
+ run_id: runId,
5063
+ decision: readOption(options.decision),
5064
+ ...(readOption(options.stepId) ? { step_id: readOption(options.stepId) } : {}),
5065
+ ...(readOption(options.planHash) ? { plan_hash: readOption(options.planHash) } : {}),
5066
+ },
5067
+ }));
4768
5068
  }))
4769
5069
  .addCommand(new Command("enable")
4770
5070
  .description("Enable a workflow automation and its current trigger.")
@@ -4817,7 +5117,46 @@ export function createProgram() {
4817
5117
  }));
4818
5118
  return program;
4819
5119
  }
5120
+ /**
5121
+ * Map a workflow compile/validation throw to a typed `OxygenError`. The
5122
+ * compiler and linter (`compileWorkflowDefinition`, `assertWorkflowManifest`,
5123
+ * `serializeWorkflowFunction`, cron parsing) throw ordinary `Error`s shaped
5124
+ * `"<code>: <message>"`, which `@oxygen/shared`'s `toFailure` would otherwise
5125
+ * flatten to a machine-hostile `unexpected_error` — making `workflows lint`
5126
+ * (whose whole job is to report authoring mistakes) read like an internal
5127
+ * crash. Preserve the embedded lint code so the failure mirrors what a manifest
5128
+ * lint would return (`duplicate_step_id`, `invalid_cron`, `invalid_max_credits`,
5129
+ * …). Same precedent as `parseJsonValue`.
5130
+ */
5131
+ function asWorkflowCompileError(error, filePath) {
5132
+ if (error instanceof OxygenError)
5133
+ return error;
5134
+ const fsCode = error?.code;
5135
+ if (typeof fsCode === "string" && /^E[A-Z]+$/.test(fsCode)) {
5136
+ return new OxygenError("workflow_file_unreadable", `Could not read workflow file: ${error instanceof Error ? error.message : fsCode}`, { details: { file: filePath }, exitCode: 1 });
5137
+ }
5138
+ const message = error instanceof Error ? error.message : String(error);
5139
+ const match = /^([a-z][a-z0-9_]*): ([\s\S]+)$/.exec(message);
5140
+ if (match?.[1] && match[2]) {
5141
+ return new OxygenError(match[1], match[2], {
5142
+ details: { file: filePath, phase: "compile" },
5143
+ exitCode: 1,
5144
+ });
5145
+ }
5146
+ return new OxygenError("invalid_workflow", message, {
5147
+ details: { file: filePath, phase: "compile" },
5148
+ exitCode: 1,
5149
+ });
5150
+ }
4820
5151
  async function compileWorkflowFile(filePath) {
5152
+ try {
5153
+ return await loadWorkflowManifestFromFile(filePath);
5154
+ }
5155
+ catch (error) {
5156
+ throw asWorkflowCompileError(error, filePath);
5157
+ }
5158
+ }
5159
+ async function loadWorkflowManifestFromFile(filePath) {
4821
5160
  const absolutePath = resolve(filePath);
4822
5161
  const source = readFileSync(absolutePath, "utf8");
4823
5162
  const extension = extname(absolutePath).toLowerCase();
@@ -5214,7 +5553,10 @@ function normalizeWorkflowRunErrors(value) {
5214
5553
  return output;
5215
5554
  }
5216
5555
  function isTerminalWorkflowRunStatus(status) {
5217
- return status === "completed" || status === "failed" || status === "canceled";
5556
+ // 'awaiting_approval' pauses the run indefinitely for a human decision (lease
5557
+ // cleared, excluded from claim + lease sweep), so it must stop the tail and
5558
+ // surface the approval rather than poll forever.
5559
+ return status === "completed" || status === "failed" || status === "canceled" || status === "awaiting_approval";
5218
5560
  }
5219
5561
  function tableWebhookListPath(options) {
5220
5562
  const params = new URLSearchParams();
@@ -6496,6 +6838,38 @@ function domainsBuyRerunCommand(domains, quoteId, options, data) {
6496
6838
  ];
6497
6839
  return `${resolveCliBinaryName()} domains buy ${domains.join(" ")} ${flags.join(" ")}`;
6498
6840
  }
6841
+ // Staff import of pre-existing managed-account domains. Exactly one of an explicit
6842
+ // list or --all; without --yes the server returns a preview and the CLI appends a
6843
+ // copy-paste rerun command to execute it.
6844
+ async function runDomainsAdopt(domains, options) {
6845
+ const hasExplicit = domains.length > 0;
6846
+ if (hasExplicit === Boolean(options.all)) {
6847
+ throw new Error("Pass exactly one of <domains...> or --all. Use --all to adopt every domain in the managed account, or list specific domains.");
6848
+ }
6849
+ const organizationId = readOption(options.organizationId);
6850
+ const data = await requestOxygen("/api/cli/domains/adopt", {
6851
+ method: "POST",
6852
+ body: {
6853
+ ...(options.all ? { all: true } : {}),
6854
+ ...(hasExplicit ? { domains } : {}),
6855
+ ...(organizationId ? { organization_id: organizationId } : {}),
6856
+ ...(options.yes ? { approved: true } : {}),
6857
+ },
6858
+ });
6859
+ if (options.yes)
6860
+ return data;
6861
+ return { ...data, rerun_command: domainsAdoptRerunCommand(domains, options) };
6862
+ }
6863
+ function domainsAdoptRerunCommand(domains, options) {
6864
+ const organizationId = readOption(options.organizationId);
6865
+ const flags = [
6866
+ ...(options.all ? ["--all"] : []),
6867
+ ...(organizationId ? [`--organization-id ${organizationId}`] : []),
6868
+ "--yes",
6869
+ ];
6870
+ const args = domains.length > 0 ? ` ${domains.join(" ")}` : "";
6871
+ return `${resolveCliBinaryName()} domains adopt${args} ${flags.join(" ")}`;
6872
+ }
6499
6873
  // succeeded/failed are terminal; action_required stops the wait too because
6500
6874
  // only the user (in the Cloudflare dashboard) can move it forward.
6501
6875
  function isSettledDomainRegistrationStatus(status) {
@@ -8108,6 +8482,125 @@ function formatProfileUseSuccess(profile, options) {
8108
8482
  }
8109
8483
  return lines.join("\n");
8110
8484
  }
8485
+ // `sequences signal` records an external GTM signal onto a running sequence's
8486
+ // enrollment(s). The signal *name* is validated server-side against the typed
8487
+ // enum; the CLI only enforces that a signal was supplied and that at least one
8488
+ // enrollment target (--enrollment / --lead) is present, so an unscoped call
8489
+ // fails before spending a request.
8490
+ async function handleSequenceSignalAction(sequence, options) {
8491
+ try {
8492
+ const signal = readOption(options.signal);
8493
+ if (!signal)
8494
+ throw new Error("--signal is required.");
8495
+ const enrollmentId = readOption(options.enrollment);
8496
+ const leadProviderId = readOption(options.lead);
8497
+ if (!enrollmentId && !leadProviderId) {
8498
+ throw new Error("Provide a target enrollment with --enrollment <id> and/or --lead <provider_id>.");
8499
+ }
8500
+ const data = await requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/signal`, {
8501
+ method: "POST",
8502
+ body: {
8503
+ signal,
8504
+ ...(enrollmentId ? { enrollment_id: enrollmentId } : {}),
8505
+ ...(leadProviderId ? { lead_provider_id: leadProviderId } : {}),
8506
+ },
8507
+ });
8508
+ if (options.json) {
8509
+ writeJson(success("sequences signal", data));
8510
+ return;
8511
+ }
8512
+ const updated = typeof data.enrollments_updated === "number" ? data.enrollments_updated : 0;
8513
+ const recordedSignal = data.signal ?? signal;
8514
+ const link = data.deepLink ?? data.web_url;
8515
+ process.stdout.write(`Recorded ${recordedSignal} on ${updated} enrollment(s).${link ? ` ${link}` : ""}\n`);
8516
+ }
8517
+ catch (error) {
8518
+ const failure = toFailure("sequences signal", error);
8519
+ writeJson(failure);
8520
+ writeMaxCreditsHint(error);
8521
+ process.exitCode = error instanceof OxygenError ? error.exitCode : 1;
8522
+ }
8523
+ }
8524
+ async function handleSequenceVariantsAction(sequence, options) {
8525
+ try {
8526
+ const data = await requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/variants`);
8527
+ if (options.json) {
8528
+ writeJson(success("sequences variants", data));
8529
+ return;
8530
+ }
8531
+ process.stdout.write(formatSequenceVariants(data));
8532
+ }
8533
+ catch (error) {
8534
+ const failure = toFailure("sequences variants", error);
8535
+ writeJson(failure);
8536
+ writeMaxCreditsHint(error);
8537
+ process.exitCode = error instanceof OxygenError ? error.exitCode : 1;
8538
+ }
8539
+ }
8540
+ function formatVariantCell(value) {
8541
+ if (value === null || value === undefined)
8542
+ return "—";
8543
+ return String(value);
8544
+ }
8545
+ function formatRatePercent(value) {
8546
+ if (typeof value !== "number" || !Number.isFinite(value))
8547
+ return "—";
8548
+ // Rates arrive as a 0–1 fraction; render as a one-decimal percentage.
8549
+ return `${(value * 100).toFixed(1)}%`;
8550
+ }
8551
+ function renderVariantTable(headers, rows) {
8552
+ const widths = headers.map((header, columnIndex) => {
8553
+ let max = header.length;
8554
+ for (const row of rows) {
8555
+ const cell = row[columnIndex] ?? "";
8556
+ if (cell.length > max)
8557
+ max = cell.length;
8558
+ }
8559
+ return max;
8560
+ });
8561
+ const renderRow = (cells) => ` ${cells.map((cell, i) => cell.padEnd(widths[i] ?? 0)).join(" ")}`.replace(/\s+$/, "");
8562
+ const separator = ` ${widths.map((width) => "-".repeat(width)).join(" ")}`;
8563
+ return [renderRow(headers), separator, ...rows.map(renderRow)];
8564
+ }
8565
+ function formatSequenceVariants(data) {
8566
+ const styles = ansi(output.isTTY === true && !process.env.NO_COLOR);
8567
+ const byVariant = Array.isArray(data.by_variant) ? data.by_variant : [];
8568
+ const byMailbox = Array.isArray(data.by_mailbox) ? data.by_mailbox : [];
8569
+ const lines = ["", styles.bold("By variant")];
8570
+ if (byVariant.length === 0) {
8571
+ lines.push(` ${styles.dim("No variant analytics yet.")}`);
8572
+ }
8573
+ else {
8574
+ const headers = ["STEP", "VARIANT", "SENT", "REPLIED", "REPLY RATE", "BOUNCED", "CREDITS"];
8575
+ const rows = byVariant.map((row) => [
8576
+ formatVariantCell(row.step_index),
8577
+ formatVariantCell(row.variant_id),
8578
+ formatVariantCell(row.sent),
8579
+ formatVariantCell(row.replied),
8580
+ formatRatePercent(row.reply_rate),
8581
+ formatVariantCell(row.bounced),
8582
+ formatVariantCell(row.credits_used),
8583
+ ]);
8584
+ lines.push(...renderVariantTable(headers, rows));
8585
+ }
8586
+ lines.push("", styles.bold("By mailbox"));
8587
+ if (byMailbox.length === 0) {
8588
+ lines.push(` ${styles.dim("No mailbox analytics yet.")}`);
8589
+ }
8590
+ else {
8591
+ const headers = ["MAILBOX", "SENT", "REPLIED", "REPLY RATE", "FAILED"];
8592
+ const rows = byMailbox.map((row) => [
8593
+ formatVariantCell(row.email_address),
8594
+ formatVariantCell(row.sent),
8595
+ formatVariantCell(row.replied),
8596
+ formatRatePercent(row.reply_rate),
8597
+ formatVariantCell(row.failed),
8598
+ ]);
8599
+ lines.push(...renderVariantTable(headers, rows));
8600
+ }
8601
+ lines.push("");
8602
+ return lines.join("\n");
8603
+ }
8111
8604
  function formatWhoami(identity, context) {
8112
8605
  const styles = ansi(output.isTTY === true && !process.env.NO_COLOR);
8113
8606
  const email = identity.user.email ?? identity.user.id;
@@ -8615,6 +9108,37 @@ function readNonNegativeInt(value) {
8615
9108
  }
8616
9109
  return parsed;
8617
9110
  }
9111
+ function readPositiveInteger(value) {
9112
+ const trimmed = value?.trim();
9113
+ if (!trimmed)
9114
+ return undefined;
9115
+ const parsed = Number(trimmed);
9116
+ if (!Number.isInteger(parsed) || parsed <= 0) {
9117
+ throw new OxygenError("invalid_number", "Expected a positive integer.", {
9118
+ details: { value },
9119
+ exitCode: 1,
9120
+ });
9121
+ }
9122
+ return parsed;
9123
+ }
9124
+ // Folds the sequence-level email send controls (--max-emails-per-mailbox-per-day,
9125
+ // --send-window-file) into a partial `settings` object. Returns undefined when
9126
+ // neither flag is set so the field is omitted from the request body entirely.
9127
+ // The server shallow-merges this fragment over the sequence's current settings
9128
+ // (apps/web .../sequences/[sequenceId]/route.ts), so a throttle-only PATCH
9129
+ // preserves other settings the server already holds, e.g. dispatch_mode/schedule.
9130
+ function readSequenceSettings(options) {
9131
+ const settings = {};
9132
+ const maxPerMailbox = readPositiveInteger(options.maxEmailsPerMailboxPerDay);
9133
+ if (maxPerMailbox !== undefined) {
9134
+ settings.max_emails_per_mailbox_per_day = maxPerMailbox;
9135
+ }
9136
+ const windowPath = readOption(options.sendWindowFile);
9137
+ if (windowPath) {
9138
+ settings.email_send_window = readJsonFileValue(resolve(windowPath), "--send-window-file");
9139
+ }
9140
+ return Object.keys(settings).length > 0 ? settings : undefined;
9141
+ }
8618
9142
  function muteTokenEcho() {
8619
9143
  if (!input.isTTY || process.platform === "win32")
8620
9144
  return () => undefined;