@oxygen-agent/cli 1.310.2 → 1.334.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.
Files changed (30) hide show
  1. package/README.md +1 -1
  2. package/dist/command-manifest.js +26 -10
  3. package/dist/help.js +7 -0
  4. package/dist/index.d.ts +1 -0
  5. package/dist/index.js +2077 -134
  6. package/dist/runtime.js +13 -3
  7. package/dist/skills.js +192 -32
  8. package/node_modules/@oxygen/recipe-sdk/dist/index.d.ts +2 -0
  9. package/node_modules/@oxygen/shared/dist/billing.d.ts +52 -29
  10. package/node_modules/@oxygen/shared/dist/billing.js +77 -55
  11. package/node_modules/@oxygen/shared/dist/index.d.ts +4 -0
  12. package/node_modules/@oxygen/shared/dist/index.js +4 -0
  13. package/node_modules/@oxygen/shared/dist/pricing-sheet.d.ts +84 -0
  14. package/node_modules/@oxygen/shared/dist/pricing-sheet.js +82 -0
  15. package/node_modules/@oxygen/shared/dist/recipes.d.ts +19 -0
  16. package/node_modules/@oxygen/shared/dist/recipes.js +90 -0
  17. package/node_modules/@oxygen/shared/dist/sequences.d.ts +61 -15
  18. package/node_modules/@oxygen/shared/dist/sequences.js +134 -23
  19. package/node_modules/@oxygen/shared/dist/sql-error.d.ts +25 -0
  20. package/node_modules/@oxygen/shared/dist/sql-error.js +46 -0
  21. package/node_modules/@oxygen/shared/dist/version.d.ts +2 -2
  22. package/node_modules/@oxygen/shared/dist/version.js +13 -11
  23. package/node_modules/@oxygen/shared/dist/workflow-mcp-tools.d.ts +4 -0
  24. package/node_modules/@oxygen/shared/dist/workflow-mcp-tools.js +18 -0
  25. package/node_modules/@oxygen/shared/dist/workflow-status-change.d.ts +61 -0
  26. package/node_modules/@oxygen/shared/dist/workflow-status-change.js +124 -0
  27. package/node_modules/@oxygen/shared/dist/workspace-agents.d.ts +65 -0
  28. package/node_modules/@oxygen/shared/dist/workspace-agents.js +67 -0
  29. package/node_modules/@oxygen/workflows/dist/index.js +86 -2
  30. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
9
9
  import { Command, Option } from "commander";
10
10
  import { applyOxygenHelp } from "./help.js";
11
11
  import { buildCommandManifest } from "./command-manifest.js";
12
- import { AGENCY_DIRECTORY_REGIONS, AGENCY_DIRECTORY_SERVICES, formatCellForDisplay, formatPublicBudgetScopes, exitCodeForOxygenError, isVersionGreater, isVersionLess, OXYGEN_VERSION, OxygenError, parseKnowledgePageMarkdown, sleep, success, toFailure, } from "@oxygen/shared";
12
+ import { AGENCY_DIRECTORY_REGIONS, AGENCY_DIRECTORY_SERVICES, describeWorkflowStatusChange, formatCellForDisplay, formatPublicBudgetScopes, exitCodeForOxygenError, parseWorkflowStatusChange, isVersionGreater, isVersionLess, MAX_MCP_TOOL_NAME_LENGTH, OXYGEN_VERSION, OxygenError, parseKnowledgePageMarkdown, sleep, success, toFailure, workflowMcpToolName, } 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";
@@ -160,6 +160,62 @@ function writeCreditsReceipt(data) {
160
160
  process.stderr.write(`estimated ${block.estimated_credits.toLocaleString("en-US")} credits for a live run${remaining !== null ? `, ${remaining} available` : ""}\n`);
161
161
  }
162
162
  }
163
+ // A disabled workflow is a customer's automation at zero, and `status:
164
+ // "disabled"` alone never said who did that or why (OXY-4124). Mirror the
165
+ // recorded transition as one stderr line per disabled workflow, so `workflows
166
+ // list` / `workflows get` answer "who turned this off?" in the terminal without
167
+ // polluting the machine-read stdout envelope (which carries `statusChange` in
168
+ // full). Sourced from the same shared sentence the web banner renders.
169
+ export function formatDisabledWorkflowNotices(data) {
170
+ if (!data || typeof data !== "object" || Array.isArray(data))
171
+ return [];
172
+ const block = data;
173
+ const candidates = Array.isArray(block.workflows)
174
+ ? block.workflows
175
+ : [block.workflow];
176
+ const notices = [];
177
+ for (const candidate of candidates) {
178
+ if (!candidate || typeof candidate !== "object" || Array.isArray(candidate))
179
+ continue;
180
+ const workflow = candidate;
181
+ if (workflow.status !== "disabled")
182
+ continue;
183
+ const slug = typeof workflow.slug === "string" ? workflow.slug : "workflow";
184
+ const change = parseWorkflowStatusChange(workflow.statusChange);
185
+ // A workflow disabled before the record shipped has no actor to name. Say
186
+ // that plainly rather than inventing one.
187
+ notices.push(change
188
+ ? `${slug}: ${describeWorkflowStatusChange(change)}`
189
+ : `${slug}: disabled — no actor was recorded (disabled before Oxygen tracked this).`);
190
+ }
191
+ return notices;
192
+ }
193
+ function writeDisabledWorkflowNotices(data) {
194
+ for (const notice of formatDisabledWorkflowNotices(data)) {
195
+ process.stderr.write(`${notice}\n`);
196
+ }
197
+ }
198
+ // The observability console caps each source at --limit (default 25, max 100) and
199
+ // reports which hit the cap in sources[].capped. The human (non --json) output is
200
+ // a JSON dump that buries that boolean, so surface it as a one-line stderr notice:
201
+ // the counts shown are only the returned page, and more may sit behind a capped
202
+ // source. Not pagination — raising --limit or filtering --source is the fix.
203
+ function writeObservabilityCapsNotice(data) {
204
+ if (!data || typeof data !== "object" || Array.isArray(data))
205
+ return;
206
+ const record = data;
207
+ const sources = Array.isArray(record.sources) ? record.sources : [];
208
+ const cappedSources = sources
209
+ .filter((source) => Boolean(source) && typeof source === "object" && !Array.isArray(source)
210
+ && source.capped === true)
211
+ .map((source) => String(source.source));
212
+ if (cappedSources.length === 0)
213
+ return;
214
+ const returned = typeof record.returned === "number"
215
+ ? record.returned
216
+ : Array.isArray(record.items) ? record.items.length : 0;
217
+ process.stderr.write(`note: showing ${returned} item(s); some sources hit the per-source cap (${cappedSources.join(", ")}) — raise --limit (max 100) or filter --source to see more.\n`);
218
+ }
163
219
  // Arming a cron commits recurring spend, so `workflows enable` mirrors its
164
220
  // `automation` block as stderr lines: what the schedule burns per day, what
165
221
  // share of the monthly allowance that is, and when it runs out. Observed burn
@@ -534,7 +590,6 @@ function buildCrmObjectCreateBody(options) {
534
590
  ...(readOption(options.singularName) ? { singular_name: readOption(options.singularName) } : {}),
535
591
  ...(readOption(options.pluralName) ? { plural_name: readOption(options.pluralName) } : {}),
536
592
  ...(readOption(options.labelColumn) ? { label_column: readOption(options.labelColumn) } : {}),
537
- ...(options.relationshipsJson ? { relationships: parseJsonArray(options.relationshipsJson) } : {}),
538
593
  ...(readOption(options.project) ? { project: readOption(options.project) } : {}),
539
594
  };
540
595
  }
@@ -687,12 +742,19 @@ function buildNotetakerScheduleBody(meetingUrl, options) {
687
742
  }
688
743
  function buildPublishingPostsListPath(options) {
689
744
  const query = new URLSearchParams();
690
- const status = readOption(options.status);
691
- const limit = readOption(options.limit);
692
- if (status)
693
- query.set("status", status);
694
- if (limit)
695
- query.set("limit", limit);
745
+ const filters = {
746
+ status: options.status,
747
+ approval_status: options.approvalStatus,
748
+ label: options.label,
749
+ campaign_id: options.campaign,
750
+ provider: options.provider,
751
+ limit: options.limit,
752
+ };
753
+ for (const [key, value] of Object.entries(filters)) {
754
+ const read = readOption(value);
755
+ if (read)
756
+ query.set(key, read);
757
+ }
696
758
  const suffix = query.toString();
697
759
  return suffix ? `/api/cli/publishing/posts?${suffix}` : "/api/cli/publishing/posts";
698
760
  }
@@ -878,6 +940,88 @@ function buildPublishingPostsDraftBody(options) {
878
940
  body.source_url = sourceUrl;
879
941
  return body;
880
942
  }
943
+ function buildSequenceDraftBody(options) {
944
+ const goal = readOption(options.goal);
945
+ if (!goal)
946
+ throw new Error("--goal is required.");
947
+ const name = readOption(options.name);
948
+ const slug = readOption(options.slug);
949
+ if (options.create && (!name || !slug)) {
950
+ throw new Error("--create requires --name and --slug.");
951
+ }
952
+ const audience = readOption(options.audience);
953
+ const table = readOption(options.table);
954
+ const steps = readPositiveInt(options.steps);
955
+ const variants = readPositiveInt(options.variants);
956
+ return {
957
+ goal,
958
+ max_credits: readPositiveNumber(options.maxCredits),
959
+ ...(audience ? { audience } : {}),
960
+ ...(table ? { source_table: table } : {}),
961
+ ...(steps !== undefined ? { steps } : {}),
962
+ ...(variants !== undefined ? { variants } : {}),
963
+ ...(options.create ? { create: true } : {}),
964
+ ...(name ? { name } : {}),
965
+ ...(slug ? { slug } : {}),
966
+ };
967
+ }
968
+ // Human-legible preview for `sequences draft` (stdout stays the machine JSON, like
969
+ // every other command): the drafted emails with their subjects + wait gaps, the
970
+ // Knowledge Graph pages that grounded the copy, and the next step. Written to
971
+ // stderr (the same lane as the credits receipt) so scripts still parse stdout.
972
+ function writeSequenceDraftPreview(data) {
973
+ if (!data || typeof data !== "object" || Array.isArray(data))
974
+ return;
975
+ const record = data;
976
+ const definition = record.definition;
977
+ const steps = Array.isArray(record.steps)
978
+ ? record.steps
979
+ : Array.isArray(definition?.steps)
980
+ ? definition.steps
981
+ : [];
982
+ const sequence = record.sequence;
983
+ const lines = [];
984
+ if (record.created && sequence) {
985
+ lines.push(`Saved draft sequence "${String(sequence.name ?? "")}" (${String(sequence.slug ?? "")}).`);
986
+ }
987
+ else {
988
+ lines.push("Drafted email sequence (preview — not saved):");
989
+ }
990
+ let emailNumber = 0;
991
+ for (const step of steps) {
992
+ if (!step || typeof step !== "object" || Array.isArray(step))
993
+ continue;
994
+ const entry = step;
995
+ if (entry.kind === "email_send") {
996
+ emailNumber += 1;
997
+ const subject = typeof entry.subject_template === "string" && entry.subject_template.trim()
998
+ ? entry.subject_template.trim()
999
+ : "(no subject)";
1000
+ const variantCount = Array.isArray(entry.variants) ? entry.variants.length : 0;
1001
+ lines.push(` Email ${emailNumber}: ${subject}${variantCount > 0 ? ` (+${variantCount} A/B variant${variantCount === 1 ? "" : "s"})` : ""}`);
1002
+ }
1003
+ else if (entry.kind === "wait") {
1004
+ const days = typeof entry.days === "number" ? entry.days : 0;
1005
+ lines.push(` wait ${days} day${days === 1 ? "" : "s"}`);
1006
+ }
1007
+ }
1008
+ const provenance = record.provenance;
1009
+ const pages = Array.isArray(provenance?.pages) ? provenance.pages : [];
1010
+ if (pages.length > 0) {
1011
+ const names = pages
1012
+ .map((page) => (typeof page.title === "string" && page.title.trim() ? page.title.trim() : typeof page.slug === "string" ? page.slug : ""))
1013
+ .filter((name) => name.length > 0)
1014
+ .slice(0, 5);
1015
+ lines.push(` Grounded on ${pages.length} Knowledge Graph page${pages.length === 1 ? "" : "s"}${names.length > 0 ? `: ${names.join(", ")}` : ""}.`);
1016
+ }
1017
+ else {
1018
+ lines.push(" Grounded on the workspace voice (no Knowledge Graph pages retrieved).");
1019
+ }
1020
+ if (!record.created) {
1021
+ lines.push(" Re-run with --create --name <name> --slug <slug> to save it as a draft sequence.");
1022
+ }
1023
+ process.stderr.write(`${lines.join("\n")}\n`);
1024
+ }
881
1025
  function buildPublishingDraftsListPath(options) {
882
1026
  const query = new URLSearchParams();
883
1027
  const kind = readOption(options.kind);
@@ -913,6 +1057,211 @@ function buildPublishingDraftsAcceptBody(options) {
913
1057
  body.timezone = timezone;
914
1058
  return body;
915
1059
  }
1060
+ function buildPublishingCampaignBody(options, requireName) {
1061
+ const name = readOption(options.name);
1062
+ if (requireName && !name) {
1063
+ throw new OxygenError("invalid_request", "Pass --name.", { exitCode: 1 });
1064
+ }
1065
+ const goal = readOption(options.goal);
1066
+ const startsAt = readOption(options.startsAt);
1067
+ const endsAt = readOption(options.endsAt);
1068
+ return {
1069
+ ...(name ? { name } : {}),
1070
+ ...(goal ? { goal } : {}),
1071
+ ...(startsAt ? { starts_at: startsAt } : {}),
1072
+ ...(endsAt ? { ends_at: endsAt } : {}),
1073
+ };
1074
+ }
1075
+ function buildPublishingLabelsBody(options) {
1076
+ const add = splitCommaList(options.add);
1077
+ const remove = splitCommaList(options.remove);
1078
+ if (add.length === 0 && remove.length === 0) {
1079
+ throw new OxygenError("invalid_request", "Pass --add and/or --remove.", { exitCode: 1 });
1080
+ }
1081
+ return {
1082
+ ...(add.length > 0 ? { add } : {}),
1083
+ ...(remove.length > 0 ? { remove } : {}),
1084
+ };
1085
+ }
1086
+ function splitCommaList(value) {
1087
+ const raw = readOption(value);
1088
+ if (!raw)
1089
+ return [];
1090
+ return raw.split(",").map((entry) => entry.trim()).filter(Boolean);
1091
+ }
1092
+ function buildPublishingAnalyticsPath(base, params) {
1093
+ const query = new URLSearchParams();
1094
+ for (const [key, value] of Object.entries(params)) {
1095
+ if (value)
1096
+ query.set(key, value);
1097
+ }
1098
+ const suffix = query.toString();
1099
+ return suffix ? `${base}?${suffix}` : base;
1100
+ }
1101
+ // `--file` is the whole import: a .csv is posted verbatim as `csv` (the API owns
1102
+ // the parse, so the CLI and the web importer can never disagree about a quoted
1103
+ // comma); a .json is posted as `rows`. Dry-run is the DEFAULT — a bare
1104
+ // `publishing import --file posts.csv` lints and reports, and only --approved
1105
+ // flips dry_run off, so a bulk write into the queue is never one typo away.
1106
+ function buildPublishingImportBody(options) {
1107
+ const file = readOption(options.file);
1108
+ if (!file)
1109
+ throw new OxygenError("invalid_request", "Pass --file <path.csv|.json>.", { exitCode: 1 });
1110
+ if (options.dryRun === true && options.approved === true) {
1111
+ throw new OxygenError("conflicting_flags", "Pass either --dry-run or --approved, not both.", { exitCode: 1 });
1112
+ }
1113
+ const contents = readPublishingTextFile(file, "--file");
1114
+ const campaign = readOption(options.campaign);
1115
+ const labels = splitCommaList(options.label);
1116
+ const isJson = file.trim().toLowerCase().endsWith(".json");
1117
+ return {
1118
+ ...(isJson ? { rows: readPublishingImportRows(contents) } : { csv: contents }),
1119
+ dry_run: options.approved !== true,
1120
+ ...(campaign ? { campaign_id: campaign } : {}),
1121
+ ...(labels.length > 0 ? { labels } : {}),
1122
+ };
1123
+ }
1124
+ function readPublishingImportRows(contents) {
1125
+ let parsed;
1126
+ try {
1127
+ parsed = JSON.parse(contents);
1128
+ }
1129
+ catch {
1130
+ throw new OxygenError("invalid_json", "--file is not valid JSON.", { exitCode: 1 });
1131
+ }
1132
+ if (Array.isArray(parsed))
1133
+ return parsed;
1134
+ const rows = parsed?.rows;
1135
+ if (Array.isArray(rows))
1136
+ return rows;
1137
+ throw new OxygenError("invalid_request", "Expected a JSON array of rows, or an object with a `rows` array.", { exitCode: 1 });
1138
+ }
1139
+ // Amplification create and post boost both arm REAL public writes from other
1140
+ // people's connected accounts and both spend credits, so the CLI refuses (exit 7)
1141
+ // before the request rather than relying on the API to bounce it. Same gate the
1142
+ // LinkedIn `posts delete` path uses: an irreversible public action never happens
1143
+ // because a flag was forgotten.
1144
+ function requirePublishingApprovalAndCap(approved, maxCredits, action, details) {
1145
+ if (approved !== true) {
1146
+ throw new OxygenError("approval_required", `${action} makes real public engagement from the participants' connected accounts and spends credits. Re-run with --approved --max-credits <n>.`, { details, exitCode: 7 });
1147
+ }
1148
+ const cap = readPositiveNumber(maxCredits);
1149
+ if (cap === undefined) {
1150
+ throw new OxygenError("max_credits_required", `${action} spends credits, so it needs an explicit ceiling. Re-run with --max-credits <n>.`, { details, exitCode: 7 });
1151
+ }
1152
+ return cap;
1153
+ }
1154
+ function buildPublishingAmplificationCreateBody(options) {
1155
+ const maxCredits = requirePublishingApprovalAndCap(options.approved, options.maxCredits, "Creating an amplification policy", { name: options.name, scope: options.scope });
1156
+ const senders = splitCommaList(options.senders);
1157
+ const actions = splitCommaList(options.actions);
1158
+ if (senders.length === 0)
1159
+ throw new OxygenError("invalid_request", "Pass --senders.", { exitCode: 1 });
1160
+ if (actions.length === 0)
1161
+ throw new OxygenError("invalid_request", "Pass --actions.", { exitCode: 1 });
1162
+ return {
1163
+ name: options.name,
1164
+ scope_kind: options.scope,
1165
+ participant_sender_ids: senders,
1166
+ actions,
1167
+ max_credits_per_cycle: maxCredits,
1168
+ approved: true,
1169
+ ...publishingAmplificationScope(options),
1170
+ ...publishingAmplificationTunables(options),
1171
+ };
1172
+ }
1173
+ function publishingAmplificationScope(options) {
1174
+ const campaign = readOption(options.campaign);
1175
+ const label = readOption(options.label);
1176
+ const post = readOption(options.post);
1177
+ return {
1178
+ ...(campaign ? { scope_campaign_id: campaign } : {}),
1179
+ ...(label ? { scope_label: label } : {}),
1180
+ ...(post ? { scope_post_id: post } : {}),
1181
+ };
1182
+ }
1183
+ function publishingAmplificationTunables(options) {
1184
+ const maxActions = readPositiveInt(options.maxActionsPerPost);
1185
+ const commentPool = readPublishingCommentPool(options);
1186
+ const reactionMin = readNonNegativeInt(options.reactionDelayMin);
1187
+ const reactionMax = readNonNegativeInt(options.reactionDelayMax);
1188
+ const commentMin = readNonNegativeInt(options.commentDelayMin);
1189
+ const commentMax = readNonNegativeInt(options.commentDelayMax);
1190
+ return {
1191
+ ...(maxActions !== undefined ? { max_actions_per_post: maxActions } : {}),
1192
+ ...(commentPool ? { comment_source: "pool", comment_pool: commentPool } : {}),
1193
+ ...(reactionMin !== undefined ? { reaction_delay_min_seconds: reactionMin } : {}),
1194
+ ...(reactionMax !== undefined ? { reaction_delay_max_seconds: reactionMax } : {}),
1195
+ ...(commentMin !== undefined ? { comment_delay_min_seconds: commentMin } : {}),
1196
+ ...(commentMax !== undefined ? { comment_delay_max_seconds: commentMax } : {}),
1197
+ };
1198
+ }
1199
+ // Comments are prose: a comma-separated flag would split them mid-sentence. The
1200
+ // pool is a repeatable --comment-pool flag, or a file of one comment per line.
1201
+ function readPublishingCommentPool(options) {
1202
+ const file = readOption(options.commentPoolFile);
1203
+ const inline = Array.isArray(options.commentPool)
1204
+ ? options.commentPool.map((entry) => entry.trim()).filter(Boolean)
1205
+ : [];
1206
+ if (file && inline.length > 0) {
1207
+ throw new OxygenError("conflicting_flags", "Pass either --comment-pool or --comment-pool-file, not both.", {
1208
+ exitCode: 1,
1209
+ });
1210
+ }
1211
+ if (file) {
1212
+ const lines = readPublishingTextFile(file, "--comment-pool-file")
1213
+ .split("\n")
1214
+ .map((line) => line.trim())
1215
+ .filter(Boolean);
1216
+ if (lines.length === 0) {
1217
+ throw new OxygenError("invalid_request", "--comment-pool-file has no comments.", { exitCode: 1 });
1218
+ }
1219
+ return lines;
1220
+ }
1221
+ return inline.length > 0 ? inline : null;
1222
+ }
1223
+ function buildPublishingAmplificationUpdateBody(options) {
1224
+ const name = readOption(options.name);
1225
+ const senders = splitCommaList(options.senders);
1226
+ const actions = splitCommaList(options.actions);
1227
+ const maxCredits = readPositiveNumber(options.maxCredits);
1228
+ return {
1229
+ ...(name ? { name } : {}),
1230
+ ...(senders.length > 0 ? { participant_sender_ids: senders } : {}),
1231
+ ...(actions.length > 0 ? { actions } : {}),
1232
+ ...(maxCredits !== undefined ? { max_credits_per_cycle: maxCredits } : {}),
1233
+ ...publishingAmplificationTunables(options),
1234
+ };
1235
+ }
1236
+ function buildPublishingBoostBody(postId, options) {
1237
+ const maxCredits = requirePublishingApprovalAndCap(options.approved, options.maxCredits, "Boosting a post", { post_id: postId });
1238
+ const senders = splitCommaList(options.senders);
1239
+ const actions = splitCommaList(options.actions);
1240
+ if (senders.length === 0)
1241
+ throw new OxygenError("invalid_request", "Pass --senders.", { exitCode: 1 });
1242
+ if (actions.length === 0)
1243
+ throw new OxygenError("invalid_request", "Pass --actions.", { exitCode: 1 });
1244
+ const maxActions = readPositiveInt(options.maxActionsPerPost);
1245
+ const commentPool = readPublishingCommentPool(options);
1246
+ return {
1247
+ senders,
1248
+ actions,
1249
+ max_credits: maxCredits,
1250
+ approved: true,
1251
+ ...(maxActions !== undefined ? { max_actions_per_post: maxActions } : {}),
1252
+ ...(commentPool ? { comment_pool: commentPool } : {}),
1253
+ };
1254
+ }
1255
+ function buildPublishingIdeaBody(options) {
1256
+ const topic = readOption(options.topic);
1257
+ const channels = splitCommaList(options.channels);
1258
+ return {
1259
+ kind: "idea",
1260
+ text: readPublishingPostText(options, true),
1261
+ ...(topic ? { topic } : {}),
1262
+ ...(channels.length > 0 ? { channels } : {}),
1263
+ };
1264
+ }
916
1265
  function buildCrmRelationshipUpsertBody(object, rowId, options) {
917
1266
  return {
918
1267
  object,
@@ -1136,6 +1485,20 @@ function isUuid(value) {
1136
1485
  // surfaces are generated here to stop them drifting (the alias previously
1137
1486
  // duplicated every subcommand). All subcommands hit the shared
1138
1487
  // /api/cli/templates/* routes.
1488
+ /**
1489
+ * Accept the domain as EITHER a positional argument or --domain.
1490
+ *
1491
+ * `subscribe` required --domain while get/warmup/cancel took a positional, so the same value had
1492
+ * two spellings depending on the verb, and `get --domain acme.com` failed outright with "unknown
1493
+ * option". Both spellings now work on every verb; nothing that used to work stops working.
1494
+ */
1495
+ function requireDomainArg(positional, option) {
1496
+ const domain = readOption(positional) ?? readOption(option);
1497
+ if (!domain) {
1498
+ throw new Error("A domain is required \u2014 pass it as an argument or with --domain.");
1499
+ }
1500
+ return domain;
1501
+ }
1139
1502
  function buildPromptTemplatesCommand(surface, description) {
1140
1503
  return new Command(surface)
1141
1504
  .description(description)
@@ -1480,7 +1843,7 @@ export function createProgram() {
1480
1843
  }));
1481
1844
  program
1482
1845
  .command("support")
1483
- .description("File and track Oxygen support tickets.")
1846
+ .description("File and track Oxygen support tickets. Staff event automation: this CLI's `support admin events` command.")
1484
1847
  .addCommand(new Command("file")
1485
1848
  .description("File a support ticket. Use when you're stuck on an Oxygen operation.")
1486
1849
  .requiredOption("--subject <subject>", "One-line summary of the problem.")
@@ -1574,6 +1937,29 @@ export function createProgram() {
1574
1937
  .option("--json", "Print a JSON envelope.")
1575
1938
  .action(async (options) => {
1576
1939
  await handleAsyncAction("support admin list", options, () => requestOxygen(withSupportListQuery("/api/cli/admin/support/tickets", options)));
1940
+ }))
1941
+ .addCommand(new Command("events")
1942
+ .description("Read-only, zero-credit poll of body-free support event envelopes (staff only). Watermark polls replay 24h; dedupe by (ticket_ref, version).")
1943
+ .option("--after <cursor>", "If has_more=true, pass next_cursor to continue that scan. If false, persist the non-null watermark_cursor for later polls. A watermark poll intentionally replays the settled 24h window, not only unseen events; dedupe every page by (ticket_ref, version).")
1944
+ .option("--limit <n>", "Events to return (1-100, defaults to 50).")
1945
+ .option("--json", "Print a JSON envelope.")
1946
+ .addHelpText("after", `
1947
+ Event schema (each data.events item has exactly these fields):
1948
+ event_kind: ticket_snapshot
1949
+ ticket_ref: UUID; version: decimal string; event_time: UTC timestamp
1950
+ status: open | triaging | waiting_on_user | resolved | closed | null
1951
+ severity: low | normal | high | null
1952
+ category: account | auth | billing | bug | data | deliverability |
1953
+ feature_request | feedback | integration | other | performance |
1954
+ provider | security | sequencer | tables | workflow | null
1955
+ source: slack | null; route: slack | cli | mcp | web | null
1956
+
1957
+ The cursor is opaque. has_more discriminates its phase: next_cursor continues a
1958
+ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retain
1959
+ (ticket_ref, version) as the idempotency key across every poll.
1960
+ `)
1961
+ .action(async (options) => {
1962
+ await handleAsyncAction("support admin events", options, () => requestOxygen(withSupportEventsQuery("/api/cli/admin/support/events", options)));
1577
1963
  }))
1578
1964
  .addCommand(new Command("get")
1579
1965
  .description("Show one support ticket with its message thread (staff only).")
@@ -1863,6 +2249,10 @@ export function createProgram() {
1863
2249
  .addCommand(new Command("list")
1864
2250
  .description("List scheduled posts.")
1865
2251
  .option("--status <status>", "Filter by draft, scheduled, queued, publishing, published, failed, or canceled.")
2252
+ .option("--approval-status <status>", "Filter by draft, needs_approval, approved, or rejected.")
2253
+ .option("--label <label>", "Only posts carrying this label.")
2254
+ .option("--campaign <campaign_id>", "Only posts in this campaign.")
2255
+ .option("--provider <provider>", "Filter by provider: linkedin, x, instagram, tiktok, facebook, or youtube.")
1866
2256
  .option("--limit <n>", "Maximum posts to return.")
1867
2257
  .option("--json", "Print a JSON envelope.")
1868
2258
  .action(async (options) => {
@@ -1984,6 +2374,78 @@ export function createProgram() {
1984
2374
  method: "POST",
1985
2375
  body: { max_credits: readPositiveNumber(options.maxCredits) },
1986
2376
  }));
2377
+ }))
2378
+ .addCommand(new Command("boost")
2379
+ .description("Boost ONE post: your teammates' connected accounts react to and comment on it, once. These are REAL public LinkedIn writes from their accounts and they SPEND CREDITS — refused (exit 7) without --approved and --max-credits. Creates a post-scoped policy that is already enabled and plans its actions immediately. For a standing rule across many posts, use `publishing amplification create`.")
2380
+ .argument("<post_id>", "Scheduled post id.")
2381
+ .requiredOption("--senders <ids>", "Comma-separated connected sender account ids that will engage.")
2382
+ .requiredOption("--actions <list>", "Comma-separated actions: reaction, comment.")
2383
+ .option("--max-actions-per-post <n>", "Hard cap on how many actions are planned.")
2384
+ .option("--max-credits <n>", "Credit ceiling for the boost (required — this is a paid action).")
2385
+ .option("--comment-pool <text>", "A comment to draw from. Repeatable. Required when --actions includes comment.", collectRepeatable, [])
2386
+ .option("--comment-pool-file <path>", "Read the comment pool from a file, one comment per line.")
2387
+ .option("--approved", "Confirm the real public engagement. Without it the command refuses and nothing is planned.")
2388
+ .option("--json", "Print a JSON envelope.")
2389
+ .action(async (postId, options) => {
2390
+ await handleAsyncAction("publishing posts boost", options, () => requestOxygen(`/api/cli/publishing/posts/${encodeURIComponent(postId)}/boost`, {
2391
+ method: "POST",
2392
+ body: buildPublishingBoostBody(postId, options),
2393
+ }));
2394
+ }))
2395
+ .addCommand(new Command("revisions")
2396
+ .description("List a post's content history: every revision with its number, title, text, author, and time.")
2397
+ .argument("<post_id>", "Scheduled post id.")
2398
+ .option("--json", "Print a JSON envelope.")
2399
+ .action(async (postId, options) => {
2400
+ await handleAsyncAction("publishing posts revisions", options, () => requestOxygen(`/api/cli/publishing/posts/${encodeURIComponent(postId)}/revisions`));
2401
+ }))
2402
+ .addCommand(new Command("restore")
2403
+ .description("Roll a post back to an earlier revision (see `publishing posts revisions`). The current text is snapshotted as a new revision first, so a restore is itself undoable. Refused once the post is publishing, published, or canceled.")
2404
+ .argument("<post_id>", "Scheduled post id.")
2405
+ .requiredOption("--revision <n>", "Revision number to restore.")
2406
+ .option("--json", "Print a JSON envelope.")
2407
+ .action(async (postId, options) => {
2408
+ await handleAsyncAction("publishing posts restore", options, () => {
2409
+ const revision = readPositiveInt(options.revision);
2410
+ if (revision === undefined) {
2411
+ throw new OxygenError("invalid_request", "Pass --revision <n>.", { exitCode: 1 });
2412
+ }
2413
+ return requestOxygen(`/api/cli/publishing/posts/${encodeURIComponent(postId)}/revisions/${revision}/restore`, { method: "POST" });
2414
+ });
2415
+ }))
2416
+ .addCommand(new Command("notes")
2417
+ .description("List the internal review notes on a post. Notes are workspace-only — they are never published.")
2418
+ .argument("<post_id>", "Scheduled post id.")
2419
+ .option("--json", "Print a JSON envelope.")
2420
+ .action(async (postId, options) => {
2421
+ await handleAsyncAction("publishing posts notes", options, () => requestOxygen(`/api/cli/publishing/posts/${encodeURIComponent(postId)}/comments`));
2422
+ }))
2423
+ .addCommand(new Command("note")
2424
+ .description("Leave an internal review note on a post, or reply to one with --parent. Never published.")
2425
+ .argument("<post_id>", "Scheduled post id.")
2426
+ .option("--text <text>", "The note.")
2427
+ .option("--text-file <path>", "Read the note from a local file.")
2428
+ .option("--parent <comment_id>", "Reply to this note.")
2429
+ .option("--json", "Print a JSON envelope.")
2430
+ .action(async (postId, options) => {
2431
+ await handleAsyncAction("publishing posts note", options, () => {
2432
+ const parent = readOption(options.parent);
2433
+ return requestOxygen(`/api/cli/publishing/posts/${encodeURIComponent(postId)}/comments`, {
2434
+ method: "POST",
2435
+ body: {
2436
+ body: readPublishingPostText(options, true),
2437
+ ...(parent ? { parent_comment_id: parent } : {}),
2438
+ },
2439
+ });
2440
+ });
2441
+ }))
2442
+ .addCommand(new Command("resolve-note")
2443
+ .description("Mark a review note handled.")
2444
+ .argument("<post_id>", "Scheduled post id.")
2445
+ .requiredOption("--comment <comment_id>", "Note id from `publishing posts notes`.")
2446
+ .option("--json", "Print a JSON envelope.")
2447
+ .action(async (postId, options) => {
2448
+ await handleAsyncAction("publishing posts resolve-note", options, () => requestOxygen(`/api/cli/publishing/posts/${encodeURIComponent(postId)}/comments/${encodeURIComponent(options.comment)}/resolve`, { method: "POST" }));
1987
2449
  })))
1988
2450
  .addCommand(new Command("drafts")
1989
2451
  .description("Review the AI post-draft queue before anything reaches the publish queue.")
@@ -2065,6 +2527,238 @@ export function createProgram() {
2065
2527
  method: "POST",
2066
2528
  body: buildPublishingMediaUploadedBody(options),
2067
2529
  }));
2530
+ })))
2531
+ .addCommand(new Command("campaigns")
2532
+ .description("Group scheduled posts under a launch or theme, then filter the queue (`publishing posts list --campaign`) or scope an amplification policy to it.")
2533
+ .addCommand(new Command("list")
2534
+ .description("List campaigns with their post counts.")
2535
+ .option("--json", "Print a JSON envelope.")
2536
+ .action(async (options) => {
2537
+ await handleAsyncAction("publishing campaigns list", options, () => requestOxygen("/api/cli/publishing/campaigns"));
2538
+ }))
2539
+ .addCommand(new Command("create")
2540
+ .description("Create a campaign.")
2541
+ .requiredOption("--name <name>", "Campaign name.")
2542
+ .option("--goal <goal>", "What this campaign is for.")
2543
+ .option("--starts-at <iso>", "ISO date-time the campaign starts.")
2544
+ .option("--ends-at <iso>", "ISO date-time the campaign ends.")
2545
+ .option("--json", "Print a JSON envelope.")
2546
+ .action(async (options) => {
2547
+ await handleAsyncAction("publishing campaigns create", options, () => requestOxygen("/api/cli/publishing/campaigns", {
2548
+ method: "POST",
2549
+ body: buildPublishingCampaignBody(options, true),
2550
+ }));
2551
+ }))
2552
+ .addCommand(new Command("get")
2553
+ .description("Get one campaign with its posts.")
2554
+ .argument("<campaign_id>", "Campaign id.")
2555
+ .option("--json", "Print a JSON envelope.")
2556
+ .action(async (campaignId, options) => {
2557
+ await handleAsyncAction("publishing campaigns get", options, () => requestOxygen(`/api/cli/publishing/campaigns/${encodeURIComponent(campaignId)}`));
2558
+ }))
2559
+ .addCommand(new Command("update")
2560
+ .description("Update a campaign's name, goal, or window.")
2561
+ .argument("<campaign_id>", "Campaign id.")
2562
+ .option("--name <name>", "Campaign name.")
2563
+ .option("--goal <goal>", "What this campaign is for.")
2564
+ .option("--starts-at <iso>", "ISO date-time the campaign starts.")
2565
+ .option("--ends-at <iso>", "ISO date-time the campaign ends.")
2566
+ .option("--json", "Print a JSON envelope.")
2567
+ .action(async (campaignId, options) => {
2568
+ await handleAsyncAction("publishing campaigns update", options, () => requestOxygen(`/api/cli/publishing/campaigns/${encodeURIComponent(campaignId)}`, {
2569
+ method: "PATCH",
2570
+ body: buildPublishingCampaignBody(options, false),
2571
+ }));
2572
+ }))
2573
+ .addCommand(new Command("delete")
2574
+ .description("Delete a campaign. Its posts survive — they are just unlinked from it.")
2575
+ .argument("<campaign_id>", "Campaign id.")
2576
+ .option("--json", "Print a JSON envelope.")
2577
+ .action(async (campaignId, options) => {
2578
+ await handleAsyncAction("publishing campaigns delete", options, () => requestOxygen(`/api/cli/publishing/campaigns/${encodeURIComponent(campaignId)}`, {
2579
+ method: "DELETE",
2580
+ }));
2581
+ })))
2582
+ .addCommand(new Command("labels")
2583
+ .description("Tag scheduled posts. Labels drive queue filters and label-scoped amplification; they are never published.")
2584
+ .addCommand(new Command("set")
2585
+ .description("Add and/or remove labels on one post.")
2586
+ .argument("<post_id>", "Scheduled post id.")
2587
+ .option("--add <labels>", "Comma-separated labels to add.")
2588
+ .option("--remove <labels>", "Comma-separated labels to remove.")
2589
+ .option("--json", "Print a JSON envelope.")
2590
+ .action(async (postId, options) => {
2591
+ await handleAsyncAction("publishing labels set", options, () => requestOxygen(`/api/cli/publishing/posts/${encodeURIComponent(postId)}/labels`, {
2592
+ method: "POST",
2593
+ body: buildPublishingLabelsBody(options),
2594
+ }));
2595
+ })))
2596
+ .addCommand(new Command("analytics")
2597
+ .description("Engagement metrics the worker syncs back from the provider. Free reads. LinkedIn exposes reactions, comments, and reshares only — impressions, saves, and sends come back null, never zero.")
2598
+ .addCommand(new Command("post")
2599
+ .description("One post's daily metric series, totals, and earned-media value. EMV is impressions-derived, so on LinkedIn it returns null with an unavailable_reason instead of a made-up number.")
2600
+ .argument("<post_id>", "Scheduled post id.")
2601
+ .option("--since <date>", "ISO date to start the series from.")
2602
+ .option("--json", "Print a JSON envelope.")
2603
+ .action(async (postId, options) => {
2604
+ await handleAsyncAction("publishing analytics post", options, () => requestOxygen(buildPublishingAnalyticsPath(`/api/cli/publishing/analytics/posts/${encodeURIComponent(postId)}`, { since: readOption(options.since) })));
2605
+ }))
2606
+ .addCommand(new Command("account")
2607
+ .description("Per-account metric series for the connected publishing accounts.")
2608
+ .option("--provider <provider>", "Provider to read. Defaults to linkedin.")
2609
+ .option("--since <date>", "ISO date to start the series from.")
2610
+ .option("--json", "Print a JSON envelope.")
2611
+ .action(async (options) => {
2612
+ await handleAsyncAction("publishing analytics account", options, () => requestOxygen(buildPublishingAnalyticsPath("/api/cli/publishing/analytics/account", {
2613
+ provider: readOption(options.provider),
2614
+ since: readOption(options.since),
2615
+ })));
2616
+ }))
2617
+ .addCommand(new Command("emv")
2618
+ .description("Set the workspace CPM used for earned-media value, per channel. EMV stays null on any channel whose provider does not expose impressions.")
2619
+ .requiredOption("--cpm-json <json>", "Per-channel CPM object, e.g. '{\"linkedin\":30,\"x\":12}'.")
2620
+ .option("--json", "Print a JSON envelope.")
2621
+ .action(async (options) => {
2622
+ await handleAsyncAction("publishing analytics emv", options, () => requestOxygen("/api/cli/publishing/analytics/emv", {
2623
+ method: "POST",
2624
+ body: { cpm: parseJsonObject(options.cpmJson ?? "") },
2625
+ }));
2626
+ })))
2627
+ .addCommand(new Command("import")
2628
+ .description("Bulk-load scheduled posts from a .csv or .json file. Dry-run by DEFAULT: it parses, lints, and reports invalid rows while writing nothing. Pass --approved to actually write. Imported posts land as drafts that still need approval — import never approves and never publishes. A row whose idempotency_key was already used is skipped, so re-importing the same file cannot duplicate the queue.")
2629
+ .requiredOption("--file <path>", "Path to a .csv (header row) or .json file ([rows] or { rows: [...] }).")
2630
+ .option("--dry-run", "Parse, lint, and report without writing. Default.")
2631
+ .option("--approved", "Write the parsed rows into the queue as needs-approval drafts.")
2632
+ .option("--campaign <campaign_id>", "Campaign for every imported post.")
2633
+ .option("--label <labels>", "Comma-separated labels for every imported post.")
2634
+ .option("--json", "Print a JSON envelope.")
2635
+ .action(async (options) => {
2636
+ await handleAsyncAction("publishing import", options, () => requestOxygen("/api/cli/publishing/import", {
2637
+ method: "POST",
2638
+ body: buildPublishingImportBody(options),
2639
+ }));
2640
+ }))
2641
+ .addCommand(new Command("amplification")
2642
+ .description("Standing auto-engagement policies: the sender accounts you name react to and comment on every post in scope, paced and capped. Default OFF — a new policy is created disabled, and arming it is a separate, deliberate call.")
2643
+ .addCommand(new Command("list")
2644
+ .description("List amplification policies with their scope, caps, and enabled state.")
2645
+ .option("--json", "Print a JSON envelope.")
2646
+ .action(async (options) => {
2647
+ await handleAsyncAction("publishing amplification list", options, () => requestOxygen("/api/cli/publishing/amplification"));
2648
+ }))
2649
+ .addCommand(new Command("create")
2650
+ .description("Create an amplification policy. It arms REAL public engagement from other people's connected accounts and SPENDS CREDITS, so it is refused (exit 7) without --approved and --max-credits — that pair is the standing grant, recorded with who/when/what scope. The policy is created DISABLED: run `amplification enable` when you actually want it to run.")
2651
+ .requiredOption("--name <name>", "Policy name.")
2652
+ .requiredOption("--scope <kind>", "What it amplifies: all, campaign, label, or post.")
2653
+ .option("--campaign <campaign_id>", "Campaign to scope to (with --scope campaign).")
2654
+ .option("--label <label>", "Label to scope to (with --scope label).")
2655
+ .option("--post <post_id>", "Post to scope to (with --scope post).")
2656
+ .requiredOption("--senders <ids>", "Comma-separated connected sender account ids that will engage.")
2657
+ .requiredOption("--actions <list>", "Comma-separated actions: reaction, comment.")
2658
+ .option("--max-actions-per-post <n>", "Hard cap on actions per post.")
2659
+ .option("--max-credits <n>", "Hard credit cap per cycle (required — this policy spends credits).")
2660
+ .option("--comment-pool <text>", "A comment to draw from. Repeatable. Required when --actions includes comment.", collectRepeatable, [])
2661
+ .option("--comment-pool-file <path>", "Read the comment pool from a file, one comment per line.")
2662
+ .option("--reaction-delay-min <seconds>", "Lower bound of the randomized reaction delay.")
2663
+ .option("--reaction-delay-max <seconds>", "Upper bound of the randomized reaction delay.")
2664
+ .option("--comment-delay-min <seconds>", "Lower bound of the randomized comment delay.")
2665
+ .option("--comment-delay-max <seconds>", "Upper bound of the randomized comment delay.")
2666
+ .option("--approved", "Grant the standing permission. Without it the command refuses and creates nothing.")
2667
+ .option("--json", "Print a JSON envelope.")
2668
+ .action(async (options) => {
2669
+ await handleAsyncAction("publishing amplification create", options, () => requestOxygen("/api/cli/publishing/amplification", {
2670
+ method: "POST",
2671
+ body: buildPublishingAmplificationCreateBody(options),
2672
+ }));
2673
+ }))
2674
+ .addCommand(new Command("get")
2675
+ .description("Get one policy: scope, participants, caps, pacing, enabled state, and standing approval.")
2676
+ .argument("<policy_id>", "Amplification policy id.")
2677
+ .option("--json", "Print a JSON envelope.")
2678
+ .action(async (policyId, options) => {
2679
+ await handleAsyncAction("publishing amplification get", options, () => requestOxygen(`/api/cli/publishing/amplification/${encodeURIComponent(policyId)}`));
2680
+ }))
2681
+ .addCommand(new Command("update")
2682
+ .description("Update a policy's participants, actions, caps, or pacing. Tightening a cap takes effect on the next cycle.")
2683
+ .argument("<policy_id>", "Amplification policy id.")
2684
+ .option("--name <name>", "Policy name.")
2685
+ .option("--senders <ids>", "Comma-separated connected sender account ids.")
2686
+ .option("--actions <list>", "Comma-separated actions: reaction, comment.")
2687
+ .option("--max-actions-per-post <n>", "Hard cap on actions per post.")
2688
+ .option("--max-credits <n>", "Hard credit cap per cycle.")
2689
+ .option("--comment-pool <text>", "A comment to draw from. Repeatable.", collectRepeatable, [])
2690
+ .option("--comment-pool-file <path>", "Read the comment pool from a file, one comment per line.")
2691
+ .option("--reaction-delay-min <seconds>", "Lower bound of the randomized reaction delay.")
2692
+ .option("--reaction-delay-max <seconds>", "Upper bound of the randomized reaction delay.")
2693
+ .option("--comment-delay-min <seconds>", "Lower bound of the randomized comment delay.")
2694
+ .option("--comment-delay-max <seconds>", "Upper bound of the randomized comment delay.")
2695
+ .option("--json", "Print a JSON envelope.")
2696
+ .action(async (policyId, options) => {
2697
+ await handleAsyncAction("publishing amplification update", options, () => requestOxygen(`/api/cli/publishing/amplification/${encodeURIComponent(policyId)}`, {
2698
+ method: "PATCH",
2699
+ body: buildPublishingAmplificationUpdateBody(options),
2700
+ }));
2701
+ }))
2702
+ .addCommand(new Command("enable")
2703
+ .description("Arm a policy. From here the worker engages on every post in scope from the participants' real accounts, within the caps — so this refuses (exit 7) without --approved. Nothing else in the policy changes.")
2704
+ .argument("<policy_id>", "Amplification policy id.")
2705
+ .option("--approved", "Confirm arming the standing public engagement. Without it nothing is armed.")
2706
+ .option("--json", "Print a JSON envelope.")
2707
+ .action(async (policyId, options) => {
2708
+ await handleAsyncAction("publishing amplification enable", options, () => {
2709
+ if (options.approved !== true) {
2710
+ throw new OxygenError("approval_required", "Enabling a policy starts real public engagement from the participants' connected accounts. Re-run with --approved.", { details: { policy_id: policyId }, exitCode: 7 });
2711
+ }
2712
+ return requestOxygen(`/api/cli/publishing/amplification/${encodeURIComponent(policyId)}/enable`, { method: "POST" });
2713
+ });
2714
+ }))
2715
+ .addCommand(new Command("disable")
2716
+ .description("Disarm a policy and skip every action it had already planned. Always allowed — the off switch is never gated.")
2717
+ .argument("<policy_id>", "Amplification policy id.")
2718
+ .option("--json", "Print a JSON envelope.")
2719
+ .action(async (policyId, options) => {
2720
+ await handleAsyncAction("publishing amplification disable", options, () => requestOxygen(`/api/cli/publishing/amplification/${encodeURIComponent(policyId)}/disable`, {
2721
+ method: "POST",
2722
+ }));
2723
+ }))
2724
+ .addCommand(new Command("delete")
2725
+ .description("Delete a policy.")
2726
+ .argument("<policy_id>", "Amplification policy id.")
2727
+ .option("--json", "Print a JSON envelope.")
2728
+ .action(async (policyId, options) => {
2729
+ await handleAsyncAction("publishing amplification delete", options, () => requestOxygen(`/api/cli/publishing/amplification/${encodeURIComponent(policyId)}`, {
2730
+ method: "DELETE",
2731
+ }));
2732
+ }))
2733
+ .addCommand(new Command("actions")
2734
+ .description("The policy's action ledger: what each participant account did (or is about to do) on which post, with status and cost.")
2735
+ .argument("<policy_id>", "Amplification policy id.")
2736
+ .option("--status <status>", "Filter by planned, dispatched, failed, or skipped.")
2737
+ .option("--json", "Print a JSON envelope.")
2738
+ .action(async (policyId, options) => {
2739
+ await handleAsyncAction("publishing amplification actions", options, () => requestOxygen(buildPublishingAnalyticsPath(`/api/cli/publishing/amplification/${encodeURIComponent(policyId)}/actions`, { status: readOption(options.status) })));
2740
+ })))
2741
+ .addCommand(new Command("ideas")
2742
+ .description("The content backlog: ideas parked for later, with no publish time and no AI spend.")
2743
+ .addCommand(new Command("add")
2744
+ .description("File an idea into the backlog. Free — no AI call, nothing scheduled. Turn one into a post later with `publishing drafts accept --publish-at`.")
2745
+ .option("--text <text>", "The idea.")
2746
+ .option("--text-file <path>", "Read the idea from a local file.")
2747
+ .option("--topic <topic>", "Optional topic tag.")
2748
+ .option("--channels <list>", "Comma-separated channels this idea is meant for.")
2749
+ .option("--json", "Print a JSON envelope.")
2750
+ .action(async (options) => {
2751
+ await handleAsyncAction("publishing ideas add", options, () => requestOxygen("/api/cli/publishing/drafts", {
2752
+ method: "POST",
2753
+ body: buildPublishingIdeaBody(options),
2754
+ }));
2755
+ }))
2756
+ .addCommand(new Command("list")
2757
+ .description("List the idea backlog.")
2758
+ .option("--limit <n>", "Maximum ideas to return.")
2759
+ .option("--json", "Print a JSON envelope.")
2760
+ .action(async (options) => {
2761
+ await handleAsyncAction("publishing ideas list", options, () => requestOxygen(buildPublishingDraftsListPath({ ...options, kind: "idea" })));
2068
2762
  })));
2069
2763
  program
2070
2764
  .command("dashboards")
@@ -2126,7 +2820,6 @@ export function createProgram() {
2126
2820
  .requiredOption("--display-name <name>", "Human-readable object name, such as Projects.")
2127
2821
  .requiredOption("--columns-json <json>", "JSON array of column definitions {key,label,dataType,semanticType,...}.")
2128
2822
  .option("--identities-json <json>", "JSON array of identity definitions {columnKey,normalization,isPrimary?}.")
2129
- .option("--relationships-json <json>", "JSON array of relationship definitions (not yet supported).")
2130
2823
  .option("--label-column <key>", "Column key to use as the record label. Defaults to the isRecordLabel column or the first column.")
2131
2824
  .option("--singular-name <name>", "Singular display name, such as Project.")
2132
2825
  .option("--plural-name <name>", "Plural display name, such as Projects.")
@@ -2196,7 +2889,35 @@ export function createProgram() {
2196
2889
  await handleAsyncAction("crm get", options, () => requestOxygen(`/api/cli/crm/objects/${encodeURIComponent(object)}/records/${encodeURIComponent(rowId)}`));
2197
2890
  }))
2198
2891
  .addCommand(new Command("relationships")
2199
- .description("Manage CRM record relationships.")
2892
+ .description("Define CRM relationships and manage record relationship edges.")
2893
+ .addCommand(new Command("define")
2894
+ .description("Define a relationship (and its relation column) from a custom object to any object. Defaults to dry-run.")
2895
+ .argument("<object>", "Source CRM object slug. Must be a custom object, such as projects.")
2896
+ .argument("<slug>", "Relationship slug — also the relation column key on the source, such as client.")
2897
+ .requiredOption("--target-object <object>", "Target CRM object slug, custom or standard (companies, people, deals, ...).")
2898
+ .option("--display-name <name>", "Display name for the relation column. Defaults to the slug in Title Case.")
2899
+ .option("--cardinality <cardinality>", "one_to_one, one_to_many, many_to_one, or many_to_many (source:target). Default many_to_many. one_to_many means each source record links many targets and each target links back to one source.")
2900
+ .option("--inverse-slug <slug>", "Inverse relationship slug on the target. On a custom target this also creates the inverse relation column.")
2901
+ .option("--inverse-display-name <name>", "Display name for the inverse side.")
2902
+ .option("--dry-run", "Preview the relationship definition without writing.")
2903
+ .option("--live", "Apply the definition. Default is dry-run.")
2904
+ .option("--json", "Print a JSON envelope.")
2905
+ .action(async (object, slug, options) => {
2906
+ await handleAsyncAction("crm relationships define", options, () => requestOxygen(`/api/cli/crm/objects/${encodeURIComponent(object)}/relationships`, {
2907
+ method: "POST",
2908
+ body: {
2909
+ relationship: {
2910
+ slug,
2911
+ target_object: options.targetObject,
2912
+ ...(options.displayName ? { display_name: options.displayName } : {}),
2913
+ ...(options.cardinality ? { cardinality: options.cardinality } : {}),
2914
+ ...(options.inverseSlug ? { inverse_slug: options.inverseSlug } : {}),
2915
+ ...(options.inverseDisplayName ? { inverse_display_name: options.inverseDisplayName } : {}),
2916
+ },
2917
+ mode: options.live ? "live" : "dry_run",
2918
+ },
2919
+ }));
2920
+ }))
2200
2921
  .addCommand(new Command("upsert")
2201
2922
  .description("Create or replace a CRM relationship edge. Defaults to dry-run.")
2202
2923
  .argument("<object>", "Source CRM object slug, such as companies or people.")
@@ -2899,6 +3620,83 @@ export function createProgram() {
2899
3620
  });
2900
3621
  });
2901
3622
  }));
3623
+ tablesCommand.addCommand(new Command("promote")
3624
+ .description("Write bound rows' columns onto their matched CRM records (Records<->Tables). Free, but a truth write: previews by default, then re-run with --approved to write. Approved runs queue as a background run — watch with `oxygen table-runs wait <run_id>`.")
3625
+ .argument("<table>", "Table id or slug whose bound rows to promote.")
3626
+ .option("--object <slug>", "CRM object to promote onto (companies, people, deals, ...). Inferred when the table has exactly one bind column.")
3627
+ .requiredOption("--map <pairs>", "Column-to-attribute mapping as column=attribute pairs (e.g. job_title=job_title,seniority=seniority) or a JSON array/object.")
3628
+ .option("--policy <policy>", "Conflict policy: fill-empty-only (default), overwrite, or skip-conflicts.", "fill-empty-only")
3629
+ .option("--row-id <id...>", "Promote only these row ids. Defaults to every bound row.")
3630
+ .option("--dry-run", "Preview per-field would_fill/would_overwrite/conflicts without writing (the default when --approved is absent).")
3631
+ .option("--approved", "Confirm the truth write onto CRM records after inspecting the preview.")
3632
+ .option("--max-concurrency <n>", "Maximum concurrent row items for the run. Defaults to 50.")
3633
+ .option("--json", "Print a JSON envelope.")
3634
+ .action(async (table, options) => {
3635
+ if (options.dryRun && options.approved) {
3636
+ throw new OxygenError("invalid_promote", "Pass either --dry-run or --approved, not both.", { exitCode: 1 });
3637
+ }
3638
+ const mappings = parsePromoteMapOption(options.map);
3639
+ const policy = normalizePromotePolicy(options.policy);
3640
+ const maxConcurrency = readPositiveInt(options.maxConcurrency);
3641
+ await handleAsyncAction("tables promote", options, () => requestOxygen("/api/cli/tables/promote", {
3642
+ method: "POST",
3643
+ body: {
3644
+ table,
3645
+ ...(readOption(options.object) ? { object: readOption(options.object) } : {}),
3646
+ mappings,
3647
+ policy,
3648
+ ...(options.rowId && options.rowId.length > 0 ? { row_ids: options.rowId } : {}),
3649
+ // Preview by default; --approved is the only path that writes.
3650
+ ...(options.approved ? { approved: true } : { dry_run: true }),
3651
+ ...(maxConcurrency ? { max_concurrency: maxConcurrency } : {}),
3652
+ },
3653
+ }));
3654
+ }));
3655
+ tablesCommand.addCommand(new Command("dedupe")
3656
+ .description("Collapse duplicate rows by normalized (email/domain/linkedin) or fuzzy-label keys, or cross-check a column against another table. Previews by default; --apply --approved deletes losers (kept in row history).")
3657
+ .argument("<table>", "Table id or slug to dedupe.")
3658
+ .requiredOption("--on <cols>", "Key column(s): one column, or two comma-separated for a composite key (e.g. email or first_name,company).")
3659
+ .option("--normalize <mode>", "Match mode: exact (default), email, domain, linkedin, or fuzzy-label. Fuzzy allowed only as a single key.", "exact")
3660
+ .option("--keep <policy>", "Survivor per duplicate group: oldest (default), newest, or most-complete.", "oldest")
3661
+ .option("--merge-values <mode>", "With --apply, fill the survivor's blank cells from the losers first: fill-empty.")
3662
+ .option("--scan-limit <n>", "Max rows to scan. Defaults to 50000, capped at 200000.")
3663
+ .option("--apply", "Delete the duplicate rows (losers). Without this flag the command only previews.")
3664
+ .option("--approved", "Confirm the deletion (required with --apply); without it the API returns the preview to inspect first.")
3665
+ .option("--against <table>", "Cross-table check: report rows whose --on value already exists in this other table (read-only).")
3666
+ .option("--against-column <col>", "Column in the --against table to match against. Required with --against.")
3667
+ .option("--json", "Print a JSON envelope.")
3668
+ .action(async (table, options) => {
3669
+ await handleAsyncAction("tables dedupe", options, () => {
3670
+ const request = buildDedupeRequest(table, options);
3671
+ return requestOxygen(request.endpoint, { method: "POST", body: request.body });
3672
+ });
3673
+ }));
3674
+ tablesCommand.addCommand(new Command("send")
3675
+ .description("Copy rows from one table into another as a durable background run (free, resumable). Map columns with --map (or --automap), optionally flatten an array column into one row per element, and insert or upsert. --dry-run previews the resolved mapping, row estimate, and sample rows without writing.")
3676
+ .argument("<source>", "Source table id or slug.")
3677
+ .argument("<target>", "Target table id or slug (must be a different table).")
3678
+ .option("--map <pairs>", "target=source column pairs, comma-separated and/or repeated (e.g. --map company=company_name,website=domain).", collectRepeatable, [])
3679
+ .option("--mapping <json>", 'Full mapping JSON array for literals and dot-paths, e.g. [{"target":"email","source":{"type":"column","key":"$element"},"path":"email"}]. Escape hatch when --map is not enough.')
3680
+ .option("--automap", "Match columns automatically (exact key, then case-insensitive label). The resolved mapping is echoed back.")
3681
+ .option("--flatten <column>", 'Source column whose array value emits one target row per element (the element is mapping source key "$element"); non-array values pass through as one row.')
3682
+ .option("--flatten-path <path>", "Dot-path to the array inside the --flatten column's value.")
3683
+ .option("--filter <json>", 'Source-row filters JSON, e.g. [{"column":"domain","op":"is_not_null"}]. Ops: eq, neq, is_null, is_not_null, in, not_in.')
3684
+ .option("--mode <mode>", "insert (append, default) or upsert (requires --upsert-key).", "insert")
3685
+ .option("--upsert-key <key>", "Mapped target column that identifies existing rows for upsert.")
3686
+ .option("--page-size <n>", "Rows copied per durable page. Defaults to 500, max 2000.")
3687
+ .option("--dry-run", "Validate and preview only; nothing is written.")
3688
+ .option("--json", "Print a JSON envelope.")
3689
+ .action(async (source, target, options) => {
3690
+ try {
3691
+ const body = buildTablesSendRequest(source, target, options);
3692
+ const data = await requestOxygen("/api/cli/tables/send", { method: "POST", body });
3693
+ emitSuccess("tables send", data, options);
3694
+ writeTablesSendHint(data);
3695
+ }
3696
+ catch (error) {
3697
+ emitCliFailure("tables send", error);
3698
+ }
3699
+ }));
2902
3700
  tablesCommand.addCommand(new Command("webhook")
2903
3701
  .description("Create and manage direct table webhooks.")
2904
3702
  .addCommand(new Command("list")
@@ -2932,7 +3730,7 @@ export function createProgram() {
2932
3730
  .option("--event-type-path <path>", "Dot path to the event type. Defaults to type or event.")
2933
3731
  .option("--occurred-at-path <path>", "Dot path to the event timestamp.")
2934
3732
  .option("--auto-run-columns <csv>", "Comma-separated enrichment, tool, AI, or formula columns to queue for webhook-written rows.")
2935
- .addOption(new Option("--auto-run-max-credits <n>", "Deprecated optional safety ceiling for webhook-triggered auto-runs.").hideHelp())
3733
+ .option("--auto-run-max-credits <n>", "Credit ceiling per webhook-triggered auto-run batch; items beyond it are skipped with credit_limit_reached.")
2936
3734
  .option("--auto-run-max-concurrency <n>", "Maximum concurrent row items for webhook-triggered auto-runs.")
2937
3735
  .option("--auto-run-force", "Run auto-run columns even when the target cell already has a value.")
2938
3736
  .option("--auto-run-connection-id <connection_id>", "Optional provider integration connection id for auto-runs.")
@@ -2963,7 +3761,7 @@ export function createProgram() {
2963
3761
  .option("--status <status>", "active or disabled.")
2964
3762
  .option("--auth-mode <mode>", "none or secret. Setting secret rotates and returns a new webhook secret.")
2965
3763
  .option("--auto-run-columns <csv>", "Comma-separated enrichment, tool, AI, or formula columns to queue for webhook-written rows.")
2966
- .addOption(new Option("--auto-run-max-credits <n>", "Deprecated optional safety ceiling for webhook-triggered auto-runs.").hideHelp())
3764
+ .option("--auto-run-max-credits <n>", "Credit ceiling per webhook-triggered auto-run batch; items beyond it are skipped with credit_limit_reached.")
2967
3765
  .option("--auto-run-max-concurrency <n>", "Maximum concurrent row items for webhook-triggered auto-runs.")
2968
3766
  .option("--auto-run-force", "Run auto-run columns even when the target cell already has a value.")
2969
3767
  .option("--auto-run-connection-id <connection_id>", "Optional provider integration connection id for auto-runs.")
@@ -3067,6 +3865,125 @@ export function createProgram() {
3067
3865
  .argument("<view>", "View id.")
3068
3866
  .option("--json", "Print a JSON envelope.")
3069
3867
  .action((table, view, options) => handleAsyncAction("tables views delete", options, () => requestOxygen(`/api/cli/tables/views?table=${encodeURIComponent(table)}&view=${encodeURIComponent(view)}`, { method: "DELETE" })))));
3868
+ tablesCommand.addCommand(new Command("auto-run")
3869
+ .description("Inspect and manage a table's standing auto-run configuration (columns queued automatically when new rows are written).")
3870
+ .addCommand(new Command("get")
3871
+ .description("Show a table's standing auto-run configuration (columns queued automatically when new rows are written).")
3872
+ .argument("<table>", "Table id or slug.")
3873
+ .option("--json", "Print a JSON envelope.")
3874
+ .action((table, options) => handleAsyncAction("tables auto-run get", options, () => {
3875
+ const params = new URLSearchParams({ table });
3876
+ return requestOxygen(`/api/cli/tables/auto-run?${params.toString()}`);
3877
+ })))
3878
+ .addCommand(new Command("set")
3879
+ .description("Enable a standing auto-run: automatically queue the given columns for rows written to this table (imports, inserts, upserts). Default off.")
3880
+ .argument("<table>", "Table id or slug.")
3881
+ .requiredOption("--columns <csv>", "Comma-separated column keys to auto-run on newly written rows (tool, AI, formula, enrichment, bind, or lookup columns).")
3882
+ .option("--sources <csv>", "Which write sources trigger the auto-run: any of import, insert, upsert, send. Defaults to all when omitted.")
3883
+ .option("--force", "Re-run the columns even for rows that already have values.")
3884
+ .option("--max-credits <n>", "Credit ceiling PER enqueued auto-run batch; rows beyond it are skipped with credit_limit_reached.")
3885
+ .option("--max-concurrency <n>", "Maximum concurrent row items per auto-run batch.")
3886
+ .option("--json", "Print a JSON envelope.")
3887
+ .action((table, options) => {
3888
+ const columns = readCsvOption(options.columns);
3889
+ const sources = readCsvOption(options.sources);
3890
+ const maxCredits = readPositiveNumber(options.maxCredits);
3891
+ const maxConcurrency = readPositiveInt(options.maxConcurrency);
3892
+ const force = Boolean(options.force);
3893
+ return handleAsyncAction("tables auto-run set", options, () => requestOxygen("/api/cli/tables/auto-run", {
3894
+ method: "POST",
3895
+ body: {
3896
+ table,
3897
+ columns,
3898
+ ...(sources.length ? { sources } : {}),
3899
+ ...(force ? { force: true } : {}),
3900
+ ...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
3901
+ ...(maxConcurrency ? { max_concurrency: maxConcurrency } : {}),
3902
+ },
3903
+ }));
3904
+ }))
3905
+ .addCommand(new Command("disable")
3906
+ .description("Disable (clear) a table's standing auto-run configuration.")
3907
+ .argument("<table>", "Table id or slug.")
3908
+ .option("--json", "Print a JSON envelope.")
3909
+ .action((table, options) => handleAsyncAction("tables auto-run disable", options, () => requestOxygen("/api/cli/tables/auto-run", {
3910
+ method: "DELETE",
3911
+ body: { table },
3912
+ })))));
3913
+ tablesCommand.addCommand(new Command("auto-dedupe")
3914
+ .description("Inspect and manage a table's standing auto-dedupe configuration (duplicate rows collapsed automatically after every write; deleted rows are kept in row history).")
3915
+ .addCommand(new Command("get")
3916
+ .description("Show a table's standing auto-dedupe configuration.")
3917
+ .argument("<table>", "Table id or slug.")
3918
+ .option("--json", "Print a JSON envelope.")
3919
+ .action((table, options) => handleAsyncAction("tables auto-dedupe get", options, () => {
3920
+ const params = new URLSearchParams({ table });
3921
+ return requestOxygen(`/api/cli/tables/auto-dedupe?${params.toString()}`);
3922
+ })))
3923
+ .addCommand(new Command("set")
3924
+ .description("Enable a standing auto-dedupe: after every write (insert, upsert, import, send, webhook), duplicate groups containing a just-written row are collapsed by the keep policy — losers are DELETED automatically without per-write approval (kept in row history), and any standing auto-run only enriches the survivors. Runs BEFORE auto-run so credits are never spent on rows about to be deleted. Fuzzy-label matching is on-demand only: use `oxygen tables dedupe --normalize fuzzy-label` instead.")
3925
+ .argument("<table>", "Table id or slug.")
3926
+ .requiredOption("--on <cols>", "Key column(s): one column, or two comma-separated for a composite key (e.g. email or first_name,company).")
3927
+ .option("--normalize <modes>", "Match mode per --on column, comma-separated positionally: exact (default), email, domain, or linkedin. Unpaired columns default to exact; fuzzy-label is rejected (on-demand only).")
3928
+ .option("--keep <policy>", "Survivor per duplicate group (existing rows included): oldest (default), newest, or most-complete.", "oldest")
3929
+ .option("--merge-values <mode>", "Fill the survivor's blank cells from the losers before deleting them: fill-empty.")
3930
+ .option("--json", "Print a JSON envelope.")
3931
+ .action((table, options) => handleAsyncAction("tables auto-dedupe set", options, () => requestOxygen("/api/cli/tables/auto-dedupe", {
3932
+ method: "POST",
3933
+ body: buildTablesAutoDedupeSetBody(table, options),
3934
+ }))))
3935
+ .addCommand(new Command("disable")
3936
+ .description("Disable (clear) a table's standing auto-dedupe configuration.")
3937
+ .argument("<table>", "Table id or slug.")
3938
+ .option("--json", "Print a JSON envelope.")
3939
+ .action((table, options) => handleAsyncAction("tables auto-dedupe disable", options, () => requestOxygen("/api/cli/tables/auto-dedupe", {
3940
+ method: "DELETE",
3941
+ body: { table },
3942
+ })))));
3943
+ tablesCommand.addCommand(new Command("schedule")
3944
+ .description("Scheduled column refresh: a cron workflow re-runs one column on a schedule (only empty/failed cells unless --force). One schedule per column.")
3945
+ .addCommand(new Command("set")
3946
+ .description("Create or replace a column's scheduled refresh (a cron-triggered hosted workflow; visible via `oxygen workflows list`). Paid columns (tool, AI, enrichment) require --max-credits AND --approved; formula columns are free.")
3947
+ .argument("<table>", "Table id or slug.")
3948
+ .argument("<column>", "Column key or id (tool, AI, formula, or enrichment kind).")
3949
+ .option("--cron <expr>", 'Cron expression in UTC, e.g. "0 9 * * *".')
3950
+ .option("--every <sugar>", "Shorthand schedule: hourly, daily@<hour>, or weekly@<day><hour> (e.g. weekly@mon9). Hours 0-23 UTC.")
3951
+ .option("--max-credits <n>", "Credit ceiling PER scheduled refresh; required with --approved for paid columns.")
3952
+ .option("--approved", "Approve the recurring paid runs; required with --max-credits for paid columns.")
3953
+ .option("--force", "Re-run cells that already have values on every tick (full refresh instead of fill-missing).")
3954
+ .option("--json", "Print a JSON envelope.")
3955
+ .action((table, column, options) => {
3956
+ const maxCredits = readPositiveNumber(options.maxCredits);
3957
+ return handleAsyncAction("tables schedule set", options, () => requestOxygen("/api/cli/tables/schedule", {
3958
+ method: "POST",
3959
+ body: {
3960
+ table,
3961
+ column,
3962
+ ...(readOption(options.cron) ? { cron: readOption(options.cron) } : {}),
3963
+ ...(readOption(options.every) ? { every: readOption(options.every) } : {}),
3964
+ ...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
3965
+ ...(options.approved ? { approved: true } : {}),
3966
+ ...(options.force ? { force: true } : {}),
3967
+ },
3968
+ }));
3969
+ }))
3970
+ .addCommand(new Command("list")
3971
+ .description("List a table's scheduled column refreshes.")
3972
+ .argument("<table>", "Table id or slug.")
3973
+ .option("--json", "Print a JSON envelope.")
3974
+ .action((table, options) => handleAsyncAction("tables schedule list", options, () => {
3975
+ const params = new URLSearchParams({ table });
3976
+ return requestOxygen(`/api/cli/tables/schedule?${params.toString()}`);
3977
+ })))
3978
+ .addCommand(new Command("remove")
3979
+ .description("Remove a column's scheduled refresh (deletes the backing cron workflow; future scheduled runs stop).")
3980
+ .argument("<table>", "Table id or slug.")
3981
+ .argument("<column>", "Column key or id.")
3982
+ .option("--json", "Print a JSON envelope.")
3983
+ .action((table, column, options) => handleAsyncAction("tables schedule remove", options, () => requestOxygen("/api/cli/tables/schedule", {
3984
+ method: "DELETE",
3985
+ body: { table, column },
3986
+ })))));
3070
3987
  program
3071
3988
  .command("context")
3072
3989
  .description("Workspace-level GTM context commands.")
@@ -3505,7 +4422,7 @@ export function createProgram() {
3505
4422
  .option("--enabled", "Enable unattended synthesis.")
3506
4423
  .option("--disabled", "Disable unattended synthesis.")
3507
4424
  .option("--cadence-hours <n>", "Hours between synthesis cycles (1-168, default 24).")
3508
- .option("--max-credits-per-day <n>", "Managed-credit cap per UTC day (0-500, default 5).")
4425
+ .option("--max-credits-per-day <n>", "Managed-credit cap per UTC day (0-10,000, default 100).")
3509
4426
  .option("--json", "Print a JSON envelope.")
3510
4427
  .action(async (options) => {
3511
4428
  await handleAsyncAction("knowledge agent set", options, () => {
@@ -3560,7 +4477,7 @@ export function createProgram() {
3560
4477
  }));
3561
4478
  program
3562
4479
  .command("blueprints")
3563
- .description("Portable Oxygen blueprints: bundle a workflow + tables + columns + prompts as shareable JSON.")
4480
+ .description("Scaffolding bundles: a workflow + tables + columns + prompts as shareable JSON. For guided GTM plays see `oxygen recipes`.")
3564
4481
  .addCommand(new Command("list")
3565
4482
  .description("List Oxygen blueprints visible in this workspace (seeds + saved).")
3566
4483
  .argument("[query]", "Search text.")
@@ -3794,6 +4711,64 @@ export function createProgram() {
3794
4711
  return requestOxygen(`/api/blueprints/marketplace${qs}`, { requireAuth: false });
3795
4712
  });
3796
4713
  }));
4714
+ program
4715
+ .command("recipes")
4716
+ .description("Business-case GTM playbooks: proven plays with prerequisites, credit posture, and approval gates spelled out.")
4717
+ .addCommand(new Command("list")
4718
+ .description("List recipes, optionally filtered by text, business-case category, journey stage, or audience.")
4719
+ .argument("[query]", "Search text (matches slug/title/business case/tags).")
4720
+ .option("--category <category>", "Business-case category (e.g. start-here, pipeline-from-zero).")
4721
+ .option("--stage <stage>", "Journey stage: day-1, day-7, day-30, or ongoing.")
4722
+ .option("--audience <audience>", "founder, gtm-operator, or agency-operator.")
4723
+ .option("--json", "Print a JSON envelope.")
4724
+ .action(async (query, options) => {
4725
+ await handleAsyncAction("recipes list", options, () => {
4726
+ const params = new URLSearchParams();
4727
+ if (query)
4728
+ params.set("query", query);
4729
+ const category = readOption(options.category);
4730
+ if (category)
4731
+ params.set("category", category);
4732
+ const stage = readOption(options.stage);
4733
+ if (stage)
4734
+ params.set("stage", stage);
4735
+ const audience = readOption(options.audience);
4736
+ if (audience)
4737
+ params.set("audience", audience);
4738
+ const qs = params.toString() ? `?${params.toString()}` : "";
4739
+ return requestOxygen(`/api/cli/recipes${qs}`);
4740
+ });
4741
+ }))
4742
+ .addCommand(new Command("show")
4743
+ .description("Show one recipe: the full playbook body plus prerequisites, credits, and approval gates.")
4744
+ .argument("<slug>", "Recipe slug, e.g. outbound-pilot-50.")
4745
+ .option("--json", "Print a JSON envelope.")
4746
+ .action(async (slug, options) => {
4747
+ await handleAsyncAction("recipes show", options, () => requestOxygen("/api/cli/recipes/get", {
4748
+ method: "POST",
4749
+ body: { slug },
4750
+ }));
4751
+ }))
4752
+ .addCommand(new Command("install")
4753
+ .description("Install a recipe into the workspace wiki as a playbook page (revisioned, retrieval-grounded, with per-version provenance).")
4754
+ .argument("<slug>", "Recipe slug, e.g. outbound-pilot-50.")
4755
+ .option("--slug <wiki_slug>", "Override the target wiki slug (default playbook-<recipe-slug>).")
4756
+ .option("--draft", "Install as a draft page (drafts never ground AI actions until activated).")
4757
+ .option("--force", "Overwrite a customized installed copy (edits survive as a page revision).")
4758
+ .option("--json", "Print a JSON envelope.")
4759
+ .action(async (slug, options) => {
4760
+ await handleAsyncAction("recipes install", options, () => {
4761
+ const body = { slug };
4762
+ const wikiSlug = readOption(options.slug);
4763
+ if (wikiSlug)
4764
+ body.wiki_slug = wikiSlug;
4765
+ if (options.draft)
4766
+ body.draft = true;
4767
+ if (options.force)
4768
+ body.force = true;
4769
+ return requestOxygen("/api/cli/recipes/install", { method: "POST", body });
4770
+ });
4771
+ }));
3797
4772
  program.addCommand(buildPromptTemplatesCommand("prompts", "Reusable prompt templates layered into AI columns at run time."));
3798
4773
  // The deprecated `templates` alias tree was removed at its registry sunset
3799
4774
  // (v1.290.0) — `oxygen prompts` has been canonical since v1.80.0.
@@ -3873,7 +4848,7 @@ export function createProgram() {
3873
4848
  .option("--label <label>", "Display label for the new column. Required unless --prompt-key supplies a default title.")
3874
4849
  .option("--key <key>", "Optional stable column key. Defaults to a normalized label.")
3875
4850
  .option("--data-type <type>", "Column data type: text, numeric, boolean, jsonb, or timestamptz.")
3876
- .option("--kind <kind>", "Column kind. Defaults to manual.")
4851
+ .option("--kind <kind>", "Column kind: manual, ai, formula, enrichment, tool, bind, or lookup. Defaults to manual.")
3877
4852
  .option("--semantic-type <type>", "Optional semantic type such as company_domain.")
3878
4853
  .option("--definition-json <json>", "Optional JSON object with column definition metadata.")
3879
4854
  .option("--prompt-key <key>", "OXYGEN prompt-library key (e.g. email_draft_v1). Materializes prompt + output_schema and forces kind=ai.")
@@ -3884,6 +4859,16 @@ export function createProgram() {
3884
4859
  .option("--run-condition-columns <csv>", "Comma-separated column keys referenced by --run-condition.")
3885
4860
  .option("--output-schema-json <json>", "AI column output JSON schema as inline JSON.")
3886
4861
  .option("--output-schema-file <path>", "AI column output JSON schema read from a file path.")
4862
+ .option("--bind-object <slug>", "Bind column CRM object slug (companies, people, deals, ...). Sets kind=bind and resolves rows to CRM records by identity.")
4863
+ .option("--bind-map <pairs>", "Bind identity mapping as identity=column pairs, e.g. domain=website,linkedin_url=li.")
4864
+ .option("--bind-create", "Bind columns: assert a new CRM record for unmatched rows (onNoMatch=create; a truth write, needs --approved on run).")
4865
+ .option("--lookup-table <table>", "Lookup column: source table id or slug to read from (same workspace). Sets kind=lookup. Free + point-in-time (re-run to refresh).")
4866
+ .option("--lookup-match <pair>", "Lookup join as localColumn=sourceColumn, e.g. company_domain=domain.")
4867
+ .option("--lookup-normalize <mode>", "Lookup key normalization: exact, lower-trim (default), email, domain, or linkedin.")
4868
+ .option("--lookup-mode <mode>", "Lookup mode: first-match (default), count, exists, or aggregate.")
4869
+ .option("--lookup-return <csv>", "Lookup first-match: source column keys to pull (single -> scalar cell, multiple -> object).")
4870
+ .option("--lookup-order <column:dir>", "Lookup first-match tie-break when a key matches many source rows, e.g. created_at:desc.")
4871
+ .option("--lookup-aggregate <fn:column>", "Lookup aggregate reducer as fn:column, e.g. sum:amount (fn: sum, avg, min, or max).")
3887
4872
  .option("--json", "Print a JSON envelope.")
3888
4873
  // skipcq: JS-R1005 — intentional per-option branching to assemble the columns-add request body
3889
4874
  .action(async (table, options) => {
@@ -3914,6 +4899,39 @@ export function createProgram() {
3914
4899
  const definition = isRecord(column.definition) ? column.definition : {};
3915
4900
  column.definition = applyAiColumnConfig(definition, options);
3916
4901
  }
4902
+ if (readOption(options.bindObject) || readOption(options.bindMap) || options.bindCreate) {
4903
+ const definition = isRecord(column.definition) ? column.definition : {};
4904
+ column.kind = "bind";
4905
+ // Bind columns write a jsonb status cell; the server rejects any other
4906
+ // data type (invalid_column_definition). Default it so callers don't have
4907
+ // to pass --data-type jsonb by hand alongside --bind-object.
4908
+ if (!options.dataType)
4909
+ column.data_type = "jsonb";
4910
+ column.definition = applyBindColumnConfig(definition, options);
4911
+ }
4912
+ if (readOption(options.lookupTable)
4913
+ || readOption(options.lookupMatch)
4914
+ || readOption(options.lookupMode)
4915
+ || readOption(options.lookupReturn)
4916
+ || readOption(options.lookupOrder)
4917
+ || readOption(options.lookupAggregate)
4918
+ || readOption(options.lookupNormalize)) {
4919
+ const definition = isRecord(column.definition) ? column.definition : {};
4920
+ column.kind = "lookup";
4921
+ const lookupDefinition = applyLookupColumnConfig(definition, options);
4922
+ column.definition = lookupDefinition;
4923
+ // Derive the output data type from the mode so callers don't have to know
4924
+ // it (the bind lesson): count/aggregate -> numeric, exists -> boolean,
4925
+ // first_match -> jsonb (holds a scalar OR the multi-return object).
4926
+ if (!options.dataType) {
4927
+ const mode = typeof lookupDefinition.mode === "string" ? lookupDefinition.mode : "first_match";
4928
+ column.data_type = mode === "count" || mode === "aggregate"
4929
+ ? "numeric"
4930
+ : mode === "exists"
4931
+ ? "boolean"
4932
+ : "jsonb";
4933
+ }
4934
+ }
3917
4935
  const body = { table, column };
3918
4936
  if (options.promptKey)
3919
4937
  body.prompt_key = options.promptKey;
@@ -3925,7 +4943,7 @@ export function createProgram() {
3925
4943
  }));
3926
4944
  }))
3927
4945
  .addCommand(new Command("run")
3928
- .description("Run an executable AI, tool, formula, or local custom HTTP column for one row or a bounded batch.")
4946
+ .description("Run an executable AI, tool, formula, enrichment, bind, lookup, or local custom HTTP column for one row or a bounded batch. Bind create-mode (onNoMatch=create) needs --approved.")
3929
4947
  .argument("<table>", "Table id or slug.")
3930
4948
  .argument("<column>", "Column id or key.")
3931
4949
  .option("--row-id <row_id>", "Workspace row id to run.")
@@ -3935,7 +4953,7 @@ export function createProgram() {
3935
4953
  .option("--force", "Run even when the target cell already has a value.")
3936
4954
  .option("--connection-id <connection_id>", "Optional provider integration connection id.")
3937
4955
  .option("--background", "Create a durable background table action run instead of executing synchronously.")
3938
- .option("--approved", "Confirm the paid background run after inspecting a dry run or preview.")
4956
+ .option("--approved", "Confirm a paid background run, or a bind create-mode run (onNoMatch=create), after inspecting a dry run or preview.")
3939
4957
  .option("--max-credits <n>", "Maximum managed/provider credits to reserve for a background run.")
3940
4958
  .option("--max-concurrency <n>", "Maximum concurrent row items for a background run. Defaults to 250 for AI columns and 50 otherwise.")
3941
4959
  .option("--local", "Run a custom HTTP column in this CLI process so env-var secrets stay local.")
@@ -4891,7 +5909,7 @@ export function createProgram() {
4891
5909
  .command("billing")
4892
5910
  .description("Plan and managed credit commands.")
4893
5911
  .addCommand(new Command("balance")
4894
- .description("Show the current plan and managed credit balance.")
5912
+ .description("Show the current plan and managed credit balance. Credits are Oxygen's native unit; the plan's price and $-per-credit are at https://oxygen-agent.com/billing.")
4895
5913
  .option("--json", "Print a JSON envelope.")
4896
5914
  .action(async (options) => {
4897
5915
  await handleAsyncAction("billing balance", options, () => requestOxygen("/api/cli/billing/balance"));
@@ -5014,6 +6032,18 @@ export function createProgram() {
5014
6032
  method: "POST",
5015
6033
  body: { action: "resume" },
5016
6034
  }));
6035
+ }))
6036
+ .addCommand(new Command("topup")
6037
+ .description("Buy on-demand credits at $1.25 per 1,000 (subscription credits are 20% cheaper). Run without a pack to list packs; with a pack it returns a Stripe checkout link — credits post as soon as the payment confirms and never expire.")
6038
+ .argument("[pack]", "Pack size in USD: 10, 25, 100, or 250.")
6039
+ .option("--json", "Print a JSON envelope.")
6040
+ .action(async (pack, options) => {
6041
+ await handleAsyncAction("billing topup", options, () => pack
6042
+ ? requestOxygen("/api/cli/billing/topup", {
6043
+ method: "POST",
6044
+ body: { pack },
6045
+ })
6046
+ : requestOxygen("/api/cli/billing/topup"));
5017
6047
  }));
5018
6048
  program
5019
6049
  .command("budget")
@@ -5331,9 +6361,9 @@ export function createProgram() {
5331
6361
  })));
5332
6362
  program
5333
6363
  .command("observability")
5334
- .description("Redacted operation event commands for the current organization.")
6364
+ .description("Workspace observability: redacted operation events, plus the cross-primitive runs lens and approvals inbox (list-only — decisions route to the owning primitive).")
5335
6365
  .addCommand(new Command("events")
5336
- .description("List recent redacted operation events and failures.")
6366
+ .description("List recent redacted operation events and failures. For staff ticket-change envelopes, use this CLI's `support admin events` command.")
5337
6367
  // Keep in sync with OBSERVABILITY_STATUS_FILTERS in
5338
6368
  // apps/web/src/lib/observability.ts and the MCP tool enum in
5339
6369
  // packages/mcp-server/src/tools/observability.ts. The API rejects any
@@ -5373,6 +6403,112 @@ export function createProgram() {
5373
6403
  const suffix = params.toString() ? `?${params.toString()}` : "";
5374
6404
  return requestOxygen(`/api/cli/observability/events${suffix}`);
5375
6405
  });
6406
+ }))
6407
+ .addCommand(new Command("runs")
6408
+ .description("List active and failed runs across workflows and table action/ingestion runs — the cross-primitive runs lens. Read-only; each failed item carries the command that resolves it.")
6409
+ // Keep --status / --source values in sync with OBSERVABILITY_CONSOLE_STATES
6410
+ // and OBSERVABILITY_RUN_SOURCES in apps/web/src/lib/observability-console.ts
6411
+ // and the MCP tool enums. The API rejects any other value with
6412
+ // invalid_request so a typo fails loudly.
6413
+ .option("--status <statuses>", "Comma-separated normalized states: running, queued, waiting_approval, failed, completed, canceled. Defaults to active + failed.")
6414
+ .option("--source <sources>", "Comma-separated sources: workflow, table_run.")
6415
+ .option("--limit <n>", "Maximum items per source (default 25, max 100). Counts reflect the returned page only.")
6416
+ .option("--json", "Print a JSON envelope.")
6417
+ .action(async (options) => {
6418
+ await handleAsyncAction("observability runs", options, async () => {
6419
+ const params = new URLSearchParams();
6420
+ const status = readOption(options.status);
6421
+ const source = readOption(options.source);
6422
+ const limit = readPositiveInt(options.limit);
6423
+ if (status)
6424
+ params.set("status", status);
6425
+ if (source)
6426
+ params.set("source", source);
6427
+ if (limit)
6428
+ params.set("limit", String(limit));
6429
+ const suffix = params.toString() ? `?${params.toString()}` : "";
6430
+ const data = await requestOxygen(`/api/cli/observability/runs${suffix}`);
6431
+ if (!options.json)
6432
+ writeObservabilityCapsNotice(data);
6433
+ return data;
6434
+ });
6435
+ }))
6436
+ .addCommand(new Command("approvals")
6437
+ .description("List everything waiting for a human decision across workflows, publishing, message reviews, and inbox reply drafts — the cross-primitive approvals inbox. Read-only: each item includes the exact command to resolve it and a web link to decide.")
6438
+ // Keep --source values in sync with OBSERVABILITY_APPROVAL_SOURCES in
6439
+ // apps/web/src/lib/observability-console.ts and the MCP tool enum. The API
6440
+ // rejects any other value with invalid_request so a typo fails loudly.
6441
+ .option("--source <sources>", "Comma-separated sources: workflow, publishing, message_review, inbox_draft.")
6442
+ .option("--limit <n>", "Maximum items per source (default 25, max 100). Counts reflect the returned page only.")
6443
+ .option("--json", "Print a JSON envelope.")
6444
+ .action(async (options) => {
6445
+ await handleAsyncAction("observability approvals", options, async () => {
6446
+ const params = new URLSearchParams();
6447
+ const source = readOption(options.source);
6448
+ const limit = readPositiveInt(options.limit);
6449
+ if (source)
6450
+ params.set("source", source);
6451
+ if (limit)
6452
+ params.set("limit", String(limit));
6453
+ const suffix = params.toString() ? `?${params.toString()}` : "";
6454
+ const data = await requestOxygen(`/api/cli/observability/approvals${suffix}`);
6455
+ if (!options.json)
6456
+ writeObservabilityCapsNotice(data);
6457
+ return data;
6458
+ });
6459
+ }));
6460
+ // Singular `agent`, deliberately: this is ONE governed roster surface — a lens
6461
+ // over the built-in specialist agents that already run inside the workspace as
6462
+ // approval-gated, workflow-owned behavior — not a 14th primitive and not a
6463
+ // build-your-own-agent runtime. (Distinct from `oxygen knowledge agent`, which
6464
+ // configures the knowledge-synthesis specialist itself.)
6465
+ program
6466
+ .command("agent")
6467
+ .description("Workspace Agent roster: your built-in specialist agents (inbox reply drafts, meeting notetaker, knowledge synthesis) that run inside your workspace as approval-gated runs. List them, inspect run history, and enable or disable each one.")
6468
+ .addCommand(new Command("list")
6469
+ .description("List the built-in specialist agents with their enabled state and recent run history.")
6470
+ .option("--json", "Print a JSON envelope.")
6471
+ .action(async (options) => {
6472
+ await handleAsyncAction("agent list", options, () => requestOxygen("/api/cli/agent"));
6473
+ }))
6474
+ .addCommand(new Command("get")
6475
+ .description("Show one specialist agent: its state, approval boundary, config command, and recent runs.")
6476
+ .argument("<slug>", "Specialist slug: inbox-reply-drafts, meeting-notetaker, or knowledge-synthesis.")
6477
+ .option("--json", "Print a JSON envelope.")
6478
+ .action(async (slug, options) => {
6479
+ await handleAsyncAction("agent get", options, () => requestOxygen(`/api/cli/agent/${encodeURIComponent(slug)}`));
6480
+ }))
6481
+ .addCommand(new Command("enable")
6482
+ .description("Enable a specialist agent so it resumes its approval-gated drafts/runs.")
6483
+ .argument("<slug>", "Specialist slug: inbox-reply-drafts, meeting-notetaker, or knowledge-synthesis.")
6484
+ .option("--json", "Print a JSON envelope.")
6485
+ .action(async (slug, options) => {
6486
+ await handleAsyncAction("agent enable", options, () => requestOxygen(`/api/cli/agent/${encodeURIComponent(slug)}/enable`, { method: "POST" }));
6487
+ }))
6488
+ .addCommand(new Command("disable")
6489
+ .description("Disable a specialist agent so it stops its drafts/runs on the next worker tick.")
6490
+ .argument("<slug>", "Specialist slug: inbox-reply-drafts, meeting-notetaker, or knowledge-synthesis.")
6491
+ .option("--json", "Print a JSON envelope.")
6492
+ .action(async (slug, options) => {
6493
+ await handleAsyncAction("agent disable", options, () => requestOxygen(`/api/cli/agent/${encodeURIComponent(slug)}/disable`, { method: "POST" }));
6494
+ }))
6495
+ .addCommand(new Command("runs")
6496
+ .description("List recent runs across the specialist agents (or one via <slug>), newest first.")
6497
+ .argument("[slug]", "Optional specialist slug to filter to one agent.")
6498
+ .option("--limit <n>", "Maximum runs to return (1-100). Defaults to 25.")
6499
+ .option("--json", "Print a JSON envelope.")
6500
+ .action(async (slug, options) => {
6501
+ await handleAsyncAction("agent runs", options, () => {
6502
+ const params = new URLSearchParams();
6503
+ const target = readOption(slug);
6504
+ const limit = readPositiveInt(options.limit);
6505
+ if (target)
6506
+ params.set("slug", target);
6507
+ if (limit)
6508
+ params.set("limit", String(limit));
6509
+ const suffix = params.toString() ? `?${params.toString()}` : "";
6510
+ return requestOxygen(`/api/cli/agent/runs${suffix}`);
6511
+ });
5376
6512
  }));
5377
6513
  program
5378
6514
  .command("runs")
@@ -6311,10 +7447,10 @@ export function createProgram() {
6311
7447
  });
6312
7448
  })));
6313
7449
  program.addCommand(new Command("posts")
6314
- .description("Read and publish LinkedIn posts through a connected account. `get` returns a post and the composite social_id that `comments`/`reactions` and `oxygen engagement harvest` need; `create` publishes a real post (approval-gated). All calls are metered against the sender account's daily quota.")
7450
+ .description("Read and publish LinkedIn posts through a connected account. `get` returns a post and the composite social_id that `comments`/`reactions` and `oxygen engagement harvest` need; `create` publishes a real post (approval-gated). Every command here addresses ONE post you already have an id for — Oxygen cannot enumerate an account's own LinkedIn history, so analytics only cover posts Oxygen itself published (`oxygen publishing ...`). All calls are metered against the sender account's daily quota.")
6315
7451
  .addCommand(new Command("get")
6316
7452
  .description("Read a LinkedIn post. Returns the post and its composite social_id (reuse that social_id for `posts comments`, `posts reactions`, and `engagement harvest --source unipile` — NOT the raw activity URN). Metered as an account read.")
6317
- .requiredOption("--post <id>", "Numeric activity id, activity URL, or composite social_id.")
7453
+ .requiredOption("--post <id>", "Numeric activity id, activity URL, or composite social_id. There is no way to LIST your own posts: take the id from the post's LinkedIn URL, or from a post you scheduled (`oxygen publishing posts get <post_id>` → provider_post_id).")
6318
7454
  .option("--account <ref>", "Sender account to read through (sender id, connection id, or Unipile account id). Omit for the org default.")
6319
7455
  .option("--json", "Print a JSON envelope.")
6320
7456
  .action(async (options) => {
@@ -6530,14 +7666,14 @@ export function createProgram() {
6530
7666
  });
6531
7667
  }))
6532
7668
  .addCommand(new Command("watch")
6533
- .description("Declarative engagement watches (the signals wedge): stand up a watch on a post's engagers or 'who viewed my profile' that harvests people into a table and, under a standing approval, auto-enrolls them into a sequence. The watch materializes the harvest drip and enrolls newly-harvested people each cycle — the sequence still gates its own sends.")
7669
+ .description("Declarative engagement watches (the signals wedge): stand up a watch on a post's engagers, 'who viewed my profile', or the sender's inbound network (new followers / new connections) that harvests people into a table and, under a standing approval, auto-enrolls them into a sequence. The watch materializes the harvest drip and enrolls newly-harvested people each cycle — the sequence still gates its own sends.")
6534
7670
  .addCommand(new Command("create")
6535
- .description("Arm an engagement watch. `--kind post` watches a post's reactors + commenters (needs --post social_id; a cookieless post also needs --post-url); `--kind profile_viewers` watches 'who viewed my profile' (source unipile). With --auto-enroll it enrolls harvested people into --sequence, capped by --max-enrolls-per-day. --max-credits-per-cycle is the standing per-cycle spend cap (required for --auto-enroll and cookieless). No messages are sent by the watch itself.")
6536
- .requiredOption("--kind <kind>", "post | profile_viewers.")
7671
+ .description("Arm an engagement watch. `--kind post` watches a post's reactors + commenters (needs --post social_id; a cookieless post also needs --post-url); `--kind profile_viewers` watches 'who viewed my profile' (source unipile); `--kind followers` / `--kind connections` watch the sender's inbound network — people NEW to the org's orbit stream into the watch table as they follow/connect (source unipile, no credits, reads metered against the account's daily ingest budget). With --auto-enroll it enrolls harvested people into --sequence, capped by --max-enrolls-per-day — the inbound-led-outbound loop. --max-credits-per-cycle is the standing per-cycle spend cap (required for --auto-enroll and cookieless). No messages are sent by the watch itself.")
7672
+ .requiredOption("--kind <kind>", "post | profile_viewers | followers | connections.")
6537
7673
  .requiredOption("--account <ref>", "Reading LinkedIn sender (sender id, connection id, or Unipile account id).")
6538
7674
  .option("--post <social_id>", "Composite post social_id from `oxygen posts get` (required for --kind post).")
6539
7675
  .option("--post-url <url>", "Public LinkedIn post URL (required for a cookieless post watch).")
6540
- .option("--source <source>", "cookieless | unipile. Defaults to cookieless for post, unipile for profile_viewers.")
7676
+ .option("--source <source>", "cookieless | unipile. Defaults to cookieless for post; profile_viewers/followers/connections are always unipile.")
6541
7677
  .option("--table <id_or_slug>", "Existing table to land harvested people into. Omit to create one.")
6542
7678
  .option("--sequence <id_or_slug>", "Sequence to auto-enroll harvested people into (required with --auto-enroll).")
6543
7679
  .option("--auto-enroll", "Auto-enroll newly harvested people into --sequence under the standing approval.")
@@ -6839,7 +7975,7 @@ export function createProgram() {
6839
7975
  .option("--status <keys>", "Comma-separated status keys (e.g. interested,meeting_booked). Cross-channel — filters email + LinkedIn + WhatsApp by the shared taxonomy.")
6840
7976
  .option("--since <iso>", "Only conversations whose last message is on/after this ISO date/timestamp. Cross-channel.")
6841
7977
  .option("--until <iso>", "Only conversations whose last message is on/before this ISO date/timestamp. Cross-channel.")
6842
- .option("--sequence-id <ids>", "Email only: comma-separated campaign (sequence) ids.")
7978
+ .option("--sequence-id <ids>", "Email only: comma-separated campaign (sequence) UUIDs (not slugs — get the id from `sequences get <slug>`).")
6843
7979
  .option("--provider <providers>", "Email only: comma-separated providers (google,microsoft).")
6844
7980
  .option("--domain <domains>", "Email only: comma-separated counterpart domains to include.")
6845
7981
  .option("--exclude-domain <domains>", "Email only: comma-separated counterpart domains to exclude.")
@@ -7300,11 +8436,12 @@ export function createProgram() {
7300
8436
  });
7301
8437
  }))
7302
8438
  .addCommand(new Command("stats")
7303
- .description("Cross-channel campaign analytics: outbound/inbound totals, heuristic reply rate by channel + campaign, status/sentiment breakdowns, response-time percentiles, top counterpart domains, and winning openers. Scope with --sequence-id, --channel, and a date range.")
8439
+ .description("Cross-channel campaign analytics: outbound/inbound totals, heuristic reply rate by channel + campaign, status/sentiment breakdowns, response-time percentiles, top counterpart domains, and winning openers. Scope with --sequence-id, --channel, and a date range. Defaults to the last 30 days; pass --all-time for full history.")
7304
8440
  .option("--sequence-id <ids>", "Comma-separated campaign (sequence) ids to scope to.")
7305
8441
  .option("--channel <channel>", "Channel: all (default), email, linkedin, or whatsapp.")
7306
- .option("--since <iso>", "Only messages sent at or after this ISO timestamp.")
8442
+ .option("--since <iso>", "Only messages sent at or after this ISO timestamp. Defaults to 30 days ago.")
7307
8443
  .option("--until <iso>", "Only messages sent before this ISO timestamp.")
8444
+ .option("--all-time", "Scan full message history instead of the default trailing 30 days. Ignored if --since or --until is also set.")
7308
8445
  .option("--json", "Print a JSON envelope.")
7309
8446
  .action(async (options) => {
7310
8447
  await handleAsyncAction("messages stats", options, () => {
@@ -7319,6 +8456,8 @@ export function createProgram() {
7319
8456
  if (value)
7320
8457
  params.set(key, value);
7321
8458
  }
8459
+ if (options.allTime)
8460
+ params.set("all_time", "true");
7322
8461
  const suffix = params.toString();
7323
8462
  return requestOxygen(`/api/cli/messages/stats${suffix ? `?${suffix}` : ""}`);
7324
8463
  });
@@ -7369,7 +8508,7 @@ export function createProgram() {
7369
8508
  .description("Create a draft multichannel sequence from a steps JSON file. Assign LinkedIn senders with --senders (required when the journey has LinkedIn steps); bind an Instantly email track with --email-*.")
7370
8509
  .requiredOption("--name <name>", "Human-readable sequence name.")
7371
8510
  .requiredOption("--slug <slug>", "Unique slug for the sequence.")
7372
- .requiredOption("--steps-file <path>", "Path to a JSON file: { \"steps\": [...] }. LinkedIn steps (invite | message | wait_for_connection | inmail), email steps (email_send | email_reply | email_enroll | email_move | email_stop), and control steps (wait | wait_for_signal | branch | stop), each with an `id`. A `branch` routes on signals (then/else) or the legacy connection_accepted/already_connected sugar (then_id/else_id).")
8511
+ .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_reply | email_enroll | email_move | email_stop), and control steps (wait | wait_for_signal | branch | stop), each with an `id`. Copy templates support {{column}} interpolation from the lead's row. Native email sends (email_send/email_reply) also expose three reserved sender variables from the sending mailbox: {{sender_name}} (mailbox display name), {{sender_first_name}} (its first word), and {{sender_email}} (the from address); a row column of the same name WINS on collision, and a missing display 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 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\": \"...\" } — subject_template + body_template are REQUIRED on email_send; 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).")
7373
8512
  .option("--channels <list>", "Comma-separated channels: linkedin,email,whatsapp. Defaults to the channels the journey touches.")
7374
8513
  .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.")
7375
8514
  .option("--phone-column-key <key>", "WhatsApp: row_values key holding each lead's phone number (else falls back to phone/phone_number/mobile).")
@@ -7390,7 +8529,11 @@ export function createProgram() {
7390
8529
  .option("--esp-matching <mode>", "Native-email ESP matching: 'off' (default) rotates mailboxes freely; 'prefer' biases toward a mailbox on the recipient's own provider; 'strict' requires a same-provider mailbox and defers the send when none exists.")
7391
8530
  .option("--sender-failover <mode>", "What happens when an enrollment's LinkedIn/WhatsApp sender goes unavailable: 'wait' (default) resumes when the sender recovers; 'rebind' moves UNTOUCHED enrollments (no thread, no pending invite, no lead binding) to the least-loaded healthy sender in the pool after ~5h of confirmed outage.")
7392
8531
  .option("--email-min-gap-minutes <n>", "Minimum minutes between two live emails from the SAME mailbox for this sequence (Instantly's 'time gap between emails'; integer 0-720, 0 = none). A humanization gap that only WIDENS the derived per-mailbox spacing — it never bypasses the daily caps or the send window.")
8532
+ .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.")
8533
+ .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 (max 50). 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).")
7393
8534
  .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.")
8535
+ .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.")
8536
+ .option("--no-exclude-contacted", "Turn the sequence-level exclude-contacted default back OFF (enrollments then exclude cross-campaign only when a call opts in).")
7394
8537
  .option("--json", "Print a JSON envelope.")
7395
8538
  .action(async (options) => {
7396
8539
  await handleAsyncAction("sequences create", options, () => {
@@ -7421,12 +8564,39 @@ export function createProgram() {
7421
8564
  },
7422
8565
  });
7423
8566
  });
8567
+ }))
8568
+ .addCommand(new Command("draft")
8569
+ .description("Draft an email outreach sequence with AI, grounded in the workspace Knowledge Graph (voice, positioning, campaign learnings) — Instantly's AI sequence generator, with provenance. PAID (one AI call, typically ~15 credits [low tier]; an under-cap call errors BEFORE spending and prints the live estimate) — requires --max-credits. Prints the drafted steps + the KG pages that grounded them; nothing is saved until you re-run with --create (which needs --name and --slug). Even created, it lands as a DRAFT — enrolling leads and starting it stay approval-gated.")
8570
+ .requiredOption("--goal <goal>", "Plain-language goal for the sequence (e.g. \"book demos with RevOps leaders at Series A SaaS\"). The trusted brief the copy is written to.")
8571
+ .option("--audience <audience>", "Who the sequence targets (e.g. \"RevOps leaders at 20-100 person B2B SaaS\"). Sharpens the Knowledge Graph retrieval.")
8572
+ .option("--table <id>", "Source table id whose column keys become the {{column|fallback}} personalization tokens the copy may use.")
8573
+ .option("--steps <n>", "How many emails in the journey (1-7). Defaults to 4.")
8574
+ .option("--variants <n>", "Copy variants per email for A/B testing (1-3). Defaults to 1.")
8575
+ .requiredOption("--max-credits <n>", "Credit cap for the AI call (required — this is a paid action).")
8576
+ .option("--create", "Save the draft as a DRAFT sequence (requires --name and --slug). Without this, the draft is only previewed.")
8577
+ .option("--name <name>", "Human-readable sequence name (required with --create).")
8578
+ .option("--slug <slug>", "Unique slug for the sequence (required with --create).")
8579
+ .option("--json", "Print a JSON envelope.")
8580
+ .action(async (options) => {
8581
+ try {
8582
+ const data = await requestOxygen("/api/cli/sequences/draft", {
8583
+ method: "POST",
8584
+ body: buildSequenceDraftBody(options),
8585
+ });
8586
+ emitSuccess("sequences draft", data, options);
8587
+ if (!options.json)
8588
+ writeSequenceDraftPreview(data);
8589
+ writeCreditsReceipt(data);
8590
+ }
8591
+ catch (error) {
8592
+ emitCliFailure("sequences draft", error);
8593
+ }
7424
8594
  }))
7425
8595
  .addCommand(new Command("update")
7426
8596
  .description("Update a DRAFT sequence's name, journey, channels, senders, email binding, or LinkedIn credit cap, plus the always-editable open/click tracking toggles (--[no-]tracking-opens / --[no-]tracking-clicks, default on). The journey is re-validated; the email binding stays editable only while the sequence is a draft. --max-credits is locked once the sequence has been started (re-run `sequences start`). Pass only the fields you want to change.")
7427
8597
  .argument("<sequence>", "Sequence id or slug.")
7428
8598
  .option("--name <name>", "New human-readable sequence name.")
7429
- .option("--steps-file <path>", "Path to a JSON file: { \"steps\": [...] } replacing the journey.")
8599
+ .option("--steps-file <path>", "Path to a JSON file: { \"steps\": [...] } replacing the journey. Copy supports {{column}} interpolation; native email steps also expose {{sender_name}} / {{sender_first_name}} / {{sender_email}} from the sending mailbox (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\").")
7430
8600
  .option("--channels <list>", "Comma-separated channels: linkedin,email,whatsapp.")
7431
8601
  .option("--whatsapp-cold-initiate", "WhatsApp: allow cold-initiating new chats (no prior conversation). Required to start a WhatsApp sequence live (draft-editable).")
7432
8602
  .option("--phone-column-key <key>", "WhatsApp: row_values key holding each lead's phone number (else falls back to phone/phone_number/mobile).")
@@ -7446,7 +8616,11 @@ export function createProgram() {
7446
8616
  .option("--esp-matching <mode>", "Native-email ESP matching: 'off' (default) rotates mailboxes freely; 'prefer' biases toward a mailbox on the recipient's own provider; 'strict' requires a same-provider mailbox and defers the send when none exists.")
7447
8617
  .option("--sender-failover <mode>", "What happens when an enrollment's LinkedIn/WhatsApp sender goes unavailable: 'wait' (default) resumes when the sender recovers; 'rebind' moves UNTOUCHED enrollments (no thread, no pending invite, no lead binding) to the least-loaded healthy sender in the pool after ~5h of confirmed outage.")
7448
8618
  .option("--email-min-gap-minutes <n>", "Minimum minutes between two live emails from the SAME mailbox for this sequence (Instantly's 'time gap between emails'; integer 0-720, 0 = none). A humanization gap that only WIDENS the derived per-mailbox spacing — it never bypasses the daily caps or the send window.")
8619
+ .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.")
8620
+ .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 (max 50). 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).")
7449
8621
  .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.")
8622
+ .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.")
8623
+ .option("--no-exclude-contacted", "Turn the sequence-level exclude-contacted default back OFF (enrollments then exclude cross-campaign only when a call opts in).")
7450
8624
  .option("--tracking-opens", "Turn open-pixel tracking ON for this sequence's native email sends (the default; injection still needs a verified tracking domain — see `oxygen domains tracking`).")
7451
8625
  .option("--no-tracking-opens", "Turn OFF the open pixel for this sequence's native email sends (a deliverability knob — a pixel is spam-filter surface).")
7452
8626
  .option("--tracking-clicks", "Turn click-link tracking ON for this sequence's native email sends (the default; same verified-tracking-domain gate as opens).")
@@ -7492,7 +8666,7 @@ export function createProgram() {
7492
8666
  body.email = email;
7493
8667
  }
7494
8668
  if (Object.keys(body).length === 0) {
7495
- throw new Error("Provide at least one field to update (--name, --steps-file, --channels, --senders, --email-*, --clear-email, --max-credits, --max-live-sends, --max-emails-per-mailbox-per-day, --send-window-file, --no-stop-on-bounce, or --[no-]tracking-opens / --[no-]tracking-clicks).");
8669
+ throw new Error("Provide at least one field to update (--name, --steps-file, --channels, --senders, --email-*, --clear-email, --max-credits, --max-live-sends, --max-emails-per-mailbox-per-day, --send-window-file, --no-stop-on-bounce, --[no-]exclude-contacted, or --[no-]tracking-opens / --[no-]tracking-clicks).");
7496
8670
  }
7497
8671
  return requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}`, {
7498
8672
  method: "PATCH",
@@ -7512,7 +8686,8 @@ export function createProgram() {
7512
8686
  .argument("<sequence>", "Sequence id or slug.")
7513
8687
  .option("--leads-file <path>", "Path to a JSON file: { \"leads\": [{ row_values: { email }, lead_name }] } for cold email, or { lead_provider_id, lead_name, table_row_id, row_values } for LinkedIn / existing rows. Exactly one of --leads-file or --from-table.")
7514
8688
  .option("--from-table", "Enroll every not-yet-enrolled row of the sequence's bound source table (up to 500 per run; re-run to continue). Exactly one of --leads-file or --from-table.")
7515
- .option("--exclude-contacted", "Also skip leads any OTHER active sequence is already contacting (cross-campaign exclusion). Off by default.")
8689
+ .option("--exclude-contacted", "Also skip leads any OTHER active sequence is already contacting (cross-campaign exclusion), for THIS enroll. Off by default; forces exclusion on even if the sequence's exclude_contacted default is off.")
8690
+ .option("--no-exclude-contacted", "Force-INCLUDE cross-campaign leads for this enroll even when the sequence's exclude_contacted default is on (per-call override).")
7516
8691
  .option("--suppress-list <ids>", "Per-call do-not-enroll lead provider ids dropped for this enroll only: a comma-separated list, or @<path> to a file of ids (comma/whitespace separated).")
7517
8692
  .option("--ignore-sender-bindings", "Enroll leads even when they are already owned by a sender account outside this sequence's pool (overrides the one-person-one-sender guarantee). Off by default.")
7518
8693
  .option("--json", "Print a JSON envelope.")
@@ -7527,7 +8702,9 @@ export function createProgram() {
7527
8702
  }
7528
8703
  const suppressList = parseSuppressListOption(options.suppressList);
7529
8704
  const sharedFlags = {
7530
- ...(options.excludeContacted ? { exclude_contacted: true } : {}),
8705
+ // Tri-state: --exclude-contacted (true) / --no-exclude-contacted (false)
8706
+ // / absent (undefined → the route falls back to the sequence's default).
8707
+ ...(options.excludeContacted === undefined ? {} : { exclude_contacted: options.excludeContacted }),
7531
8708
  ...(suppressList.length > 0 ? { suppress_list: suppressList } : {}),
7532
8709
  ...(options.ignoreSenderBindings ? { ignore_sender_bindings: true } : {}),
7533
8710
  };
@@ -7579,6 +8756,16 @@ export function createProgram() {
7579
8756
  .option("--json", "Print a JSON envelope.")
7580
8757
  .action(async (sequence, options) => {
7581
8758
  await handleSequenceSignalAction(sequence, options);
8759
+ }))
8760
+ .addCommand(new Command("duplicate")
8761
+ .description("Duplicate a sequence as a new DRAFT: copies the step definition, settings, channels, source table, and sender pool. Never copies enrollments, stats, or an Instantly email binding (re-bind with `sequences update` if the source used one; native email steps need no binding). Client-side composition of get + create — the copy starts fully inert until you `sequences start` it.")
8762
+ .argument("<sequence>", "Source sequence id or slug.")
8763
+ .requiredOption("--name <name>", "Name for the copy.")
8764
+ .option("--slug <slug>", "Slug for the copy. Defaults to the source slug + '-copy'.")
8765
+ .option("--no-senders", "Do not copy the source's sender pool onto the copy.")
8766
+ .option("--json", "Print a JSON envelope.")
8767
+ .action(async (sequence, options) => {
8768
+ await handleSequenceDuplicateAction(sequence, options);
7582
8769
  }))
7583
8770
  .addCommand(new Command("pause")
7584
8771
  .description("Pause an active sequence (stops new dispatches; enrollments resume on un-pause).")
@@ -7632,11 +8819,23 @@ export function createProgram() {
7632
8819
  .option("--json", "Print a JSON envelope.")
7633
8820
  .action(async (sequence, options) => {
7634
8821
  await handleAsyncAction("sequences stats", options, () => requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/stats`));
8822
+ }))
8823
+ .addCommand(new Command("events")
8824
+ .description("Chronological activity feed for a sequence (like Instantly's Sending Activities): sends, failures, deferrals, opens, clicks, replies, bounces, unsubscribes, and positive reply-status transitions, newest first. Keyset-paginated via --before.")
8825
+ .argument("<sequence>", "Sequence id or slug.")
8826
+ .option("--enrollment <id>", "Scope the feed to one enrollment id.")
8827
+ .option("--kind <list>", "Comma-separated event kinds to include (sent,failed,skipped,open,click,replied,bounced,unsubscribed,positive).")
8828
+ .option("--include-bots", "Include bot-attributed opens/clicks (excluded by default).")
8829
+ .option("--limit <n>", "Maximum events to return (1-200, default 50).")
8830
+ .option("--before <iso>", "Keyset cursor: only events strictly before this ISO timestamp (pass the previous page's next_before).")
8831
+ .option("--json", "Print a JSON envelope.")
8832
+ .action(async (sequence, options) => {
8833
+ await handleSequenceEventsAction(sequence, options);
7635
8834
  }))
7636
8835
  .addCommand(new Command("variants")
7637
- .description("A/B scoreboard for a sequence: per-step/per-variant sent, replied, reply rate, bounced, credits, plus the reversible auto-winner state (which variants are paused + evidence). Flags run the reply-metric auto-winner or manually override/reset a variant's pause.")
8836
+ .description("A/B scoreboard for a sequence: per-step/per-variant sent, replied, reply rate, bounced, credits, plus the reversible auto-winner state (which variants are paused + evidence). Flags run the auto-winner or manually override/reset a variant's pause.")
7638
8837
  .argument("<sequence>", "Sequence id or slug.")
7639
- .option("--auto-optimize", "Run the reply-metric auto-winner now: pause the statistically-losing variant(s) of any step carrying an auto_optimize config, once every variant clears its thresholds. Reversible.")
8838
+ .option("--auto-optimize", "Run the auto-winner now: pause the statistically-losing variant(s) of any step carrying an auto_optimize config, once every variant clears its thresholds, on that step's configured metric — reply (the recommended default), click, or open (click/open need native open/click tracking; opens are soft signals under Apple Mail Privacy Protection). Reversible.")
7640
8839
  .option("--pause <variant>", "Manually pause one variant (requires --step). Overrides the auto-winner.")
7641
8840
  .option("--activate <variant>", "Manually reactivate one paused variant (requires --step).")
7642
8841
  .option("--reset [variant]", "Clear an auto/manual decision: one variant (with --step), a whole step (--step only), or the entire sequence (no value). Reactivates the affected variants.")
@@ -7679,7 +8878,7 @@ export function createProgram() {
7679
8878
  });
7680
8879
  })));
7681
8880
  program.addCommand(new Command("suppressions")
7682
- .description("Org do-not-contact list (people/multichannel): lead provider ids the sequencer enroller always skips at plan time. list | add | remove. Consumes 0 credits.")
8881
+ .description("Do-not-contact + email blocklist. People/multichannel: lead provider ids the sequencer enroller skips at plan time (list | add | remove). Email blocklist (Instantly parity): bulk-import addresses + whole-domain blocks the native email dispatcher skips before sending (import | domains | remove-domain). Consumes 0 credits.")
7683
8882
  .addCommand(new Command("list")
7684
8883
  .description("List the org's do-not-contact suppressions, newest first. Filter by --reason or by --search (case-insensitive substring of the lead provider id).")
7685
8884
  .option("--reason <reason>", "Filter by reason: manual, replied, unsubscribed, bounced, do_not_contact, friends.")
@@ -7688,7 +8887,125 @@ export function createProgram() {
7688
8887
  .option("--offset <n>", "Pagination offset (0-based).")
7689
8888
  .option("--json", "Print a JSON envelope.")
7690
8889
  .action(async (options) => {
7691
- await handleAsyncAction("suppressions list", options, () => {
8890
+ await handleAsyncAction("suppressions list", options, () => {
8891
+ const params = new URLSearchParams();
8892
+ const reason = readOption(options.reason);
8893
+ if (reason)
8894
+ params.set("reason", reason);
8895
+ const search = readOption(options.search);
8896
+ if (search)
8897
+ params.set("search", search);
8898
+ const limit = readOption(options.limit);
8899
+ if (limit)
8900
+ params.set("limit", limit);
8901
+ const offset = readOption(options.offset);
8902
+ if (offset)
8903
+ params.set("offset", offset);
8904
+ const suffix = params.toString();
8905
+ return requestOxygen(`/api/cli/suppressions${suffix ? `?${suffix}` : ""}`);
8906
+ });
8907
+ }))
8908
+ .addCommand(new Command("add")
8909
+ .description("Add (or refresh) a lead provider id on the org do-not-contact list so the sequencer never enrolls them again. Idempotent. --reason defaults to manual.")
8910
+ .requiredOption("--lead <provider_id>", "The lead provider id (e.g. a LinkedIn ACo... id) to suppress.")
8911
+ .option("--reason <reason>", "Why: manual (default), replied, unsubscribed, bounced, do_not_contact, friends.")
8912
+ .option("--detail <text>", "Optional free-text note stored with the suppression.")
8913
+ .option("--json", "Print a JSON envelope.")
8914
+ .action(async (options) => {
8915
+ await handleAsyncAction("suppressions add", options, () => {
8916
+ const lead = readOption(options.lead);
8917
+ if (!lead)
8918
+ throw new Error("--lead is required.");
8919
+ const reason = readOption(options.reason);
8920
+ const detail = readOption(options.detail);
8921
+ return requestOxygen("/api/cli/suppressions", {
8922
+ method: "POST",
8923
+ body: {
8924
+ lead_provider_id: lead,
8925
+ ...(reason ? { reason } : {}),
8926
+ ...(detail ? { detail } : {}),
8927
+ },
8928
+ });
8929
+ });
8930
+ }))
8931
+ .addCommand(new Command("remove")
8932
+ .description("Remove a lead provider id from the org do-not-contact list (re-enable contact).")
8933
+ .requiredOption("--lead <provider_id>", "The lead provider id to un-suppress.")
8934
+ .option("--json", "Print a JSON envelope.")
8935
+ .action(async (options) => {
8936
+ await handleAsyncAction("suppressions remove", options, () => {
8937
+ const lead = readOption(options.lead);
8938
+ if (!lead)
8939
+ throw new Error("--lead is required.");
8940
+ return requestOxygen(`/api/cli/suppressions?lead_provider_id=${encodeURIComponent(lead)}`, {
8941
+ method: "DELETE",
8942
+ });
8943
+ });
8944
+ }))
8945
+ .addCommand(new Command("import")
8946
+ .description("Bulk-import an email blocklist from a file of emails and/or bare domains (newline / comma / whitespace separated, max 5000). An entry with '@' goes on the per-address list; a bare domain (e.g. acme.com) blocks the WHOLE domain. A domain block is a sharp tool, so --reason is REQUIRED when the file contains any domains; a pure-email import defaults to manual. Idempotent. Consumes 0 credits.")
8947
+ .requiredOption("--file <path>", "Path to a file of emails and/or bare domains, separated by newlines, commas, or whitespace.")
8948
+ .option("--reason <reason>", "Suppression reason. Emails: hard_bounce | complaint | unsubscribe | manual. Domains: manual | complaint | policy. Required when the file contains any domains.")
8949
+ .option("--source <text>", "Optional provenance note stored on every imported row (e.g. instantly_export).")
8950
+ .option("--json", "Print a JSON envelope.")
8951
+ .action(async (options) => {
8952
+ try {
8953
+ const file = readOption(options.file);
8954
+ if (!file)
8955
+ throw new Error("--file is required.");
8956
+ const text = readFileSync(resolve(file), "utf8");
8957
+ const entries = [
8958
+ ...new Set(text
8959
+ .split(/[\s,]+/)
8960
+ .map((entry) => entry.trim())
8961
+ .filter((entry) => entry.length > 0)),
8962
+ ];
8963
+ if (entries.length === 0)
8964
+ throw new Error(`No emails or domains found in ${file}.`);
8965
+ const reason = readOption(options.reason);
8966
+ const source = readOption(options.source);
8967
+ const data = await requestOxygen("/api/cli/suppressions/import", {
8968
+ method: "POST",
8969
+ body: {
8970
+ entries,
8971
+ ...(reason ? { reason } : {}),
8972
+ ...(source ? { source } : {}),
8973
+ },
8974
+ });
8975
+ if (options.json) {
8976
+ writeJson(success("suppressions import", data));
8977
+ }
8978
+ else {
8979
+ // Compact human view: the counts plus up to 10 rejected entries (the
8980
+ // full list is always in the --json envelope).
8981
+ const parsed = (data ?? {});
8982
+ const rejected = Array.isArray(parsed.rejected) ? parsed.rejected : [];
8983
+ writeJson({
8984
+ imported_emails: parsed.imported_emails ?? 0,
8985
+ imported_domains: parsed.imported_domains ?? 0,
8986
+ rejected_count: rejected.length,
8987
+ rejected: rejected.slice(0, 10),
8988
+ ...(rejected.length > 10
8989
+ ? { rejected_note: `+${rejected.length - 10} more (use --json for the full list)` }
8990
+ : {}),
8991
+ deep_link: parsed.deep_link,
8992
+ });
8993
+ }
8994
+ writeCreditsReceipt(data);
8995
+ }
8996
+ catch (error) {
8997
+ emitCliFailure("suppressions import", error);
8998
+ }
8999
+ }))
9000
+ .addCommand(new Command("domains")
9001
+ .description("List the org's whole-domain email blocks, newest first. Filter by --reason (manual | complaint | policy) or --search (case-insensitive substring of the domain).")
9002
+ .option("--reason <reason>", "Filter by reason: manual, complaint, policy.")
9003
+ .option("--search <text>", "Case-insensitive substring match on the domain.")
9004
+ .option("--limit <n>", "Maximum domain blocks to return (1-500; default 100).")
9005
+ .option("--offset <n>", "Pagination offset (0-based).")
9006
+ .option("--json", "Print a JSON envelope.")
9007
+ .action(async (options) => {
9008
+ await handleAsyncAction("suppressions domains", options, () => {
7692
9009
  const params = new URLSearchParams();
7693
9010
  const reason = readOption(options.reason);
7694
9011
  if (reason)
@@ -7703,45 +9020,40 @@ export function createProgram() {
7703
9020
  if (offset)
7704
9021
  params.set("offset", offset);
7705
9022
  const suffix = params.toString();
7706
- return requestOxygen(`/api/cli/suppressions${suffix ? `?${suffix}` : ""}`);
9023
+ return requestOxygen(`/api/cli/suppressions/domains${suffix ? `?${suffix}` : ""}`);
7707
9024
  });
7708
9025
  }))
7709
- .addCommand(new Command("add")
7710
- .description("Add (or refresh) a lead provider id on the org do-not-contact list so the sequencer never enrolls them again. Idempotent. --reason defaults to manual.")
7711
- .requiredOption("--lead <provider_id>", "The lead provider id (e.g. a LinkedIn ACo... id) to suppress.")
7712
- .option("--reason <reason>", "Why: manual (default), replied, unsubscribed, bounced, do_not_contact, friends.")
7713
- .option("--detail <text>", "Optional free-text note stored with the suppression.")
9026
+ .addCommand(new Command("remove-domain")
9027
+ .description("Remove a whole-domain email block (re-enable sending to the domain).")
9028
+ .argument("<domain>", "The bare domain to un-block (e.g. acme.com).")
7714
9029
  .option("--json", "Print a JSON envelope.")
7715
- .action(async (options) => {
7716
- await handleAsyncAction("suppressions add", options, () => {
7717
- const lead = readOption(options.lead);
7718
- if (!lead)
7719
- throw new Error("--lead is required.");
7720
- const reason = readOption(options.reason);
7721
- const detail = readOption(options.detail);
7722
- return requestOxygen("/api/cli/suppressions", {
7723
- method: "POST",
7724
- body: {
7725
- lead_provider_id: lead,
7726
- ...(reason ? { reason } : {}),
7727
- ...(detail ? { detail } : {}),
7728
- },
9030
+ .action(async (domain, options) => {
9031
+ await handleAsyncAction("suppressions remove-domain", options, () => {
9032
+ const value = domain?.trim();
9033
+ if (!value)
9034
+ throw new Error("A domain is required.");
9035
+ return requestOxygen(`/api/cli/suppressions/domains?domain=${encodeURIComponent(value)}`, {
9036
+ method: "DELETE",
7729
9037
  });
7730
9038
  });
7731
9039
  }))
7732
- .addCommand(new Command("remove")
7733
- .description("Remove a lead provider id from the org do-not-contact list (re-enable contact).")
7734
- .requiredOption("--lead <provider_id>", "The lead provider id to un-suppress.")
9040
+ .addCommand(new Command("remove-address")
9041
+ .description("Remove one blocked email ADDRESS from the do-not-contact ledger (re-enables sending to it). Addresses land here via bounces, unsubscribes, and `suppressions import`; whole-domain blocks have their own `remove-domain`.")
9042
+ .argument("<email>", "The blocked address, e.g. ada@acme.com.")
7735
9043
  .option("--json", "Print a JSON envelope.")
7736
- .action(async (options) => {
7737
- await handleAsyncAction("suppressions remove", options, () => {
7738
- const lead = readOption(options.lead);
7739
- if (!lead)
7740
- throw new Error("--lead is required.");
7741
- return requestOxygen(`/api/cli/suppressions?lead_provider_id=${encodeURIComponent(lead)}`, {
7742
- method: "DELETE",
7743
- });
7744
- });
9044
+ .action(async (email, options) => {
9045
+ try {
9046
+ const data = await requestOxygen(`/api/cli/suppressions/addresses?email=${encodeURIComponent(email)}`, { method: "DELETE" });
9047
+ if (options.json) {
9048
+ writeJson(success("suppressions remove-address", data));
9049
+ return;
9050
+ }
9051
+ const link = data.deepLink ?? data.web_url;
9052
+ process.stdout.write(`${data.removed ? "Removed" : "Not found:"} ${data.email ?? email}.${link ? ` ${link}` : ""}\n`);
9053
+ }
9054
+ catch (error) {
9055
+ emitCliFailure("suppressions remove-address", error);
9056
+ }
7745
9057
  })));
7746
9058
  program.addCommand(new Command("email")
7747
9059
  .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.")
@@ -7785,10 +9097,17 @@ export function createProgram() {
7785
9097
  });
7786
9098
  })));
7787
9099
  program.addCommand(new Command("managed-inboxes")
7788
- .description("Whitelabel sending inboxes bought through OXYGEN: subscribe a domain + N mailboxes (google/microsoft/azure) as a recurring MONTHLY subscription billed in USD to your Oxygen Email Infrastructure subscription, list/get your subscriptions, and cancel. Subscribe/cancel are approval-gated (preview → re-run with --approved --quote). The vendor is chosen for you; --vendor pins one.")
9100
+ .description("Whitelabel sending inboxes bought through OXYGEN: subscribe a domain + N mailboxes (google/microsoft/azure) as a recurring MONTHLY subscription billed in USD to your Oxygen Email Infrastructure subscription, list/get your subscriptions, verify that Oxygen/Stripe/the vendor agree, and cancel. Subscribe/cancel are approval-gated (preview → re-run with --approved --quote). The vendor is chosen for you; --vendor pins one.")
9101
+ .addCommand(new Command("verify")
9102
+ .description("Check that OXYGEN, STRIPE, and the VENDOR agree about what this org is buying. The truth about a managed inbox lives in three systems — what the customer asked for, what they are charged, and what is actually running — and a 200 from any one of them proves nothing. Reports every disagreement with WHO IS LOSING MONEY while it stands (customer_overbilled first, then oxygen_pays). Read-only, no writes, 0 Oxygen credits.")
9103
+ .option("--json", "Print a JSON envelope.")
9104
+ .action(async (options) => {
9105
+ await handleAsyncAction("managed-inboxes verify", options, () => requestOxygen("/api/cli/managed-inboxes/verify"));
9106
+ }))
7789
9107
  .addCommand(new Command("subscribe")
7790
9108
  .description("Subscribe a 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. Prices come from the vendor's LIVE rate card and LIVE domain price, and are refused if they sit below vendor cost. Fails closed until the vendor key + founder-signed per-platform pricing are configured.")
7791
- .requiredOption("--domain <domain>", "Sending domain to register + host the mailboxes (e.g. send.acme.com).")
9109
+ .argument("[domain]", "Sending domain to register + host the mailboxes (e.g. send.acme.com). May also be passed as --domain.")
9110
+ .option("--domain <domain>", "Sending domain (alternative to the positional argument).")
7792
9111
  .requiredOption("--provider <provider>", "Mailbox PLATFORM: google, microsoft, or azure. (google/microsoft cap at 5 mailboxes per domain; azure allows 100.)")
7793
9112
  .option("--vendor <vendor>", "Pin the vendor: inboxkit or cmr. Omit to let OXYGEN choose. A named vendor with no credential FAILS rather than falling back to another.")
7794
9113
  .option("--mailboxes <json>", "JSON array of mailboxes: [{\"username\",\"first_name\",\"last_name\"}].")
@@ -7801,9 +9120,12 @@ export function createProgram() {
7801
9120
  .option("--approved", "Place the order (requires --quote). Without it, a priced preview is returned.")
7802
9121
  .option("--quote <id>", "The quote_id from a fresh preview. Required with --approved.")
7803
9122
  .option("--json", "Print a JSON envelope.")
7804
- .action(async (options) => {
9123
+ .action(async (domainArg, options) => {
7805
9124
  await handleAsyncAction("managed-inboxes subscribe", options, () => {
7806
- const domain = readOption(options.domain);
9125
+ // The domain may arrive either way. `subscribe` took --domain while get/warmup/
9126
+ // cancel took a positional, so the same value had two spellings depending on the
9127
+ // verb — `get --domain x` simply failed. Both work everywhere now.
9128
+ const domain = readOption(domainArg) ?? readOption(options.domain);
7807
9129
  const provider = readOption(options.provider);
7808
9130
  if (!domain)
7809
9131
  throw new Error("--domain is required.");
@@ -7862,20 +9184,26 @@ export function createProgram() {
7862
9184
  }))
7863
9185
  .addCommand(new Command("get")
7864
9186
  .description("Get one managed inbox subscription by domain (vendor, platform, mailbox count, monthly USD price, vendor order id, lifecycle status, auto-renew, period end, internal billing status). Read-only, 0 Oxygen credits.")
7865
- .argument("<domain>", "The managed inbox domain (e.g. send.acme.com).")
9187
+ .argument("[domain]", "The managed inbox domain (e.g. send.acme.com). May also be passed as --domain.")
9188
+ .option("--domain <domain>", "The managed inbox domain (alternative to the positional argument).")
7866
9189
  .option("--json", "Print a JSON envelope.")
7867
- .action(async (domain, options) => {
7868
- await handleAsyncAction("managed-inboxes get", options, () => requestOxygen(`/api/cli/managed-inboxes/${encodeURIComponent(domain)}`));
9190
+ .action(async (domainArg, options) => {
9191
+ await handleAsyncAction("managed-inboxes get", options, () => {
9192
+ const domain = requireDomainArg(domainArg, options.domain);
9193
+ return requestOxygen(`/api/cli/managed-inboxes/${encodeURIComponent(domain)}`);
9194
+ });
7869
9195
  }))
7870
9196
  .addCommand(new Command("warmup")
7871
9197
  .description("Turn the warmup pool on or off for a managed domain's inboxes, after purchase. Warmup runs on the vendor's pool against mailboxes it provisioned, so it is only available on inboxes BOUGHT through Oxygen — an imported inbox you already owned must arrive already warmed. WITHOUT --approved this prints a priced preview; re-run with --approved to apply.")
7872
- .argument("<domain>", "The managed inbox domain (e.g. send.acme.com).")
9198
+ .argument("[domain]", "The managed inbox domain (e.g. send.acme.com). May also be passed as --domain.")
9199
+ .option("--domain <domain>", "The managed inbox domain (alternative to the positional argument).")
7873
9200
  .option("--enable", "Turn warmup on (a recurring monthly per-mailbox add-on).")
7874
9201
  .option("--disable", "Turn warmup off and stop billing for it.")
7875
9202
  .option("--approved", "Apply the change (otherwise a priced preview is returned).")
7876
9203
  .option("--json", "Print a JSON envelope.")
7877
- .action(async (domain, options) => {
9204
+ .action(async (domainArg, options) => {
7878
9205
  await handleAsyncAction("managed-inboxes warmup", options, () => {
9206
+ const domain = requireDomainArg(domainArg, options.domain);
7879
9207
  if (options.enable === options.disable) {
7880
9208
  throw new Error("Pass exactly one of --enable or --disable.");
7881
9209
  }
@@ -7891,17 +9219,21 @@ export function createProgram() {
7891
9219
  }))
7892
9220
  .addCommand(new Command("cancel")
7893
9221
  .description("Cancel a managed inbox subscription by domain (stops the recurring monthly charge and frees the domain at period end). WITHOUT --approved prints a preview; re-run with --approved to cancel. Cancelling is free.")
7894
- .argument("<domain>", "The managed inbox domain to cancel.")
9222
+ .argument("[domain]", "The managed inbox domain to cancel. May also be passed as --domain.")
9223
+ .option("--domain <domain>", "The managed inbox domain (alternative to the positional argument).")
7895
9224
  .option("--approved", "Actually cancel (otherwise a preview is returned).")
7896
9225
  .option("--json", "Print a JSON envelope.")
7897
- .action(async (domain, options) => {
7898
- await handleAsyncAction("managed-inboxes cancel", options, () => requestOxygen("/api/cli/managed-inboxes/cancel", {
7899
- method: "POST",
7900
- body: {
7901
- domain,
7902
- ...(options.approved ? { approved: true } : {}),
7903
- },
7904
- }));
9226
+ .action(async (domainArg, options) => {
9227
+ await handleAsyncAction("managed-inboxes cancel", options, () => {
9228
+ const domain = requireDomainArg(domainArg, options.domain);
9229
+ return requestOxygen("/api/cli/managed-inboxes/cancel", {
9230
+ method: "POST",
9231
+ body: {
9232
+ domain,
9233
+ ...(options.approved ? { approved: true } : {}),
9234
+ },
9235
+ });
9236
+ });
7905
9237
  })));
7906
9238
  program.addCommand(new Command("mailboxes")
7907
9239
  .description("Native email sending pool: register/refresh Google/Microsoft mailboxes a campaign rotates over, pause/disable inboxes, and delegate warmup to Instantly (BYOK — Instantly bills your account, 0 Oxygen credits).")
@@ -8186,27 +9518,6 @@ export function createProgram() {
8186
9518
  deep_link: "https://oxygen-agent.com/billing",
8187
9519
  },
8188
9520
  }));
8189
- }))
8190
- .addCommand(new Command("orders")
8191
- .description("List the org's managed mailbox provisioning orders (newest first).")
8192
- .option("--status <status>", "Comma-separated status filter (e.g. provisioning,warming).")
8193
- .option("--json", "Print a JSON envelope.")
8194
- .action(async (options) => {
8195
- await handleAsyncAction("mailboxes orders", options, () => {
8196
- const params = new URLSearchParams();
8197
- const status = readOption(options.status);
8198
- if (status)
8199
- params.set("status", status);
8200
- const suffix = params.toString();
8201
- return requestOxygen(`/api/cli/mailboxes/orders${suffix ? `?${suffix}` : ""}`);
8202
- });
8203
- }))
8204
- .addCommand(new Command("order-status")
8205
- .description("Show one managed mailbox order's status (provider, domains, status, quote, held credits, Zapmail refs).")
8206
- .argument("<order>", "Mailbox order id.")
8207
- .option("--json", "Print a JSON envelope.")
8208
- .action(async (order, options) => {
8209
- await handleAsyncAction("mailboxes order-status", options, () => requestOxygen(`/api/cli/mailboxes/orders/${encodeURIComponent(order)}`));
8210
9521
  })));
8211
9522
  program.addCommand(new Command("deliverability")
8212
9523
  .description("External email deliverability: fleet reputation health and DIRECTIONAL inbox-placement (spam) tests via EmailGuard or Zapmail. Placement tests are approval-gated paid runs (managed bills Oxygen credits; BYOK = 0 Oxygen credits — Zapmail BYOK bills your Zapmail wallet ~$2/test); fail closed with 409 when no health provider is connected.")
@@ -8572,7 +9883,11 @@ export function createProgram() {
8572
9883
  .description("List workflow automations.")
8573
9884
  .option("--json", "Print a JSON envelope.")
8574
9885
  .action(async (options) => {
8575
- await handleAsyncAction("workflows list", options, () => requestOxygen("/api/cli/workflows"));
9886
+ await handleAsyncAction("workflows list", options, async () => {
9887
+ const data = await requestOxygen("/api/cli/workflows");
9888
+ writeDisabledWorkflowNotices(data);
9889
+ return data;
9890
+ });
8576
9891
  }))
8577
9892
  .addCommand(new Command("get")
8578
9893
  .description("Get one workflow automation.")
@@ -8580,10 +9895,14 @@ export function createProgram() {
8580
9895
  .option("--include-bundle", "Include durable recipe bundles in JSON output.")
8581
9896
  .option("--json", "Print a JSON envelope.")
8582
9897
  .action(async (workflow, options) => {
8583
- await handleAsyncAction("workflows get", options, async () => prepareWorkflowCliOutput(await requestOxygen("/api/cli/workflows/get", {
8584
- method: "POST",
8585
- body: { workflow },
8586
- }), options));
9898
+ await handleAsyncAction("workflows get", options, async () => {
9899
+ const data = await requestOxygen("/api/cli/workflows/get", {
9900
+ method: "POST",
9901
+ body: { workflow },
9902
+ });
9903
+ writeDisabledWorkflowNotices(data);
9904
+ return prepareWorkflowCliOutput(data, options);
9905
+ });
8587
9906
  }))
8588
9907
  .addCommand(new Command("duplicate")
8589
9908
  .description("Duplicate a workflow automation as a disabled copy by default.")
@@ -8780,31 +10099,39 @@ export function createProgram() {
8780
10099
  .addCommand(new Command("enable")
8781
10100
  .description("Enable a workflow automation and its current trigger. Prints the schedule's projected automation-action burn; refuses a cron cadence the plan's monthly allowance cannot sustain.")
8782
10101
  .argument("<workflow>", "Workflow id, slug, or name.")
10102
+ .option("--reason <text>", "Why you are enabling it. Recorded on the workflow and shown to the workspace.")
8783
10103
  .option("--json", "Print a JSON envelope.")
8784
10104
  .action(async (workflow, options) => {
8785
10105
  await handleAsyncAction("workflows enable", options, async () => {
8786
10106
  const data = await requestOxygen("/api/cli/workflows/enable", {
8787
10107
  method: "POST",
8788
- body: { workflow },
10108
+ body: {
10109
+ workflow,
10110
+ ...(readOption(options.reason) ? { reason: readOption(options.reason) } : {}),
10111
+ },
8789
10112
  });
8790
10113
  writeAutomationProjection(data);
8791
10114
  return data;
8792
10115
  });
8793
10116
  }))
8794
10117
  .addCommand(new Command("disable")
8795
- .description("Disable a workflow automation and its current trigger.")
10118
+ .description("Disable a workflow automation and its current trigger. Records who disabled it, from where, and why — the workspace sees it on the workflow.")
8796
10119
  .argument("<workflow>", "Workflow id, slug, or name.")
10120
+ .option("--reason <text>", "Why you are disabling it. Recorded on the workflow and shown to the workspace.")
8797
10121
  .option("--json", "Print a JSON envelope.")
8798
10122
  .action(async (workflow, options) => {
8799
10123
  await handleAsyncAction("workflows disable", options, () => requestOxygen("/api/cli/workflows/disable", {
8800
10124
  method: "POST",
8801
- body: { workflow },
10125
+ body: {
10126
+ workflow,
10127
+ ...(readOption(options.reason) ? { reason: readOption(options.reason) } : {}),
10128
+ },
8802
10129
  }));
8803
10130
  }))
8804
10131
  .addCommand(new Command("mcp")
8805
10132
  .description("Publish workflows as dynamic MCP tools (oxygen_workflow_<slug>) — the 'Clay Functions' pattern.")
8806
10133
  .addCommand(new Command("enable")
8807
- .description("Publish an active workflow as a callable MCP tool so any MCP client can run it.")
10134
+ .description("Publish an active workflow as a callable MCP tool so any MCP client can run it. The published tool appears to MCP clients as oxygen_workflow_<slug>.")
8808
10135
  .argument("<workflow>", "Workflow id, slug, or name.")
8809
10136
  .option("--display-name <name>", "Display name for the published MCP tool.")
8810
10137
  .option("--description <text>", "Description for the published MCP tool.")
@@ -8820,15 +10147,22 @@ export function createProgram() {
8820
10147
  ...(readOption(options.description) ? { description: readOption(options.description) } : {}),
8821
10148
  },
8822
10149
  });
8823
- // MCP tool names are capped at 64 chars. Warn (non-fatal) when the
8824
- // resolved slug pushes oxygen_workflow_<slug> over the limit: the
8825
- // flag is set but the tool is skipped in tools/list until the slug
8826
- // is shortened. Uses the server-resolved slug, not the raw ref.
10150
+ // Surface the resolved MCP tool name. Prefer the server's, fall
10151
+ // back to the canonical builder on the server-resolved slug.
8827
10152
  const slug = data?.workflow?.slug;
8828
- if (typeof slug === "string") {
8829
- const toolName = `oxygen_workflow_${slug}`;
8830
- if (toolName.length > 64) {
8831
- process.stderr.write(`warning: "${toolName}" is ${toolName.length} chars (limit 64) — the workflow is enabled for MCP but will not appear in tools/list until its slug is shortened.\n`);
10153
+ const toolName = data?.tool_name
10154
+ ?? (typeof slug === "string" ? workflowMcpToolName(slug) : null);
10155
+ if (toolName) {
10156
+ // MCP tool names are capped at MAX_MCP_TOOL_NAME_LENGTH. Over the
10157
+ // limit the flag is set but the tool is SKIPPED in tools/list until
10158
+ // the slug is shortened — always warn (stderr, JSON stays clean).
10159
+ // Under it, confirm the callable name in human output only (the
10160
+ // JSON envelope already carries tool_name for --json callers).
10161
+ if (toolName.length > MAX_MCP_TOOL_NAME_LENGTH) {
10162
+ process.stderr.write(`warning: "${toolName}" is ${toolName.length} chars (limit ${MAX_MCP_TOOL_NAME_LENGTH}) — the workflow is enabled for MCP but will not appear in tools/list until its slug is shortened.\n`);
10163
+ }
10164
+ else if (!options.json) {
10165
+ process.stderr.write(`Published as MCP tool ${toolName} — call it from any MCP client.\n`);
8832
10166
  }
8833
10167
  }
8834
10168
  return data;
@@ -9337,11 +10671,25 @@ function waitForWorkflowRun(runId, options) {
9337
10671
  const runUrl = readRecordString(latestEnvelope, "runUrl");
9338
10672
  const webUrl = readRecordString(latestEnvelope, "web_url");
9339
10673
  const deepLink = readRecordString(latestEnvelope, "deepLink");
10674
+ // `waiting` stops the poll but the run is NOT over — it resumes on its own
10675
+ // at ready_at. Say so, and say when, or the caller reads `terminal: true`
10676
+ // and concludes the run died.
10677
+ const parked = status === "waiting";
10678
+ const resumeAt = parked ? readRecordString(run, "readyAt") ?? readRecordString(run, "ready_at") : null;
9340
10679
  return {
9341
10680
  run,
9342
10681
  workflowRunId: readRecordString(run, "id") ?? runId,
9343
10682
  status,
9344
- terminal: true,
10683
+ terminal: !parked,
10684
+ ...(parked
10685
+ ? {
10686
+ parked: true,
10687
+ ...(resumeAt ? { resumes_at: resumeAt } : {}),
10688
+ message: resumeAt
10689
+ ? `Run is parked until ${resumeAt} (a provider quota or a retry backoff). It resumes automatically — no action needed.`
10690
+ : "Run is parked and resumes automatically — no action needed.",
10691
+ }
10692
+ : {}),
9345
10693
  polls,
9346
10694
  elapsedMs,
9347
10695
  ...(workflowUrl ? { workflowUrl } : {}),
@@ -9442,10 +10790,18 @@ function normalizeWorkflowRunErrors(value) {
9442
10790
  return output;
9443
10791
  }
9444
10792
  function isTerminalWorkflowRunStatus(status) {
10793
+ // Terminal here means "stop polling", not "the run is over".
10794
+ //
9445
10795
  // 'awaiting_approval' pauses the run indefinitely for a human decision (lease
9446
10796
  // cleared, excluded from claim + lease sweep), so it must stop the tail and
9447
10797
  // surface the approval rather than poll forever.
9448
- return status === "completed" || status === "failed" || status === "canceled" || status === "awaiting_approval";
10798
+ //
10799
+ // 'waiting' is the same shape with a clock instead of a human: the run is
10800
+ // parked until `ready_at` — typically a provider's own quota reset, which can
10801
+ // be ~20 hours out. Polling that is pointless; the tail stops and reports the
10802
+ // resume time (see readWorkflowRunPauseReason).
10803
+ return status === "completed" || status === "failed" || status === "canceled"
10804
+ || status === "awaiting_approval" || status === "waiting";
9449
10805
  }
9450
10806
  function tableWebhookListPath(options) {
9451
10807
  const params = new URLSearchParams();
@@ -9590,6 +10946,447 @@ function applyAiColumnConfig(definition, options) {
9590
10946
  }
9591
10947
  return definition;
9592
10948
  }
10949
+ // Parse a `--bind-map identity=column,identity2=column2` value into an
10950
+ // inputMapping of column refs. Server-side normalizeBindColumnDefinition also
10951
+ // accepts a bare column-key string, but we emit the explicit ref shape so the
10952
+ // wire payload is self-describing. Full JSON control stays available via
10953
+ // --definition-json.
10954
+ function parseBindMapOption(value) {
10955
+ const mapping = {};
10956
+ for (const entry of readCsvOption(value)) {
10957
+ const separator = entry.indexOf("=");
10958
+ if (separator <= 0 || separator === entry.length - 1) {
10959
+ throw new OxygenError("invalid_bind_map", "--bind-map entries must be identity=column pairs, e.g. domain=website,linkedin_url=li.", { details: { entry }, exitCode: 1 });
10960
+ }
10961
+ const identity = entry.slice(0, separator).trim();
10962
+ const columnKey = entry.slice(separator + 1).trim();
10963
+ mapping[identity] = { type: "column", columnKey };
10964
+ }
10965
+ return mapping;
10966
+ }
10967
+ // `columns add` bind sugar: assemble the { version, object, inputMapping,
10968
+ // onNoMatch } definition from --bind-object / --bind-map / --bind-create. The API
10969
+ // column normalizer does all validation (identity slugs, ref shapes); the CLI only
10970
+ // marshals. --definition-json remains the escape hatch for createValues /
10971
+ // minConfidence / runCondition.
10972
+ function applyBindColumnConfig(definition, options) {
10973
+ definition.version = 1;
10974
+ const object = readOption(options.bindObject);
10975
+ if (object)
10976
+ definition.object = object;
10977
+ const mapping = parseBindMapOption(options.bindMap);
10978
+ if (Object.keys(mapping).length > 0) {
10979
+ const existing = isRecord(definition.inputMapping) ? definition.inputMapping : {};
10980
+ definition.inputMapping = { ...existing, ...mapping };
10981
+ }
10982
+ if (options.bindCreate)
10983
+ definition.onNoMatch = "create";
10984
+ return definition;
10985
+ }
10986
+ const LOOKUP_NORMALIZE_ALIASES = {
10987
+ exact: "exact",
10988
+ "lower-trim": "lower_trim",
10989
+ lower_trim: "lower_trim",
10990
+ email: "email_v1",
10991
+ domain: "domain_v1",
10992
+ linkedin: "linkedin_url_v1",
10993
+ };
10994
+ const LOOKUP_MODE_ALIASES = {
10995
+ "first-match": "first_match",
10996
+ first_match: "first_match",
10997
+ count: "count",
10998
+ exists: "exists",
10999
+ aggregate: "aggregate",
11000
+ };
11001
+ // Parse `--lookup-match localColumn=sourceColumn` into its two halves (LHS is the
11002
+ // working-row column, RHS the source table's join column).
11003
+ function parseLookupMatchOption(value) {
11004
+ const trimmed = readOption(value);
11005
+ if (!trimmed)
11006
+ return null;
11007
+ const separator = trimmed.indexOf("=");
11008
+ if (separator <= 0 || separator === trimmed.length - 1) {
11009
+ throw new OxygenError("invalid_lookup_match", "--lookup-match must be localColumn=sourceColumn, e.g. company_domain=domain.", { details: { value: trimmed }, exitCode: 1 });
11010
+ }
11011
+ return { localColumn: trimmed.slice(0, separator).trim(), sourceColumn: trimmed.slice(separator + 1).trim() };
11012
+ }
11013
+ function parseLookupNormalizeOption(value) {
11014
+ const raw = readOption(value);
11015
+ if (!raw)
11016
+ return null;
11017
+ const mapped = LOOKUP_NORMALIZE_ALIASES[raw.trim().toLowerCase()];
11018
+ if (!mapped) {
11019
+ throw new OxygenError("invalid_lookup_normalize", "--lookup-normalize must be exact, lower-trim, email, domain, or linkedin.", { details: { value: raw }, exitCode: 1 });
11020
+ }
11021
+ return mapped;
11022
+ }
11023
+ function parseLookupModeOption(value) {
11024
+ const raw = readOption(value);
11025
+ if (!raw)
11026
+ return null;
11027
+ const mapped = LOOKUP_MODE_ALIASES[raw.trim().toLowerCase()];
11028
+ if (!mapped) {
11029
+ throw new OxygenError("invalid_lookup_mode", "--lookup-mode must be first-match, count, exists, or aggregate.", { details: { value: raw }, exitCode: 1 });
11030
+ }
11031
+ return mapped;
11032
+ }
11033
+ function parseLookupOrderOption(value) {
11034
+ const raw = readOption(value);
11035
+ if (!raw)
11036
+ return null;
11037
+ const separator = raw.indexOf(":");
11038
+ const column = (separator === -1 ? raw : raw.slice(0, separator)).trim();
11039
+ const directionRaw = (separator === -1 ? "asc" : raw.slice(separator + 1)).trim().toLowerCase();
11040
+ if (!column || (directionRaw !== "asc" && directionRaw !== "desc")) {
11041
+ throw new OxygenError("invalid_lookup_order", "--lookup-order must be column or column:asc|desc, e.g. created_at:desc.", { details: { value: raw }, exitCode: 1 });
11042
+ }
11043
+ return { column, direction: directionRaw };
11044
+ }
11045
+ function parseLookupAggregateOption(value) {
11046
+ const raw = readOption(value);
11047
+ if (!raw)
11048
+ return null;
11049
+ const separator = raw.indexOf(":");
11050
+ if (separator <= 0 || separator === raw.length - 1) {
11051
+ throw new OxygenError("invalid_lookup_aggregate", "--lookup-aggregate must be fn:column, e.g. sum:amount.", { details: { value: raw }, exitCode: 1 });
11052
+ }
11053
+ const fn = raw.slice(0, separator).trim().toLowerCase();
11054
+ const column = raw.slice(separator + 1).trim();
11055
+ if (fn !== "sum" && fn !== "avg" && fn !== "min" && fn !== "max") {
11056
+ throw new OxygenError("invalid_lookup_aggregate", "--lookup-aggregate fn must be sum, avg, min, or max.", { details: { value: raw }, exitCode: 1 });
11057
+ }
11058
+ return { fn, column };
11059
+ }
11060
+ // `columns add` lookup sugar: assemble the { version, sourceTable, match,
11061
+ // inputMapping, mode, returnColumns, orderBy, aggregate } definition from the
11062
+ // --lookup-* flags. The API column normalizer does all validation (mode-specific
11063
+ // requirements, self-lookup, ref shapes); the CLI only marshals. --definition-json
11064
+ // remains the escape hatch for runCondition and literal match_value refs.
11065
+ function applyLookupColumnConfig(definition, options) {
11066
+ definition.version = 1;
11067
+ const sourceTable = readOption(options.lookupTable);
11068
+ if (sourceTable)
11069
+ definition.sourceTable = sourceTable;
11070
+ const match = parseLookupMatchOption(options.lookupMatch);
11071
+ const normalization = parseLookupNormalizeOption(options.lookupNormalize);
11072
+ if (match || normalization) {
11073
+ const existingMatch = isRecord(definition.match) ? definition.match : {};
11074
+ definition.match = {
11075
+ ...existingMatch,
11076
+ ...(match ? { sourceColumn: match.sourceColumn } : {}),
11077
+ ...(normalization ? { normalization } : {}),
11078
+ };
11079
+ }
11080
+ if (match) {
11081
+ definition.inputMapping = { match_value: { type: "column", columnKey: match.localColumn } };
11082
+ }
11083
+ const mode = parseLookupModeOption(options.lookupMode);
11084
+ if (mode)
11085
+ definition.mode = mode;
11086
+ const returnColumns = readCsvOption(options.lookupReturn);
11087
+ if (returnColumns.length > 0)
11088
+ definition.returnColumns = returnColumns;
11089
+ const orderBy = parseLookupOrderOption(options.lookupOrder);
11090
+ if (orderBy)
11091
+ definition.orderBy = orderBy;
11092
+ const aggregate = parseLookupAggregateOption(options.lookupAggregate);
11093
+ if (aggregate)
11094
+ definition.aggregate = aggregate;
11095
+ return definition;
11096
+ }
11097
+ // Parse a `tables promote --map` value into the wire mappings [{column, attribute}].
11098
+ // Accepts the CSV pair form `column=attribute,column2=attribute2` (LHS is the working
11099
+ // column, RHS the CRM attribute — the reverse of --bind-map's identity=column), a
11100
+ // JSON array of {column, attribute}, or a JSON object { column: attribute }.
11101
+ function parsePromoteMapOption(value) {
11102
+ const trimmed = readOption(value);
11103
+ if (!trimmed) {
11104
+ throw new OxygenError("invalid_promote_map", "--map is required: column=attribute pairs or a JSON array/object.", { exitCode: 1 });
11105
+ }
11106
+ if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
11107
+ let parsed;
11108
+ try {
11109
+ parsed = JSON.parse(trimmed);
11110
+ }
11111
+ catch {
11112
+ throw new OxygenError("invalid_promote_map", "--map JSON is not valid JSON.", { details: { value: trimmed }, exitCode: 1 });
11113
+ }
11114
+ const entries = Array.isArray(parsed)
11115
+ ? parsed.map((entry) => {
11116
+ const record = isRecord(entry) ? entry : {};
11117
+ return {
11118
+ column: typeof record.column === "string" ? record.column.trim() : "",
11119
+ attribute: typeof record.attribute === "string" ? record.attribute.trim() : "",
11120
+ };
11121
+ })
11122
+ : isRecord(parsed)
11123
+ ? Object.entries(parsed).map(([column, attribute]) => ({ column: column.trim(), attribute: String(attribute).trim() }))
11124
+ : [];
11125
+ const mappings = entries.filter((mapping) => mapping.column && mapping.attribute);
11126
+ if (mappings.length === 0) {
11127
+ throw new OxygenError("invalid_promote_map", "--map must contain at least one column-to-attribute mapping.", { exitCode: 1 });
11128
+ }
11129
+ return mappings;
11130
+ }
11131
+ const mappings = [];
11132
+ for (const entry of readCsvOption(value)) {
11133
+ const separator = entry.indexOf("=");
11134
+ if (separator <= 0 || separator === entry.length - 1) {
11135
+ throw new OxygenError("invalid_promote_map", "--map entries must be column=attribute pairs, e.g. job_title=job_title,seniority=seniority.", { details: { entry }, exitCode: 1 });
11136
+ }
11137
+ mappings.push({ column: entry.slice(0, separator).trim(), attribute: entry.slice(separator + 1).trim() });
11138
+ }
11139
+ if (mappings.length === 0) {
11140
+ throw new OxygenError("invalid_promote_map", "--map must contain at least one column-to-attribute mapping.", { exitCode: 1 });
11141
+ }
11142
+ return mappings;
11143
+ }
11144
+ // Map the hyphenated CLI policy flag to the wire enum.
11145
+ function normalizePromotePolicy(value) {
11146
+ const normalized = (readOption(value) ?? "fill-empty-only").replace(/-/g, "_");
11147
+ if (normalized === "fill_empty_only" || normalized === "overwrite" || normalized === "skip_conflicts") {
11148
+ return normalized;
11149
+ }
11150
+ throw new OxygenError("invalid_promote_policy", "--policy must be fill-empty-only, overwrite, or skip-conflicts.", {
11151
+ details: { policy: value },
11152
+ exitCode: 1,
11153
+ });
11154
+ }
11155
+ // Assemble the `tables send` request: --map pairs (headline sugar) become
11156
+ // column-copy mapping entries; --mapping JSON is the literal/path escape hatch;
11157
+ // --automap asks the server to resolve the mapping (echoed back). Exactly one
11158
+ // mapping source must be chosen, locally, so the error names CLI flags instead
11159
+ // of API fields.
11160
+ function buildTablesSendRequest(source, target, options) {
11161
+ const mapping = parseTablesSendMapping(options);
11162
+ if (mapping && options.automap) {
11163
+ throw new OxygenError("invalid_send", "Pass either a mapping (--map/--mapping) or --automap, not both.", { exitCode: 1 });
11164
+ }
11165
+ if (!mapping && !options.automap) {
11166
+ throw new OxygenError("invalid_send", "Pass --map target=source pairs, --mapping <json>, or --automap.", { exitCode: 1 });
11167
+ }
11168
+ const mode = normalizeTablesSendMode(options.mode);
11169
+ const upsertKey = readOption(options.upsertKey);
11170
+ if (mode === "upsert" && !upsertKey) {
11171
+ throw new OxygenError("invalid_send", "--mode upsert requires --upsert-key <target column>.", { exitCode: 1 });
11172
+ }
11173
+ if (mode === "insert" && upsertKey) {
11174
+ throw new OxygenError("invalid_send", "--upsert-key is only valid with --mode upsert.", { exitCode: 1 });
11175
+ }
11176
+ const flattenColumn = readOption(options.flatten);
11177
+ const flattenPath = readOption(options.flattenPath);
11178
+ if (flattenPath && !flattenColumn) {
11179
+ throw new OxygenError("invalid_send", "--flatten-path requires --flatten <column>.", { exitCode: 1 });
11180
+ }
11181
+ const filters = parseTablesSendFilters(options.filter);
11182
+ const pageSize = readPositiveInt(options.pageSize);
11183
+ return {
11184
+ source,
11185
+ target,
11186
+ ...(mapping ? { mapping } : {}),
11187
+ ...(options.automap ? { automap: true } : {}),
11188
+ ...(flattenColumn ? { flatten: { column: flattenColumn, ...(flattenPath ? { path: flattenPath } : {}) } } : {}),
11189
+ ...(filters ? { filters } : {}),
11190
+ mode,
11191
+ ...(upsertKey ? { upsert_key: upsertKey } : {}),
11192
+ ...(pageSize ? { page_size: pageSize } : {}),
11193
+ ...(options.dryRun ? { dry_run: true } : {}),
11194
+ };
11195
+ }
11196
+ function parseTablesSendMapping(options) {
11197
+ const pairs = (options.map ?? []).flatMap((entry) => readCsvOption(entry));
11198
+ const mappingJson = readOption(options.mapping);
11199
+ if (pairs.length > 0 && mappingJson) {
11200
+ throw new OxygenError("invalid_send", "Pass either --map pairs or --mapping <json>, not both.", { exitCode: 1 });
11201
+ }
11202
+ if (mappingJson) {
11203
+ let parsed;
11204
+ try {
11205
+ parsed = JSON.parse(mappingJson);
11206
+ }
11207
+ catch {
11208
+ throw new OxygenError("invalid_send", "--mapping is not valid JSON.", { details: { mapping: mappingJson }, exitCode: 1 });
11209
+ }
11210
+ if (!Array.isArray(parsed) || parsed.length === 0 || !parsed.every(isRecord)) {
11211
+ throw new OxygenError("invalid_send", "--mapping must be a non-empty JSON array of { target, source, path? } entries.", { exitCode: 1 });
11212
+ }
11213
+ return parsed;
11214
+ }
11215
+ if (pairs.length === 0)
11216
+ return null;
11217
+ return pairs.map((entry) => {
11218
+ const separator = entry.indexOf("=");
11219
+ if (separator <= 0 || separator === entry.length - 1) {
11220
+ throw new OxygenError("invalid_send", "--map entries must be target=source column pairs, e.g. company=company_name,website=domain.", { details: { entry }, exitCode: 1 });
11221
+ }
11222
+ return {
11223
+ target: entry.slice(0, separator).trim(),
11224
+ source: { type: "column", key: entry.slice(separator + 1).trim() },
11225
+ };
11226
+ });
11227
+ }
11228
+ function parseTablesSendFilters(value) {
11229
+ const raw = readOption(value);
11230
+ if (!raw)
11231
+ return null;
11232
+ let parsed;
11233
+ try {
11234
+ parsed = JSON.parse(raw);
11235
+ }
11236
+ catch {
11237
+ throw new OxygenError("invalid_send", "--filter is not valid JSON.", { details: { filter: raw }, exitCode: 1 });
11238
+ }
11239
+ const entries = Array.isArray(parsed) ? parsed : [parsed];
11240
+ if (entries.length === 0 || !entries.every(isRecord)) {
11241
+ throw new OxygenError("invalid_send", "--filter must be a JSON array of { column, op, value? } objects.", { exitCode: 1 });
11242
+ }
11243
+ return entries;
11244
+ }
11245
+ function normalizeTablesSendMode(value) {
11246
+ const normalized = (readOption(value) ?? "insert").toLowerCase();
11247
+ if (normalized === "insert" || normalized === "upsert")
11248
+ return normalized;
11249
+ throw new OxygenError("invalid_send", "--mode must be insert or upsert.", {
11250
+ details: { mode: value },
11251
+ exitCode: 1,
11252
+ });
11253
+ }
11254
+ // After a live send starts, point the terminal at the durable run's inspection
11255
+ // surfaces (stderr, so the machine-read stdout envelope stays clean).
11256
+ function writeTablesSendHint(data) {
11257
+ if (!isRecord(data) || typeof data.ingestion_run_id !== "string")
11258
+ return;
11259
+ const runUrl = typeof data.run_web_url === "string" ? ` (or open ${data.run_web_url})` : "";
11260
+ process.stderr.write(`hint: watch the copy with oxygen table-ingestions wait ${data.ingestion_run_id}${runUrl}\n`);
11261
+ }
11262
+ // Route `tables dedupe` to the preview / apply / cross-check API by flag: --against
11263
+ // switches to the read-only cross-table check (rejecting --apply); --apply targets
11264
+ // the destructive delete (approved only when --approved is set); otherwise preview.
11265
+ function buildDedupeRequest(table, options) {
11266
+ const onColumns = readCsvOption(options.on);
11267
+ if (onColumns.length === 0) {
11268
+ throw new OxygenError("invalid_dedupe", "--on requires at least one column.", { exitCode: 1 });
11269
+ }
11270
+ const normalizer = normalizeDedupeNormalizer(options.normalize);
11271
+ const scanLimit = readPositiveInt(options.scanLimit);
11272
+ const against = readOption(options.against);
11273
+ if (against) {
11274
+ if (options.apply) {
11275
+ throw new OxygenError("invalid_dedupe", "--apply cannot be combined with --against; the cross-table check is read-only.", { exitCode: 1 });
11276
+ }
11277
+ const againstColumn = readOption(options.againstColumn);
11278
+ if (!againstColumn) {
11279
+ throw new OxygenError("invalid_dedupe", "--against-column is required with --against.", { exitCode: 1 });
11280
+ }
11281
+ if (onColumns.length > 1) {
11282
+ throw new OxygenError("invalid_dedupe", "The cross-table check matches on a single --on column.", { exitCode: 1 });
11283
+ }
11284
+ return {
11285
+ endpoint: "/api/cli/tables/dedupe/cross-check",
11286
+ body: {
11287
+ table,
11288
+ column: onColumns[0],
11289
+ against_table: against,
11290
+ against_column: againstColumn,
11291
+ normalizer,
11292
+ ...(scanLimit ? { scan_limit: scanLimit } : {}),
11293
+ },
11294
+ };
11295
+ }
11296
+ const keys = onColumns.map((column) => ({ column, normalizer }));
11297
+ const keep = normalizeDedupeKeep(options.keep);
11298
+ if (options.apply) {
11299
+ const mergeValues = normalizeDedupeMergeValues(options.mergeValues);
11300
+ return {
11301
+ endpoint: "/api/cli/tables/dedupe/apply",
11302
+ body: {
11303
+ table,
11304
+ keys,
11305
+ keep,
11306
+ ...(mergeValues ? { merge_values: mergeValues } : {}),
11307
+ ...(scanLimit ? { scan_limit: scanLimit } : {}),
11308
+ // --approved is the only path that deletes; without it the API 409s with the preview.
11309
+ ...(options.approved ? { approved: true } : {}),
11310
+ },
11311
+ };
11312
+ }
11313
+ return {
11314
+ endpoint: "/api/cli/tables/dedupe/preview",
11315
+ body: { table, keys, keep, ...(scanLimit ? { scan_limit: scanLimit } : {}) },
11316
+ };
11317
+ }
11318
+ // Build the standing auto-dedupe `set` body: --on columns paired positionally
11319
+ // with --normalize modes (unpaired columns default to exact). fuzzy-label maps
11320
+ // through so the API rejects it with the on-demand pointer — one source of
11321
+ // truth for the hot-path restriction.
11322
+ function buildTablesAutoDedupeSetBody(table, options) {
11323
+ const onColumns = readCsvOption(options.on);
11324
+ if (onColumns.length === 0) {
11325
+ throw new OxygenError("invalid_auto_dedupe", "--on requires at least one column.", { exitCode: 1 });
11326
+ }
11327
+ const normalizers = readCsvOption(options.normalize);
11328
+ const keys = onColumns.map((column, index) => ({
11329
+ column,
11330
+ normalizer: normalizeDedupeNormalizer(normalizers[index]),
11331
+ }));
11332
+ const keep = normalizeDedupeKeep(options.keep);
11333
+ const mergeValues = normalizeDedupeMergeValues(options.mergeValues);
11334
+ return {
11335
+ table,
11336
+ keys,
11337
+ keep,
11338
+ ...(mergeValues ? { merge_values: mergeValues } : {}),
11339
+ };
11340
+ }
11341
+ // Map the CLI --normalize flag to the wire normalizer vocabulary.
11342
+ function normalizeDedupeNormalizer(value) {
11343
+ const normalized = (readOption(value) ?? "exact").toLowerCase();
11344
+ switch (normalized) {
11345
+ case "exact":
11346
+ return "exact";
11347
+ case "email":
11348
+ return "email";
11349
+ case "domain":
11350
+ return "domain";
11351
+ case "linkedin":
11352
+ case "linkedin-url":
11353
+ case "linkedin_url":
11354
+ return "linkedin_url";
11355
+ case "fuzzy":
11356
+ case "fuzzy-label":
11357
+ case "fuzzy_label":
11358
+ return "fuzzy_label";
11359
+ default:
11360
+ throw new OxygenError("invalid_dedupe_normalize", "--normalize must be exact, email, domain, linkedin, or fuzzy-label.", {
11361
+ details: { normalize: value },
11362
+ exitCode: 1,
11363
+ });
11364
+ }
11365
+ }
11366
+ function normalizeDedupeKeep(value) {
11367
+ const normalized = (readOption(value) ?? "oldest").replace(/-/g, "_");
11368
+ if (normalized === "oldest" || normalized === "newest" || normalized === "most_complete") {
11369
+ return normalized;
11370
+ }
11371
+ throw new OxygenError("invalid_dedupe_keep", "--keep must be oldest, newest, or most-complete.", {
11372
+ details: { keep: value },
11373
+ exitCode: 1,
11374
+ });
11375
+ }
11376
+ function normalizeDedupeMergeValues(value) {
11377
+ const raw = readOption(value);
11378
+ if (!raw)
11379
+ return null;
11380
+ const normalized = raw.replace(/-/g, "_");
11381
+ if (normalized === "fill_empty")
11382
+ return "fill_empty";
11383
+ if (normalized === "none")
11384
+ return null;
11385
+ throw new OxygenError("invalid_dedupe_merge", "--merge-values must be fill-empty.", {
11386
+ details: { merge_values: value },
11387
+ exitCode: 1,
11388
+ });
11389
+ }
9593
11390
  function tableRunsListPath(options) {
9594
11391
  const table = readOption(options.table);
9595
11392
  if (!table) {
@@ -12424,11 +14221,56 @@ function formatProfileUseSuccess(profile, options) {
12424
14221
  }
12425
14222
  return lines.join("\n");
12426
14223
  }
12427
- // `sequences signal` records an external GTM signal onto a running sequence's
12428
- // enrollment(s). The signal *name* is validated server-side against the typed
12429
- // enum; the CLI only enforces that a signal was supplied and that at least one
12430
- // enrollment target (--enrollment / --lead) is present, so an unscoped call
12431
- // fails before spending a request.
14224
+ // `sequences duplicate` is deliberately CLIENT-SIDE composition (get + create):
14225
+ // it adds a real workflow convenience (Instantly/Lemlist/HeyReach all have
14226
+ // campaign duplication) without growing the API/MCP surface — an MCP agent
14227
+ // composes the same two calls, and the tools/list payload is at its size
14228
+ // budget. The Instantly email BINDING is never copied: two campaigns must not
14229
+ // share one provider campaign id; the create-side validation tells the user
14230
+ // exactly that if the copied steps require a binding.
14231
+ async function handleSequenceDuplicateAction(sequence, options) {
14232
+ try {
14233
+ const name = readOption(options.name);
14234
+ if (!name)
14235
+ throw new Error("--name is required.");
14236
+ const source = await requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}`, { method: "GET" });
14237
+ const src = source.sequence;
14238
+ if (!src || src.definition === undefined) {
14239
+ throw new Error(`Sequence '${sequence}' was found but returned no definition to copy.`);
14240
+ }
14241
+ const slug = readOption(options.slug) ?? `${src.slug ?? sequence}-copy`;
14242
+ const copySenders = options.senders !== false;
14243
+ const created = await requestOxygen("/api/cli/sequences", {
14244
+ method: "POST",
14245
+ body: {
14246
+ slug,
14247
+ name,
14248
+ definition: src.definition,
14249
+ ...(src.channels ? { channels: src.channels } : {}),
14250
+ ...(src.sourceTableId ? { sourceTableId: src.sourceTableId } : {}),
14251
+ ...(src.linkedinUrlColumnKey ? { linkedinUrlColumnKey: src.linkedinUrlColumnKey } : {}),
14252
+ ...(src.settings ? { settings: src.settings } : {}),
14253
+ ...(src.maxCredits != null ? { maxCredits: src.maxCredits } : {}),
14254
+ ...(src.maxLiveSends != null ? { maxLiveSends: src.maxLiveSends } : {}),
14255
+ ...(copySenders && src.senderAccountIds && src.senderAccountIds.length > 0
14256
+ ? { senderRefs: src.senderAccountIds }
14257
+ : {}),
14258
+ },
14259
+ });
14260
+ if (options.json) {
14261
+ writeJson(success("sequences duplicate", created));
14262
+ return;
14263
+ }
14264
+ const link = created.deepLink ?? created.web_url;
14265
+ const note = src.emailProvider
14266
+ ? " Note: the source's email binding was NOT copied — re-bind with `sequences update` if needed."
14267
+ : "";
14268
+ process.stdout.write(`Duplicated '${sequence}' as draft '${created.sequence?.slug ?? slug}'.${note}${link ? ` ${link}` : ""}\n`);
14269
+ }
14270
+ catch (error) {
14271
+ emitCliFailure("sequences duplicate", error);
14272
+ }
14273
+ }
12432
14274
  async function handleSequenceSignalAction(sequence, options) {
12433
14275
  try {
12434
14276
  const signal = readOption(options.signal);
@@ -12460,6 +14302,74 @@ async function handleSequenceSignalAction(sequence, options) {
12460
14302
  emitCliFailure("sequences signal", error);
12461
14303
  }
12462
14304
  }
14305
+ // `sequences events` reads the per-sequence activity feed (Instantly "Sending
14306
+ // Activities" parity). Read-only: it forwards the filters as query params and
14307
+ // renders one line per event, with a keyset `next:` hint when a further page
14308
+ // exists.
14309
+ async function handleSequenceEventsAction(sequence, options) {
14310
+ try {
14311
+ const params = new URLSearchParams();
14312
+ const enrollment = readOption(options.enrollment);
14313
+ if (enrollment)
14314
+ params.set("enrollment", enrollment);
14315
+ const kinds = readCsvOption(options.kind);
14316
+ if (kinds.length > 0)
14317
+ params.set("kinds", kinds.join(","));
14318
+ if (options.includeBots)
14319
+ params.set("include_bots", "true");
14320
+ const limit = readOption(options.limit);
14321
+ if (limit)
14322
+ params.set("limit", limit);
14323
+ const before = readOption(options.before);
14324
+ if (before)
14325
+ params.set("before", before);
14326
+ const suffix = params.toString();
14327
+ const data = await requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/events${suffix ? `?${suffix}` : ""}`);
14328
+ if (options.json) {
14329
+ writeJson(success("sequences events", data));
14330
+ return;
14331
+ }
14332
+ process.stdout.write(formatSequenceEvents(data));
14333
+ }
14334
+ catch (error) {
14335
+ emitCliFailure("sequences events", error);
14336
+ }
14337
+ }
14338
+ const SEQUENCE_EVENT_KIND_WIDTH = 12;
14339
+ // Render occurred_at as a compact minute-precision UTC stamp for the terminal
14340
+ // (the full-precision ISO stays in the JSON envelope and the --before cursor).
14341
+ function formatEventTime(iso) {
14342
+ if (!iso)
14343
+ return "—";
14344
+ const date = new Date(iso);
14345
+ if (Number.isNaN(date.getTime()))
14346
+ return iso;
14347
+ return `${date.toISOString().slice(0, 16)}Z`;
14348
+ }
14349
+ function formatSequenceEvents(data) {
14350
+ const events = Array.isArray(data.events) ? data.events : [];
14351
+ if (events.length === 0)
14352
+ return "No events yet.\n";
14353
+ const lines = events.map((event) => {
14354
+ const time = formatEventTime(event.occurredAt);
14355
+ const kind = (event.kind ?? "—").padEnd(SEQUENCE_EVENT_KIND_WIDTH);
14356
+ const segments = [];
14357
+ if (event.stepIndex != null) {
14358
+ segments.push(`step ${event.stepIndex}${event.variantId ? ` (${event.variantId})` : ""}`);
14359
+ }
14360
+ if (event.actionKind)
14361
+ segments.push(event.actionKind);
14362
+ if (event.url)
14363
+ segments.push(event.url);
14364
+ if (event.detail)
14365
+ segments.push(`— ${event.detail}`);
14366
+ const tail = segments.join(" ");
14367
+ return [time, kind, tail].filter((part) => part.trim().length > 0).join(" ").replace(/\s+$/, "");
14368
+ });
14369
+ if (data.next_before)
14370
+ lines.push(`next: --before ${data.next_before}`);
14371
+ return `${lines.join("\n")}\n`;
14372
+ }
12463
14373
  async function handleSequenceVariantsAction(sequence, options) {
12464
14374
  try {
12465
14375
  const path = `/api/cli/sequences/${encodeURIComponent(sequence)}/variants`;
@@ -12938,6 +14848,17 @@ function withSupportListQuery(path, options) {
12938
14848
  const query = params.toString();
12939
14849
  return query ? `${path}?${query}` : path;
12940
14850
  }
14851
+ function withSupportEventsQuery(path, options) {
14852
+ const params = new URLSearchParams();
14853
+ const after = readOption(options.after);
14854
+ const limit = readOption(options.limit);
14855
+ if (after)
14856
+ params.set("after", after);
14857
+ if (limit)
14858
+ params.set("limit", limit);
14859
+ const query = params.toString();
14860
+ return query ? `${path}?${query}` : path;
14861
+ }
12941
14862
  // Workflow text flags (--verify-notes/--plan/--draft) bypass readOption: an
12942
14863
  // explicitly-passed empty string clears the field server-side, while an absent
12943
14864
  // flag leaves it untouched, so "" must survive to the request body.
@@ -14045,6 +15966,12 @@ function readSequenceSettings(options) {
14045
15966
  if (options.stopOnBounce === false) {
14046
15967
  settings.stop_on_bounce = false;
14047
15968
  }
15969
+ // exclude_contacted: sequence-level cross-campaign exclusion default. Tri-state
15970
+ // (--exclude-contacted / --no-exclude-contacted); undefined leaves it unset so a
15971
+ // settings PATCH stays clean and enroll keeps today's per-call-only behavior.
15972
+ if (options.excludeContacted !== undefined) {
15973
+ settings.exclude_contacted = options.excludeContacted;
15974
+ }
14048
15975
  const maxEmailsPerDay = readPositiveInt(options.maxEmailsPerDay);
14049
15976
  if (maxEmailsPerDay !== undefined)
14050
15977
  settings.max_emails_per_day = maxEmailsPerDay;
@@ -14073,6 +16000,22 @@ function readSequenceSettings(options) {
14073
16000
  if (emailMinGap !== undefined)
14074
16001
  settings.email_min_gap_minutes = emailMinGap;
14075
16002
  }
16003
+ // opportunity_value is a non-negative dollar amount (may be fractional and may be
16004
+ // 0), so it is parsed as a plain finite number rather than a positive integer;
16005
+ // the server enforces the >= 0 rule (validateSequenceSettings) and reports a clear
16006
+ // error for a negative value, so send any finite number through.
16007
+ const opportunityValueRaw = readOption(options.opportunityValue);
16008
+ if (opportunityValueRaw !== null) {
16009
+ const opportunityValue = Number(opportunityValueRaw);
16010
+ if (Number.isFinite(opportunityValue))
16011
+ settings.opportunity_value = opportunityValue;
16012
+ }
16013
+ // Per-sequence sending-account allowlist (--mailboxes id1,id2). Split + trim +
16014
+ // drop empties client-side (light); the server validates each is a UUID and caps
16015
+ // the count. An all-empty list omits the key so a stray "--mailboxes ," is a no-op.
16016
+ const mailboxIds = readCsvOption(options.mailboxes);
16017
+ if (mailboxIds.length > 0)
16018
+ settings.mailbox_ids = mailboxIds;
14076
16019
  return Object.keys(settings).length > 0 ? settings : undefined;
14077
16020
  }
14078
16021
  function muteTokenEcho() {