@oxygen-agent/cli 1.310.3 → 1.336.1

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 (34) hide show
  1. package/README.md +1 -1
  2. package/dist/browser-login.d.ts +16 -0
  3. package/dist/browser-login.js +131 -1
  4. package/dist/command-manifest.js +26 -10
  5. package/dist/help.js +7 -0
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.js +2152 -124
  8. package/dist/runtime.js +13 -3
  9. package/dist/skills.js +192 -32
  10. package/node_modules/@oxygen/recipe-sdk/dist/index.d.ts +2 -0
  11. package/node_modules/@oxygen/shared/dist/billing.d.ts +52 -29
  12. package/node_modules/@oxygen/shared/dist/billing.js +77 -55
  13. package/node_modules/@oxygen/shared/dist/cli-login-code.d.ts +3 -0
  14. package/node_modules/@oxygen/shared/dist/cli-login-code.js +30 -0
  15. package/node_modules/@oxygen/shared/dist/index.d.ts +5 -0
  16. package/node_modules/@oxygen/shared/dist/index.js +5 -0
  17. package/node_modules/@oxygen/shared/dist/pricing-sheet.d.ts +84 -0
  18. package/node_modules/@oxygen/shared/dist/pricing-sheet.js +82 -0
  19. package/node_modules/@oxygen/shared/dist/recipes.d.ts +19 -0
  20. package/node_modules/@oxygen/shared/dist/recipes.js +90 -0
  21. package/node_modules/@oxygen/shared/dist/sequences.d.ts +61 -15
  22. package/node_modules/@oxygen/shared/dist/sequences.js +134 -23
  23. package/node_modules/@oxygen/shared/dist/sql-error.d.ts +25 -0
  24. package/node_modules/@oxygen/shared/dist/sql-error.js +46 -0
  25. package/node_modules/@oxygen/shared/dist/version.d.ts +2 -2
  26. package/node_modules/@oxygen/shared/dist/version.js +13 -11
  27. package/node_modules/@oxygen/shared/dist/workflow-mcp-tools.d.ts +4 -0
  28. package/node_modules/@oxygen/shared/dist/workflow-mcp-tools.js +18 -0
  29. package/node_modules/@oxygen/shared/dist/workflow-status-change.d.ts +61 -0
  30. package/node_modules/@oxygen/shared/dist/workflow-status-change.js +124 -0
  31. package/node_modules/@oxygen/shared/dist/workspace-agents.d.ts +65 -0
  32. package/node_modules/@oxygen/shared/dist/workspace-agents.js +67 -0
  33. package/node_modules/@oxygen/workflows/dist/index.js +86 -2
  34. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -9,11 +9,11 @@ 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";
16
- import { createBrowserLoginSession, openBrowser } from "./browser-login.js";
16
+ import { createBrowserLoginSession, createRelayLoginSession, openBrowser } from "./browser-login.js";
17
17
  import { clearCredentials, defaultApiUrl, listCredentialProfiles, loadCredentials, normalizeApiUrl, pickProfileNameForIdentity, pickProfileNameForUserSession, resolveActiveProfile, saveCredentials, switchCredentialProfile, updateActiveOrganizationForProfile, } from "./credentials.js";
18
18
  import { ensureFreshCliForApiUrl, requestOxygen } from "./http-client.js";
19
19
  import { acquireMirrorLock, clearConflictFiles, deletePageFile, emptyMirrorState, findMirrorSlugByPageId, isFileDirty, listConflictFiles, listLocalMirrors, localPageSha256, markMirrorStale, mirrorExists, pageFilePath, planMirrorPush, purgeMirror, resetMirrorForFullResync, quarantineDirtyFile, readMirrorState, releaseMirrorLock, resolveDefaultConfigDir, resolveMirrorDir, writeGeneratedIndexFile, writeGeneratedLogFile, writeMirrorState, writePageFile, } from "./knowledge-mirror.js";
@@ -63,6 +63,7 @@ function buildFindBody(capability, options) {
63
63
  return body;
64
64
  }
65
65
  const BROWSER_LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
66
+ const BROWSER_LOGIN_FALLBACK_HINT_MS = 15 * 1000;
66
67
  const OXYGEN_SPINNER_INTERVAL_MS = 90;
67
68
  const INITIAL_OXYGEN_PROFILE_ENV = process.env.OXYGEN_PROFILE?.trim() || null;
68
69
  let globalProfileFlag = null;
@@ -160,6 +161,62 @@ function writeCreditsReceipt(data) {
160
161
  process.stderr.write(`estimated ${block.estimated_credits.toLocaleString("en-US")} credits for a live run${remaining !== null ? `, ${remaining} available` : ""}\n`);
161
162
  }
162
163
  }
164
+ // A disabled workflow is a customer's automation at zero, and `status:
165
+ // "disabled"` alone never said who did that or why (OXY-4124). Mirror the
166
+ // recorded transition as one stderr line per disabled workflow, so `workflows
167
+ // list` / `workflows get` answer "who turned this off?" in the terminal without
168
+ // polluting the machine-read stdout envelope (which carries `statusChange` in
169
+ // full). Sourced from the same shared sentence the web banner renders.
170
+ export function formatDisabledWorkflowNotices(data) {
171
+ if (!data || typeof data !== "object" || Array.isArray(data))
172
+ return [];
173
+ const block = data;
174
+ const candidates = Array.isArray(block.workflows)
175
+ ? block.workflows
176
+ : [block.workflow];
177
+ const notices = [];
178
+ for (const candidate of candidates) {
179
+ if (!candidate || typeof candidate !== "object" || Array.isArray(candidate))
180
+ continue;
181
+ const workflow = candidate;
182
+ if (workflow.status !== "disabled")
183
+ continue;
184
+ const slug = typeof workflow.slug === "string" ? workflow.slug : "workflow";
185
+ const change = parseWorkflowStatusChange(workflow.statusChange);
186
+ // A workflow disabled before the record shipped has no actor to name. Say
187
+ // that plainly rather than inventing one.
188
+ notices.push(change
189
+ ? `${slug}: ${describeWorkflowStatusChange(change)}`
190
+ : `${slug}: disabled — no actor was recorded (disabled before Oxygen tracked this).`);
191
+ }
192
+ return notices;
193
+ }
194
+ function writeDisabledWorkflowNotices(data) {
195
+ for (const notice of formatDisabledWorkflowNotices(data)) {
196
+ process.stderr.write(`${notice}\n`);
197
+ }
198
+ }
199
+ // The observability console caps each source at --limit (default 25, max 100) and
200
+ // reports which hit the cap in sources[].capped. The human (non --json) output is
201
+ // a JSON dump that buries that boolean, so surface it as a one-line stderr notice:
202
+ // the counts shown are only the returned page, and more may sit behind a capped
203
+ // source. Not pagination — raising --limit or filtering --source is the fix.
204
+ function writeObservabilityCapsNotice(data) {
205
+ if (!data || typeof data !== "object" || Array.isArray(data))
206
+ return;
207
+ const record = data;
208
+ const sources = Array.isArray(record.sources) ? record.sources : [];
209
+ const cappedSources = sources
210
+ .filter((source) => Boolean(source) && typeof source === "object" && !Array.isArray(source)
211
+ && source.capped === true)
212
+ .map((source) => String(source.source));
213
+ if (cappedSources.length === 0)
214
+ return;
215
+ const returned = typeof record.returned === "number"
216
+ ? record.returned
217
+ : Array.isArray(record.items) ? record.items.length : 0;
218
+ 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`);
219
+ }
163
220
  // Arming a cron commits recurring spend, so `workflows enable` mirrors its
164
221
  // `automation` block as stderr lines: what the schedule burns per day, what
165
222
  // share of the monthly allowance that is, and when it runs out. Observed burn
@@ -534,7 +591,6 @@ function buildCrmObjectCreateBody(options) {
534
591
  ...(readOption(options.singularName) ? { singular_name: readOption(options.singularName) } : {}),
535
592
  ...(readOption(options.pluralName) ? { plural_name: readOption(options.pluralName) } : {}),
536
593
  ...(readOption(options.labelColumn) ? { label_column: readOption(options.labelColumn) } : {}),
537
- ...(options.relationshipsJson ? { relationships: parseJsonArray(options.relationshipsJson) } : {}),
538
594
  ...(readOption(options.project) ? { project: readOption(options.project) } : {}),
539
595
  };
540
596
  }
@@ -687,12 +743,19 @@ function buildNotetakerScheduleBody(meetingUrl, options) {
687
743
  }
688
744
  function buildPublishingPostsListPath(options) {
689
745
  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);
746
+ const filters = {
747
+ status: options.status,
748
+ approval_status: options.approvalStatus,
749
+ label: options.label,
750
+ campaign_id: options.campaign,
751
+ provider: options.provider,
752
+ limit: options.limit,
753
+ };
754
+ for (const [key, value] of Object.entries(filters)) {
755
+ const read = readOption(value);
756
+ if (read)
757
+ query.set(key, read);
758
+ }
696
759
  const suffix = query.toString();
697
760
  return suffix ? `/api/cli/publishing/posts?${suffix}` : "/api/cli/publishing/posts";
698
761
  }
@@ -878,6 +941,88 @@ function buildPublishingPostsDraftBody(options) {
878
941
  body.source_url = sourceUrl;
879
942
  return body;
880
943
  }
944
+ function buildSequenceDraftBody(options) {
945
+ const goal = readOption(options.goal);
946
+ if (!goal)
947
+ throw new Error("--goal is required.");
948
+ const name = readOption(options.name);
949
+ const slug = readOption(options.slug);
950
+ if (options.create && (!name || !slug)) {
951
+ throw new Error("--create requires --name and --slug.");
952
+ }
953
+ const audience = readOption(options.audience);
954
+ const table = readOption(options.table);
955
+ const steps = readPositiveInt(options.steps);
956
+ const variants = readPositiveInt(options.variants);
957
+ return {
958
+ goal,
959
+ max_credits: readPositiveNumber(options.maxCredits),
960
+ ...(audience ? { audience } : {}),
961
+ ...(table ? { source_table: table } : {}),
962
+ ...(steps !== undefined ? { steps } : {}),
963
+ ...(variants !== undefined ? { variants } : {}),
964
+ ...(options.create ? { create: true } : {}),
965
+ ...(name ? { name } : {}),
966
+ ...(slug ? { slug } : {}),
967
+ };
968
+ }
969
+ // Human-legible preview for `sequences draft` (stdout stays the machine JSON, like
970
+ // every other command): the drafted emails with their subjects + wait gaps, the
971
+ // Knowledge Graph pages that grounded the copy, and the next step. Written to
972
+ // stderr (the same lane as the credits receipt) so scripts still parse stdout.
973
+ function writeSequenceDraftPreview(data) {
974
+ if (!data || typeof data !== "object" || Array.isArray(data))
975
+ return;
976
+ const record = data;
977
+ const definition = record.definition;
978
+ const steps = Array.isArray(record.steps)
979
+ ? record.steps
980
+ : Array.isArray(definition?.steps)
981
+ ? definition.steps
982
+ : [];
983
+ const sequence = record.sequence;
984
+ const lines = [];
985
+ if (record.created && sequence) {
986
+ lines.push(`Saved draft sequence "${String(sequence.name ?? "")}" (${String(sequence.slug ?? "")}).`);
987
+ }
988
+ else {
989
+ lines.push("Drafted email sequence (preview — not saved):");
990
+ }
991
+ let emailNumber = 0;
992
+ for (const step of steps) {
993
+ if (!step || typeof step !== "object" || Array.isArray(step))
994
+ continue;
995
+ const entry = step;
996
+ if (entry.kind === "email_send") {
997
+ emailNumber += 1;
998
+ const subject = typeof entry.subject_template === "string" && entry.subject_template.trim()
999
+ ? entry.subject_template.trim()
1000
+ : "(no subject)";
1001
+ const variantCount = Array.isArray(entry.variants) ? entry.variants.length : 0;
1002
+ lines.push(` Email ${emailNumber}: ${subject}${variantCount > 0 ? ` (+${variantCount} A/B variant${variantCount === 1 ? "" : "s"})` : ""}`);
1003
+ }
1004
+ else if (entry.kind === "wait") {
1005
+ const days = typeof entry.days === "number" ? entry.days : 0;
1006
+ lines.push(` wait ${days} day${days === 1 ? "" : "s"}`);
1007
+ }
1008
+ }
1009
+ const provenance = record.provenance;
1010
+ const pages = Array.isArray(provenance?.pages) ? provenance.pages : [];
1011
+ if (pages.length > 0) {
1012
+ const names = pages
1013
+ .map((page) => (typeof page.title === "string" && page.title.trim() ? page.title.trim() : typeof page.slug === "string" ? page.slug : ""))
1014
+ .filter((name) => name.length > 0)
1015
+ .slice(0, 5);
1016
+ lines.push(` Grounded on ${pages.length} Knowledge Graph page${pages.length === 1 ? "" : "s"}${names.length > 0 ? `: ${names.join(", ")}` : ""}.`);
1017
+ }
1018
+ else {
1019
+ lines.push(" Grounded on the workspace voice (no Knowledge Graph pages retrieved).");
1020
+ }
1021
+ if (!record.created) {
1022
+ lines.push(" Re-run with --create --name <name> --slug <slug> to save it as a draft sequence.");
1023
+ }
1024
+ process.stderr.write(`${lines.join("\n")}\n`);
1025
+ }
881
1026
  function buildPublishingDraftsListPath(options) {
882
1027
  const query = new URLSearchParams();
883
1028
  const kind = readOption(options.kind);
@@ -913,6 +1058,211 @@ function buildPublishingDraftsAcceptBody(options) {
913
1058
  body.timezone = timezone;
914
1059
  return body;
915
1060
  }
1061
+ function buildPublishingCampaignBody(options, requireName) {
1062
+ const name = readOption(options.name);
1063
+ if (requireName && !name) {
1064
+ throw new OxygenError("invalid_request", "Pass --name.", { exitCode: 1 });
1065
+ }
1066
+ const goal = readOption(options.goal);
1067
+ const startsAt = readOption(options.startsAt);
1068
+ const endsAt = readOption(options.endsAt);
1069
+ return {
1070
+ ...(name ? { name } : {}),
1071
+ ...(goal ? { goal } : {}),
1072
+ ...(startsAt ? { starts_at: startsAt } : {}),
1073
+ ...(endsAt ? { ends_at: endsAt } : {}),
1074
+ };
1075
+ }
1076
+ function buildPublishingLabelsBody(options) {
1077
+ const add = splitCommaList(options.add);
1078
+ const remove = splitCommaList(options.remove);
1079
+ if (add.length === 0 && remove.length === 0) {
1080
+ throw new OxygenError("invalid_request", "Pass --add and/or --remove.", { exitCode: 1 });
1081
+ }
1082
+ return {
1083
+ ...(add.length > 0 ? { add } : {}),
1084
+ ...(remove.length > 0 ? { remove } : {}),
1085
+ };
1086
+ }
1087
+ function splitCommaList(value) {
1088
+ const raw = readOption(value);
1089
+ if (!raw)
1090
+ return [];
1091
+ return raw.split(",").map((entry) => entry.trim()).filter(Boolean);
1092
+ }
1093
+ function buildPublishingAnalyticsPath(base, params) {
1094
+ const query = new URLSearchParams();
1095
+ for (const [key, value] of Object.entries(params)) {
1096
+ if (value)
1097
+ query.set(key, value);
1098
+ }
1099
+ const suffix = query.toString();
1100
+ return suffix ? `${base}?${suffix}` : base;
1101
+ }
1102
+ // `--file` is the whole import: a .csv is posted verbatim as `csv` (the API owns
1103
+ // the parse, so the CLI and the web importer can never disagree about a quoted
1104
+ // comma); a .json is posted as `rows`. Dry-run is the DEFAULT — a bare
1105
+ // `publishing import --file posts.csv` lints and reports, and only --approved
1106
+ // flips dry_run off, so a bulk write into the queue is never one typo away.
1107
+ function buildPublishingImportBody(options) {
1108
+ const file = readOption(options.file);
1109
+ if (!file)
1110
+ throw new OxygenError("invalid_request", "Pass --file <path.csv|.json>.", { exitCode: 1 });
1111
+ if (options.dryRun === true && options.approved === true) {
1112
+ throw new OxygenError("conflicting_flags", "Pass either --dry-run or --approved, not both.", { exitCode: 1 });
1113
+ }
1114
+ const contents = readPublishingTextFile(file, "--file");
1115
+ const campaign = readOption(options.campaign);
1116
+ const labels = splitCommaList(options.label);
1117
+ const isJson = file.trim().toLowerCase().endsWith(".json");
1118
+ return {
1119
+ ...(isJson ? { rows: readPublishingImportRows(contents) } : { csv: contents }),
1120
+ dry_run: options.approved !== true,
1121
+ ...(campaign ? { campaign_id: campaign } : {}),
1122
+ ...(labels.length > 0 ? { labels } : {}),
1123
+ };
1124
+ }
1125
+ function readPublishingImportRows(contents) {
1126
+ let parsed;
1127
+ try {
1128
+ parsed = JSON.parse(contents);
1129
+ }
1130
+ catch {
1131
+ throw new OxygenError("invalid_json", "--file is not valid JSON.", { exitCode: 1 });
1132
+ }
1133
+ if (Array.isArray(parsed))
1134
+ return parsed;
1135
+ const rows = parsed?.rows;
1136
+ if (Array.isArray(rows))
1137
+ return rows;
1138
+ throw new OxygenError("invalid_request", "Expected a JSON array of rows, or an object with a `rows` array.", { exitCode: 1 });
1139
+ }
1140
+ // Amplification create and post boost both arm REAL public writes from other
1141
+ // people's connected accounts and both spend credits, so the CLI refuses (exit 7)
1142
+ // before the request rather than relying on the API to bounce it. Same gate the
1143
+ // LinkedIn `posts delete` path uses: an irreversible public action never happens
1144
+ // because a flag was forgotten.
1145
+ function requirePublishingApprovalAndCap(approved, maxCredits, action, details) {
1146
+ if (approved !== true) {
1147
+ 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 });
1148
+ }
1149
+ const cap = readPositiveNumber(maxCredits);
1150
+ if (cap === undefined) {
1151
+ throw new OxygenError("max_credits_required", `${action} spends credits, so it needs an explicit ceiling. Re-run with --max-credits <n>.`, { details, exitCode: 7 });
1152
+ }
1153
+ return cap;
1154
+ }
1155
+ function buildPublishingAmplificationCreateBody(options) {
1156
+ const maxCredits = requirePublishingApprovalAndCap(options.approved, options.maxCredits, "Creating an amplification policy", { name: options.name, scope: options.scope });
1157
+ const senders = splitCommaList(options.senders);
1158
+ const actions = splitCommaList(options.actions);
1159
+ if (senders.length === 0)
1160
+ throw new OxygenError("invalid_request", "Pass --senders.", { exitCode: 1 });
1161
+ if (actions.length === 0)
1162
+ throw new OxygenError("invalid_request", "Pass --actions.", { exitCode: 1 });
1163
+ return {
1164
+ name: options.name,
1165
+ scope_kind: options.scope,
1166
+ participant_sender_ids: senders,
1167
+ actions,
1168
+ max_credits_per_cycle: maxCredits,
1169
+ approved: true,
1170
+ ...publishingAmplificationScope(options),
1171
+ ...publishingAmplificationTunables(options),
1172
+ };
1173
+ }
1174
+ function publishingAmplificationScope(options) {
1175
+ const campaign = readOption(options.campaign);
1176
+ const label = readOption(options.label);
1177
+ const post = readOption(options.post);
1178
+ return {
1179
+ ...(campaign ? { scope_campaign_id: campaign } : {}),
1180
+ ...(label ? { scope_label: label } : {}),
1181
+ ...(post ? { scope_post_id: post } : {}),
1182
+ };
1183
+ }
1184
+ function publishingAmplificationTunables(options) {
1185
+ const maxActions = readPositiveInt(options.maxActionsPerPost);
1186
+ const commentPool = readPublishingCommentPool(options);
1187
+ const reactionMin = readNonNegativeInt(options.reactionDelayMin);
1188
+ const reactionMax = readNonNegativeInt(options.reactionDelayMax);
1189
+ const commentMin = readNonNegativeInt(options.commentDelayMin);
1190
+ const commentMax = readNonNegativeInt(options.commentDelayMax);
1191
+ return {
1192
+ ...(maxActions !== undefined ? { max_actions_per_post: maxActions } : {}),
1193
+ ...(commentPool ? { comment_source: "pool", comment_pool: commentPool } : {}),
1194
+ ...(reactionMin !== undefined ? { reaction_delay_min_seconds: reactionMin } : {}),
1195
+ ...(reactionMax !== undefined ? { reaction_delay_max_seconds: reactionMax } : {}),
1196
+ ...(commentMin !== undefined ? { comment_delay_min_seconds: commentMin } : {}),
1197
+ ...(commentMax !== undefined ? { comment_delay_max_seconds: commentMax } : {}),
1198
+ };
1199
+ }
1200
+ // Comments are prose: a comma-separated flag would split them mid-sentence. The
1201
+ // pool is a repeatable --comment-pool flag, or a file of one comment per line.
1202
+ function readPublishingCommentPool(options) {
1203
+ const file = readOption(options.commentPoolFile);
1204
+ const inline = Array.isArray(options.commentPool)
1205
+ ? options.commentPool.map((entry) => entry.trim()).filter(Boolean)
1206
+ : [];
1207
+ if (file && inline.length > 0) {
1208
+ throw new OxygenError("conflicting_flags", "Pass either --comment-pool or --comment-pool-file, not both.", {
1209
+ exitCode: 1,
1210
+ });
1211
+ }
1212
+ if (file) {
1213
+ const lines = readPublishingTextFile(file, "--comment-pool-file")
1214
+ .split("\n")
1215
+ .map((line) => line.trim())
1216
+ .filter(Boolean);
1217
+ if (lines.length === 0) {
1218
+ throw new OxygenError("invalid_request", "--comment-pool-file has no comments.", { exitCode: 1 });
1219
+ }
1220
+ return lines;
1221
+ }
1222
+ return inline.length > 0 ? inline : null;
1223
+ }
1224
+ function buildPublishingAmplificationUpdateBody(options) {
1225
+ const name = readOption(options.name);
1226
+ const senders = splitCommaList(options.senders);
1227
+ const actions = splitCommaList(options.actions);
1228
+ const maxCredits = readPositiveNumber(options.maxCredits);
1229
+ return {
1230
+ ...(name ? { name } : {}),
1231
+ ...(senders.length > 0 ? { participant_sender_ids: senders } : {}),
1232
+ ...(actions.length > 0 ? { actions } : {}),
1233
+ ...(maxCredits !== undefined ? { max_credits_per_cycle: maxCredits } : {}),
1234
+ ...publishingAmplificationTunables(options),
1235
+ };
1236
+ }
1237
+ function buildPublishingBoostBody(postId, options) {
1238
+ const maxCredits = requirePublishingApprovalAndCap(options.approved, options.maxCredits, "Boosting a post", { post_id: postId });
1239
+ const senders = splitCommaList(options.senders);
1240
+ const actions = splitCommaList(options.actions);
1241
+ if (senders.length === 0)
1242
+ throw new OxygenError("invalid_request", "Pass --senders.", { exitCode: 1 });
1243
+ if (actions.length === 0)
1244
+ throw new OxygenError("invalid_request", "Pass --actions.", { exitCode: 1 });
1245
+ const maxActions = readPositiveInt(options.maxActionsPerPost);
1246
+ const commentPool = readPublishingCommentPool(options);
1247
+ return {
1248
+ senders,
1249
+ actions,
1250
+ max_credits: maxCredits,
1251
+ approved: true,
1252
+ ...(maxActions !== undefined ? { max_actions_per_post: maxActions } : {}),
1253
+ ...(commentPool ? { comment_pool: commentPool } : {}),
1254
+ };
1255
+ }
1256
+ function buildPublishingIdeaBody(options) {
1257
+ const topic = readOption(options.topic);
1258
+ const channels = splitCommaList(options.channels);
1259
+ return {
1260
+ kind: "idea",
1261
+ text: readPublishingPostText(options, true),
1262
+ ...(topic ? { topic } : {}),
1263
+ ...(channels.length > 0 ? { channels } : {}),
1264
+ };
1265
+ }
916
1266
  function buildCrmRelationshipUpsertBody(object, rowId, options) {
917
1267
  return {
918
1268
  object,
@@ -1136,6 +1486,20 @@ function isUuid(value) {
1136
1486
  // surfaces are generated here to stop them drifting (the alias previously
1137
1487
  // duplicated every subcommand). All subcommands hit the shared
1138
1488
  // /api/cli/templates/* routes.
1489
+ /**
1490
+ * Accept the domain as EITHER a positional argument or --domain.
1491
+ *
1492
+ * `subscribe` required --domain while get/warmup/cancel took a positional, so the same value had
1493
+ * two spellings depending on the verb, and `get --domain acme.com` failed outright with "unknown
1494
+ * option". Both spellings now work on every verb; nothing that used to work stops working.
1495
+ */
1496
+ function requireDomainArg(positional, option) {
1497
+ const domain = readOption(positional) ?? readOption(option);
1498
+ if (!domain) {
1499
+ throw new Error("A domain is required \u2014 pass it as an argument or with --domain.");
1500
+ }
1501
+ return domain;
1502
+ }
1139
1503
  function buildPromptTemplatesCommand(surface, description) {
1140
1504
  return new Command(surface)
1141
1505
  .description(description)
@@ -1232,7 +1596,7 @@ export function createProgram() {
1232
1596
  });
1233
1597
  program
1234
1598
  .command("login")
1235
- .description("Connect this terminal to Oxygen.")
1599
+ .description("Connect this terminal to Oxygen. Opens the browser to approve this terminal (matching confirmation code shown in both); paste a token with --token instead if you prefer.")
1236
1600
  .option("--token <token>", "CLI API token created in the Oxygen dashboard.")
1237
1601
  .option("--api-url <url>", "Oxygen API URL. Defaults to OXYGEN_API_URL or https://oxygen-agent.com.")
1238
1602
  .option("--profile <name>", "Store credentials under a named CLI profile and make it active.")
@@ -1244,7 +1608,7 @@ export function createProgram() {
1244
1608
  });
1245
1609
  program
1246
1610
  .command("auth")
1247
- .description("Authentication helpers.")
1611
+ .description("Authentication helpers for scripts and agents (non-interactive token use). Day to day, `oxygen login` and `oxygen whoami` are all you need.")
1248
1612
  .addCommand(new Command("use-token")
1249
1613
  .description("Store a CLI API token non-interactively.")
1250
1614
  .requiredOption("--token <token>", "CLI API token.")
@@ -1263,7 +1627,7 @@ export function createProgram() {
1263
1627
  }));
1264
1628
  program
1265
1629
  .command("profiles")
1266
- .description("Manage stored CLI credential profiles.")
1630
+ .description("Manage stored CLI credential profiles (switch between workspaces and hosts). `oxygen login` creates or updates the active profile.")
1267
1631
  .addCommand(new Command("list")
1268
1632
  .description("List stored CLI credential profiles.")
1269
1633
  .option("--json", "Print a JSON envelope.")
@@ -1480,7 +1844,7 @@ export function createProgram() {
1480
1844
  }));
1481
1845
  program
1482
1846
  .command("support")
1483
- .description("File and track Oxygen support tickets.")
1847
+ .description("File and track Oxygen support tickets. Staff event automation: this CLI's `support admin events` command.")
1484
1848
  .addCommand(new Command("file")
1485
1849
  .description("File a support ticket. Use when you're stuck on an Oxygen operation.")
1486
1850
  .requiredOption("--subject <subject>", "One-line summary of the problem.")
@@ -1574,6 +1938,29 @@ export function createProgram() {
1574
1938
  .option("--json", "Print a JSON envelope.")
1575
1939
  .action(async (options) => {
1576
1940
  await handleAsyncAction("support admin list", options, () => requestOxygen(withSupportListQuery("/api/cli/admin/support/tickets", options)));
1941
+ }))
1942
+ .addCommand(new Command("events")
1943
+ .description("Read-only, zero-credit poll of body-free support event envelopes (staff only). Watermark polls replay 24h; dedupe by (ticket_ref, version).")
1944
+ .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).")
1945
+ .option("--limit <n>", "Events to return (1-100, defaults to 50).")
1946
+ .option("--json", "Print a JSON envelope.")
1947
+ .addHelpText("after", `
1948
+ Event schema (each data.events item has exactly these fields):
1949
+ event_kind: ticket_snapshot
1950
+ ticket_ref: UUID; version: decimal string; event_time: UTC timestamp
1951
+ status: open | triaging | waiting_on_user | resolved | closed | null
1952
+ severity: low | normal | high | null
1953
+ category: account | auth | billing | bug | data | deliverability |
1954
+ feature_request | feedback | integration | other | performance |
1955
+ provider | security | sequencer | tables | workflow | null
1956
+ source: slack | null; route: slack | cli | mcp | web | null
1957
+
1958
+ The cursor is opaque. has_more discriminates its phase: next_cursor continues a
1959
+ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retain
1960
+ (ticket_ref, version) as the idempotency key across every poll.
1961
+ `)
1962
+ .action(async (options) => {
1963
+ await handleAsyncAction("support admin events", options, () => requestOxygen(withSupportEventsQuery("/api/cli/admin/support/events", options)));
1577
1964
  }))
1578
1965
  .addCommand(new Command("get")
1579
1966
  .description("Show one support ticket with its message thread (staff only).")
@@ -1863,6 +2250,10 @@ export function createProgram() {
1863
2250
  .addCommand(new Command("list")
1864
2251
  .description("List scheduled posts.")
1865
2252
  .option("--status <status>", "Filter by draft, scheduled, queued, publishing, published, failed, or canceled.")
2253
+ .option("--approval-status <status>", "Filter by draft, needs_approval, approved, or rejected.")
2254
+ .option("--label <label>", "Only posts carrying this label.")
2255
+ .option("--campaign <campaign_id>", "Only posts in this campaign.")
2256
+ .option("--provider <provider>", "Filter by provider: linkedin, x, instagram, tiktok, facebook, or youtube.")
1866
2257
  .option("--limit <n>", "Maximum posts to return.")
1867
2258
  .option("--json", "Print a JSON envelope.")
1868
2259
  .action(async (options) => {
@@ -1984,6 +2375,78 @@ export function createProgram() {
1984
2375
  method: "POST",
1985
2376
  body: { max_credits: readPositiveNumber(options.maxCredits) },
1986
2377
  }));
2378
+ }))
2379
+ .addCommand(new Command("boost")
2380
+ .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`.")
2381
+ .argument("<post_id>", "Scheduled post id.")
2382
+ .requiredOption("--senders <ids>", "Comma-separated connected sender account ids that will engage.")
2383
+ .requiredOption("--actions <list>", "Comma-separated actions: reaction, comment.")
2384
+ .option("--max-actions-per-post <n>", "Hard cap on how many actions are planned.")
2385
+ .option("--max-credits <n>", "Credit ceiling for the boost (required — this is a paid action).")
2386
+ .option("--comment-pool <text>", "A comment to draw from. Repeatable. Required when --actions includes comment.", collectRepeatable, [])
2387
+ .option("--comment-pool-file <path>", "Read the comment pool from a file, one comment per line.")
2388
+ .option("--approved", "Confirm the real public engagement. Without it the command refuses and nothing is planned.")
2389
+ .option("--json", "Print a JSON envelope.")
2390
+ .action(async (postId, options) => {
2391
+ await handleAsyncAction("publishing posts boost", options, () => requestOxygen(`/api/cli/publishing/posts/${encodeURIComponent(postId)}/boost`, {
2392
+ method: "POST",
2393
+ body: buildPublishingBoostBody(postId, options),
2394
+ }));
2395
+ }))
2396
+ .addCommand(new Command("revisions")
2397
+ .description("List a post's content history: every revision with its number, title, text, author, and time.")
2398
+ .argument("<post_id>", "Scheduled post id.")
2399
+ .option("--json", "Print a JSON envelope.")
2400
+ .action(async (postId, options) => {
2401
+ await handleAsyncAction("publishing posts revisions", options, () => requestOxygen(`/api/cli/publishing/posts/${encodeURIComponent(postId)}/revisions`));
2402
+ }))
2403
+ .addCommand(new Command("restore")
2404
+ .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.")
2405
+ .argument("<post_id>", "Scheduled post id.")
2406
+ .requiredOption("--revision <n>", "Revision number to restore.")
2407
+ .option("--json", "Print a JSON envelope.")
2408
+ .action(async (postId, options) => {
2409
+ await handleAsyncAction("publishing posts restore", options, () => {
2410
+ const revision = readPositiveInt(options.revision);
2411
+ if (revision === undefined) {
2412
+ throw new OxygenError("invalid_request", "Pass --revision <n>.", { exitCode: 1 });
2413
+ }
2414
+ return requestOxygen(`/api/cli/publishing/posts/${encodeURIComponent(postId)}/revisions/${revision}/restore`, { method: "POST" });
2415
+ });
2416
+ }))
2417
+ .addCommand(new Command("notes")
2418
+ .description("List the internal review notes on a post. Notes are workspace-only — they are never published.")
2419
+ .argument("<post_id>", "Scheduled post id.")
2420
+ .option("--json", "Print a JSON envelope.")
2421
+ .action(async (postId, options) => {
2422
+ await handleAsyncAction("publishing posts notes", options, () => requestOxygen(`/api/cli/publishing/posts/${encodeURIComponent(postId)}/comments`));
2423
+ }))
2424
+ .addCommand(new Command("note")
2425
+ .description("Leave an internal review note on a post, or reply to one with --parent. Never published.")
2426
+ .argument("<post_id>", "Scheduled post id.")
2427
+ .option("--text <text>", "The note.")
2428
+ .option("--text-file <path>", "Read the note from a local file.")
2429
+ .option("--parent <comment_id>", "Reply to this note.")
2430
+ .option("--json", "Print a JSON envelope.")
2431
+ .action(async (postId, options) => {
2432
+ await handleAsyncAction("publishing posts note", options, () => {
2433
+ const parent = readOption(options.parent);
2434
+ return requestOxygen(`/api/cli/publishing/posts/${encodeURIComponent(postId)}/comments`, {
2435
+ method: "POST",
2436
+ body: {
2437
+ body: readPublishingPostText(options, true),
2438
+ ...(parent ? { parent_comment_id: parent } : {}),
2439
+ },
2440
+ });
2441
+ });
2442
+ }))
2443
+ .addCommand(new Command("resolve-note")
2444
+ .description("Mark a review note handled.")
2445
+ .argument("<post_id>", "Scheduled post id.")
2446
+ .requiredOption("--comment <comment_id>", "Note id from `publishing posts notes`.")
2447
+ .option("--json", "Print a JSON envelope.")
2448
+ .action(async (postId, options) => {
2449
+ await handleAsyncAction("publishing posts resolve-note", options, () => requestOxygen(`/api/cli/publishing/posts/${encodeURIComponent(postId)}/comments/${encodeURIComponent(options.comment)}/resolve`, { method: "POST" }));
1987
2450
  })))
1988
2451
  .addCommand(new Command("drafts")
1989
2452
  .description("Review the AI post-draft queue before anything reaches the publish queue.")
@@ -2065,6 +2528,238 @@ export function createProgram() {
2065
2528
  method: "POST",
2066
2529
  body: buildPublishingMediaUploadedBody(options),
2067
2530
  }));
2531
+ })))
2532
+ .addCommand(new Command("campaigns")
2533
+ .description("Group scheduled posts under a launch or theme, then filter the queue (`publishing posts list --campaign`) or scope an amplification policy to it.")
2534
+ .addCommand(new Command("list")
2535
+ .description("List campaigns with their post counts.")
2536
+ .option("--json", "Print a JSON envelope.")
2537
+ .action(async (options) => {
2538
+ await handleAsyncAction("publishing campaigns list", options, () => requestOxygen("/api/cli/publishing/campaigns"));
2539
+ }))
2540
+ .addCommand(new Command("create")
2541
+ .description("Create a campaign.")
2542
+ .requiredOption("--name <name>", "Campaign name.")
2543
+ .option("--goal <goal>", "What this campaign is for.")
2544
+ .option("--starts-at <iso>", "ISO date-time the campaign starts.")
2545
+ .option("--ends-at <iso>", "ISO date-time the campaign ends.")
2546
+ .option("--json", "Print a JSON envelope.")
2547
+ .action(async (options) => {
2548
+ await handleAsyncAction("publishing campaigns create", options, () => requestOxygen("/api/cli/publishing/campaigns", {
2549
+ method: "POST",
2550
+ body: buildPublishingCampaignBody(options, true),
2551
+ }));
2552
+ }))
2553
+ .addCommand(new Command("get")
2554
+ .description("Get one campaign with its posts.")
2555
+ .argument("<campaign_id>", "Campaign id.")
2556
+ .option("--json", "Print a JSON envelope.")
2557
+ .action(async (campaignId, options) => {
2558
+ await handleAsyncAction("publishing campaigns get", options, () => requestOxygen(`/api/cli/publishing/campaigns/${encodeURIComponent(campaignId)}`));
2559
+ }))
2560
+ .addCommand(new Command("update")
2561
+ .description("Update a campaign's name, goal, or window.")
2562
+ .argument("<campaign_id>", "Campaign id.")
2563
+ .option("--name <name>", "Campaign name.")
2564
+ .option("--goal <goal>", "What this campaign is for.")
2565
+ .option("--starts-at <iso>", "ISO date-time the campaign starts.")
2566
+ .option("--ends-at <iso>", "ISO date-time the campaign ends.")
2567
+ .option("--json", "Print a JSON envelope.")
2568
+ .action(async (campaignId, options) => {
2569
+ await handleAsyncAction("publishing campaigns update", options, () => requestOxygen(`/api/cli/publishing/campaigns/${encodeURIComponent(campaignId)}`, {
2570
+ method: "PATCH",
2571
+ body: buildPublishingCampaignBody(options, false),
2572
+ }));
2573
+ }))
2574
+ .addCommand(new Command("delete")
2575
+ .description("Delete a campaign. Its posts survive — they are just unlinked from it.")
2576
+ .argument("<campaign_id>", "Campaign id.")
2577
+ .option("--json", "Print a JSON envelope.")
2578
+ .action(async (campaignId, options) => {
2579
+ await handleAsyncAction("publishing campaigns delete", options, () => requestOxygen(`/api/cli/publishing/campaigns/${encodeURIComponent(campaignId)}`, {
2580
+ method: "DELETE",
2581
+ }));
2582
+ })))
2583
+ .addCommand(new Command("labels")
2584
+ .description("Tag scheduled posts. Labels drive queue filters and label-scoped amplification; they are never published.")
2585
+ .addCommand(new Command("set")
2586
+ .description("Add and/or remove labels on one post.")
2587
+ .argument("<post_id>", "Scheduled post id.")
2588
+ .option("--add <labels>", "Comma-separated labels to add.")
2589
+ .option("--remove <labels>", "Comma-separated labels to remove.")
2590
+ .option("--json", "Print a JSON envelope.")
2591
+ .action(async (postId, options) => {
2592
+ await handleAsyncAction("publishing labels set", options, () => requestOxygen(`/api/cli/publishing/posts/${encodeURIComponent(postId)}/labels`, {
2593
+ method: "POST",
2594
+ body: buildPublishingLabelsBody(options),
2595
+ }));
2596
+ })))
2597
+ .addCommand(new Command("analytics")
2598
+ .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.")
2599
+ .addCommand(new Command("post")
2600
+ .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.")
2601
+ .argument("<post_id>", "Scheduled post id.")
2602
+ .option("--since <date>", "ISO date to start the series from.")
2603
+ .option("--json", "Print a JSON envelope.")
2604
+ .action(async (postId, options) => {
2605
+ await handleAsyncAction("publishing analytics post", options, () => requestOxygen(buildPublishingAnalyticsPath(`/api/cli/publishing/analytics/posts/${encodeURIComponent(postId)}`, { since: readOption(options.since) })));
2606
+ }))
2607
+ .addCommand(new Command("account")
2608
+ .description("Per-account metric series for the connected publishing accounts.")
2609
+ .option("--provider <provider>", "Provider to read. Defaults to linkedin.")
2610
+ .option("--since <date>", "ISO date to start the series from.")
2611
+ .option("--json", "Print a JSON envelope.")
2612
+ .action(async (options) => {
2613
+ await handleAsyncAction("publishing analytics account", options, () => requestOxygen(buildPublishingAnalyticsPath("/api/cli/publishing/analytics/account", {
2614
+ provider: readOption(options.provider),
2615
+ since: readOption(options.since),
2616
+ })));
2617
+ }))
2618
+ .addCommand(new Command("emv")
2619
+ .description("Set the workspace CPM used for earned-media value, per channel. EMV stays null on any channel whose provider does not expose impressions.")
2620
+ .requiredOption("--cpm-json <json>", "Per-channel CPM object, e.g. '{\"linkedin\":30,\"x\":12}'.")
2621
+ .option("--json", "Print a JSON envelope.")
2622
+ .action(async (options) => {
2623
+ await handleAsyncAction("publishing analytics emv", options, () => requestOxygen("/api/cli/publishing/analytics/emv", {
2624
+ method: "POST",
2625
+ body: { cpm: parseJsonObject(options.cpmJson ?? "") },
2626
+ }));
2627
+ })))
2628
+ .addCommand(new Command("import")
2629
+ .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.")
2630
+ .requiredOption("--file <path>", "Path to a .csv (header row) or .json file ([rows] or { rows: [...] }).")
2631
+ .option("--dry-run", "Parse, lint, and report without writing. Default.")
2632
+ .option("--approved", "Write the parsed rows into the queue as needs-approval drafts.")
2633
+ .option("--campaign <campaign_id>", "Campaign for every imported post.")
2634
+ .option("--label <labels>", "Comma-separated labels for every imported post.")
2635
+ .option("--json", "Print a JSON envelope.")
2636
+ .action(async (options) => {
2637
+ await handleAsyncAction("publishing import", options, () => requestOxygen("/api/cli/publishing/import", {
2638
+ method: "POST",
2639
+ body: buildPublishingImportBody(options),
2640
+ }));
2641
+ }))
2642
+ .addCommand(new Command("amplification")
2643
+ .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.")
2644
+ .addCommand(new Command("list")
2645
+ .description("List amplification policies with their scope, caps, and enabled state.")
2646
+ .option("--json", "Print a JSON envelope.")
2647
+ .action(async (options) => {
2648
+ await handleAsyncAction("publishing amplification list", options, () => requestOxygen("/api/cli/publishing/amplification"));
2649
+ }))
2650
+ .addCommand(new Command("create")
2651
+ .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.")
2652
+ .requiredOption("--name <name>", "Policy name.")
2653
+ .requiredOption("--scope <kind>", "What it amplifies: all, campaign, label, or post.")
2654
+ .option("--campaign <campaign_id>", "Campaign to scope to (with --scope campaign).")
2655
+ .option("--label <label>", "Label to scope to (with --scope label).")
2656
+ .option("--post <post_id>", "Post to scope to (with --scope post).")
2657
+ .requiredOption("--senders <ids>", "Comma-separated connected sender account ids that will engage.")
2658
+ .requiredOption("--actions <list>", "Comma-separated actions: reaction, comment.")
2659
+ .option("--max-actions-per-post <n>", "Hard cap on actions per post.")
2660
+ .option("--max-credits <n>", "Hard credit cap per cycle (required — this policy spends credits).")
2661
+ .option("--comment-pool <text>", "A comment to draw from. Repeatable. Required when --actions includes comment.", collectRepeatable, [])
2662
+ .option("--comment-pool-file <path>", "Read the comment pool from a file, one comment per line.")
2663
+ .option("--reaction-delay-min <seconds>", "Lower bound of the randomized reaction delay.")
2664
+ .option("--reaction-delay-max <seconds>", "Upper bound of the randomized reaction delay.")
2665
+ .option("--comment-delay-min <seconds>", "Lower bound of the randomized comment delay.")
2666
+ .option("--comment-delay-max <seconds>", "Upper bound of the randomized comment delay.")
2667
+ .option("--approved", "Grant the standing permission. Without it the command refuses and creates nothing.")
2668
+ .option("--json", "Print a JSON envelope.")
2669
+ .action(async (options) => {
2670
+ await handleAsyncAction("publishing amplification create", options, () => requestOxygen("/api/cli/publishing/amplification", {
2671
+ method: "POST",
2672
+ body: buildPublishingAmplificationCreateBody(options),
2673
+ }));
2674
+ }))
2675
+ .addCommand(new Command("get")
2676
+ .description("Get one policy: scope, participants, caps, pacing, enabled state, and standing approval.")
2677
+ .argument("<policy_id>", "Amplification policy id.")
2678
+ .option("--json", "Print a JSON envelope.")
2679
+ .action(async (policyId, options) => {
2680
+ await handleAsyncAction("publishing amplification get", options, () => requestOxygen(`/api/cli/publishing/amplification/${encodeURIComponent(policyId)}`));
2681
+ }))
2682
+ .addCommand(new Command("update")
2683
+ .description("Update a policy's participants, actions, caps, or pacing. Tightening a cap takes effect on the next cycle.")
2684
+ .argument("<policy_id>", "Amplification policy id.")
2685
+ .option("--name <name>", "Policy name.")
2686
+ .option("--senders <ids>", "Comma-separated connected sender account ids.")
2687
+ .option("--actions <list>", "Comma-separated actions: reaction, comment.")
2688
+ .option("--max-actions-per-post <n>", "Hard cap on actions per post.")
2689
+ .option("--max-credits <n>", "Hard credit cap per cycle.")
2690
+ .option("--comment-pool <text>", "A comment to draw from. Repeatable.", collectRepeatable, [])
2691
+ .option("--comment-pool-file <path>", "Read the comment pool from a file, one comment per line.")
2692
+ .option("--reaction-delay-min <seconds>", "Lower bound of the randomized reaction delay.")
2693
+ .option("--reaction-delay-max <seconds>", "Upper bound of the randomized reaction delay.")
2694
+ .option("--comment-delay-min <seconds>", "Lower bound of the randomized comment delay.")
2695
+ .option("--comment-delay-max <seconds>", "Upper bound of the randomized comment delay.")
2696
+ .option("--json", "Print a JSON envelope.")
2697
+ .action(async (policyId, options) => {
2698
+ await handleAsyncAction("publishing amplification update", options, () => requestOxygen(`/api/cli/publishing/amplification/${encodeURIComponent(policyId)}`, {
2699
+ method: "PATCH",
2700
+ body: buildPublishingAmplificationUpdateBody(options),
2701
+ }));
2702
+ }))
2703
+ .addCommand(new Command("enable")
2704
+ .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.")
2705
+ .argument("<policy_id>", "Amplification policy id.")
2706
+ .option("--approved", "Confirm arming the standing public engagement. Without it nothing is armed.")
2707
+ .option("--json", "Print a JSON envelope.")
2708
+ .action(async (policyId, options) => {
2709
+ await handleAsyncAction("publishing amplification enable", options, () => {
2710
+ if (options.approved !== true) {
2711
+ 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 });
2712
+ }
2713
+ return requestOxygen(`/api/cli/publishing/amplification/${encodeURIComponent(policyId)}/enable`, { method: "POST" });
2714
+ });
2715
+ }))
2716
+ .addCommand(new Command("disable")
2717
+ .description("Disarm a policy and skip every action it had already planned. Always allowed — the off switch is never gated.")
2718
+ .argument("<policy_id>", "Amplification policy id.")
2719
+ .option("--json", "Print a JSON envelope.")
2720
+ .action(async (policyId, options) => {
2721
+ await handleAsyncAction("publishing amplification disable", options, () => requestOxygen(`/api/cli/publishing/amplification/${encodeURIComponent(policyId)}/disable`, {
2722
+ method: "POST",
2723
+ }));
2724
+ }))
2725
+ .addCommand(new Command("delete")
2726
+ .description("Delete a policy.")
2727
+ .argument("<policy_id>", "Amplification policy id.")
2728
+ .option("--json", "Print a JSON envelope.")
2729
+ .action(async (policyId, options) => {
2730
+ await handleAsyncAction("publishing amplification delete", options, () => requestOxygen(`/api/cli/publishing/amplification/${encodeURIComponent(policyId)}`, {
2731
+ method: "DELETE",
2732
+ }));
2733
+ }))
2734
+ .addCommand(new Command("actions")
2735
+ .description("The policy's action ledger: what each participant account did (or is about to do) on which post, with status and cost.")
2736
+ .argument("<policy_id>", "Amplification policy id.")
2737
+ .option("--status <status>", "Filter by planned, dispatched, failed, or skipped.")
2738
+ .option("--json", "Print a JSON envelope.")
2739
+ .action(async (policyId, options) => {
2740
+ await handleAsyncAction("publishing amplification actions", options, () => requestOxygen(buildPublishingAnalyticsPath(`/api/cli/publishing/amplification/${encodeURIComponent(policyId)}/actions`, { status: readOption(options.status) })));
2741
+ })))
2742
+ .addCommand(new Command("ideas")
2743
+ .description("The content backlog: ideas parked for later, with no publish time and no AI spend.")
2744
+ .addCommand(new Command("add")
2745
+ .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`.")
2746
+ .option("--text <text>", "The idea.")
2747
+ .option("--text-file <path>", "Read the idea from a local file.")
2748
+ .option("--topic <topic>", "Optional topic tag.")
2749
+ .option("--channels <list>", "Comma-separated channels this idea is meant for.")
2750
+ .option("--json", "Print a JSON envelope.")
2751
+ .action(async (options) => {
2752
+ await handleAsyncAction("publishing ideas add", options, () => requestOxygen("/api/cli/publishing/drafts", {
2753
+ method: "POST",
2754
+ body: buildPublishingIdeaBody(options),
2755
+ }));
2756
+ }))
2757
+ .addCommand(new Command("list")
2758
+ .description("List the idea backlog.")
2759
+ .option("--limit <n>", "Maximum ideas to return.")
2760
+ .option("--json", "Print a JSON envelope.")
2761
+ .action(async (options) => {
2762
+ await handleAsyncAction("publishing ideas list", options, () => requestOxygen(buildPublishingDraftsListPath({ ...options, kind: "idea" })));
2068
2763
  })));
2069
2764
  program
2070
2765
  .command("dashboards")
@@ -2126,7 +2821,6 @@ export function createProgram() {
2126
2821
  .requiredOption("--display-name <name>", "Human-readable object name, such as Projects.")
2127
2822
  .requiredOption("--columns-json <json>", "JSON array of column definitions {key,label,dataType,semanticType,...}.")
2128
2823
  .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
2824
  .option("--label-column <key>", "Column key to use as the record label. Defaults to the isRecordLabel column or the first column.")
2131
2825
  .option("--singular-name <name>", "Singular display name, such as Project.")
2132
2826
  .option("--plural-name <name>", "Plural display name, such as Projects.")
@@ -2196,7 +2890,35 @@ export function createProgram() {
2196
2890
  await handleAsyncAction("crm get", options, () => requestOxygen(`/api/cli/crm/objects/${encodeURIComponent(object)}/records/${encodeURIComponent(rowId)}`));
2197
2891
  }))
2198
2892
  .addCommand(new Command("relationships")
2199
- .description("Manage CRM record relationships.")
2893
+ .description("Define CRM relationships and manage record relationship edges.")
2894
+ .addCommand(new Command("define")
2895
+ .description("Define a relationship (and its relation column) from a custom object to any object. Defaults to dry-run.")
2896
+ .argument("<object>", "Source CRM object slug. Must be a custom object, such as projects.")
2897
+ .argument("<slug>", "Relationship slug — also the relation column key on the source, such as client.")
2898
+ .requiredOption("--target-object <object>", "Target CRM object slug, custom or standard (companies, people, deals, ...).")
2899
+ .option("--display-name <name>", "Display name for the relation column. Defaults to the slug in Title Case.")
2900
+ .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.")
2901
+ .option("--inverse-slug <slug>", "Inverse relationship slug on the target. On a custom target this also creates the inverse relation column.")
2902
+ .option("--inverse-display-name <name>", "Display name for the inverse side.")
2903
+ .option("--dry-run", "Preview the relationship definition without writing.")
2904
+ .option("--live", "Apply the definition. Default is dry-run.")
2905
+ .option("--json", "Print a JSON envelope.")
2906
+ .action(async (object, slug, options) => {
2907
+ await handleAsyncAction("crm relationships define", options, () => requestOxygen(`/api/cli/crm/objects/${encodeURIComponent(object)}/relationships`, {
2908
+ method: "POST",
2909
+ body: {
2910
+ relationship: {
2911
+ slug,
2912
+ target_object: options.targetObject,
2913
+ ...(options.displayName ? { display_name: options.displayName } : {}),
2914
+ ...(options.cardinality ? { cardinality: options.cardinality } : {}),
2915
+ ...(options.inverseSlug ? { inverse_slug: options.inverseSlug } : {}),
2916
+ ...(options.inverseDisplayName ? { inverse_display_name: options.inverseDisplayName } : {}),
2917
+ },
2918
+ mode: options.live ? "live" : "dry_run",
2919
+ },
2920
+ }));
2921
+ }))
2200
2922
  .addCommand(new Command("upsert")
2201
2923
  .description("Create or replace a CRM relationship edge. Defaults to dry-run.")
2202
2924
  .argument("<object>", "Source CRM object slug, such as companies or people.")
@@ -2899,6 +3621,133 @@ export function createProgram() {
2899
3621
  });
2900
3622
  });
2901
3623
  }));
3624
+ tablesCommand.addCommand(new Command("relate")
3625
+ .description("Relate two tables: define a relation column on the source that links rows to the target table. Works on ANY table — plain tables are registered under the hood automatically. Defaults to dry-run.")
3626
+ .argument("<table>", "Source table id or slug.")
3627
+ .argument("<slug>", "Relation slug — also the relation column key on the source, such as client.")
3628
+ .requiredOption("--target-table <table>", "Target table id or slug. May also be a CRM object table (companies, people, deals).")
3629
+ .option("--display-name <name>", "Display name for the relation column. Defaults to the slug in Title Case.")
3630
+ .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 row links many target rows and each target row links back to one source.")
3631
+ .option("--inverse-slug <slug>", "Inverse relation slug on the target; also creates the inverse relation column there.")
3632
+ .option("--inverse-display-name <name>", "Display name for the inverse side.")
3633
+ .option("--dry-run", "Preview the relation plan without writing.")
3634
+ .option("--live", "Apply the relation. Default is dry-run.")
3635
+ .option("--json", "Print a JSON envelope.")
3636
+ .action(async (table, slug, options) => {
3637
+ await handleAsyncAction("tables relate", options, () => requestOxygen("/api/cli/tables/relations", {
3638
+ method: "POST",
3639
+ body: {
3640
+ table,
3641
+ relationship: {
3642
+ slug,
3643
+ target_table: options.targetTable,
3644
+ ...(options.displayName ? { display_name: options.displayName } : {}),
3645
+ ...(options.cardinality ? { cardinality: options.cardinality } : {}),
3646
+ ...(options.inverseSlug ? { inverse_slug: options.inverseSlug } : {}),
3647
+ ...(options.inverseDisplayName ? { inverse_display_name: options.inverseDisplayName } : {}),
3648
+ },
3649
+ mode: options.live ? "live" : "dry_run",
3650
+ },
3651
+ }));
3652
+ }))
3653
+ .addCommand(new Command("link")
3654
+ .description("Link one row to a row in the related table through a relation defined with `tables relate`. Defaults to dry-run.")
3655
+ .argument("<table>", "Source table id or slug.")
3656
+ .argument("<row_id>", "Source row id.")
3657
+ .requiredOption("--relation <slug>", "Relation slug defined on the source table, such as client.")
3658
+ .requiredOption("--target-row-id <row_id>", "Target row id in the related table.")
3659
+ .option("--dry-run", "Preview the link (and any cardinality replacements) without writing.")
3660
+ .option("--live", "Apply the link. Default is dry-run.")
3661
+ .option("--json", "Print a JSON envelope.")
3662
+ .action(async (table, rowId, options) => {
3663
+ await handleAsyncAction("tables link", options, () => requestOxygen("/api/cli/tables/relations/link", {
3664
+ method: "POST",
3665
+ body: {
3666
+ table,
3667
+ row_id: rowId,
3668
+ relation: options.relation,
3669
+ target_row_id: options.targetRowId,
3670
+ mode: options.live ? "live" : "dry_run",
3671
+ },
3672
+ }));
3673
+ }))
3674
+ .addCommand(new Command("promote")
3675
+ .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>`.")
3676
+ .argument("<table>", "Table id or slug whose bound rows to promote.")
3677
+ .option("--object <slug>", "CRM object to promote onto (companies, people, deals, ...). Inferred when the table has exactly one bind column.")
3678
+ .requiredOption("--map <pairs>", "Column-to-attribute mapping as column=attribute pairs (e.g. job_title=job_title,seniority=seniority) or a JSON array/object.")
3679
+ .option("--policy <policy>", "Conflict policy: fill-empty-only (default), overwrite, or skip-conflicts.", "fill-empty-only")
3680
+ .option("--row-id <id...>", "Promote only these row ids. Defaults to every bound row.")
3681
+ .option("--dry-run", "Preview per-field would_fill/would_overwrite/conflicts without writing (the default when --approved is absent).")
3682
+ .option("--approved", "Confirm the truth write onto CRM records after inspecting the preview.")
3683
+ .option("--max-concurrency <n>", "Maximum concurrent row items for the run. Defaults to 50.")
3684
+ .option("--json", "Print a JSON envelope.")
3685
+ .action(async (table, options) => {
3686
+ if (options.dryRun && options.approved) {
3687
+ throw new OxygenError("invalid_promote", "Pass either --dry-run or --approved, not both.", { exitCode: 1 });
3688
+ }
3689
+ const mappings = parsePromoteMapOption(options.map);
3690
+ const policy = normalizePromotePolicy(options.policy);
3691
+ const maxConcurrency = readPositiveInt(options.maxConcurrency);
3692
+ await handleAsyncAction("tables promote", options, () => requestOxygen("/api/cli/tables/promote", {
3693
+ method: "POST",
3694
+ body: {
3695
+ table,
3696
+ ...(readOption(options.object) ? { object: readOption(options.object) } : {}),
3697
+ mappings,
3698
+ policy,
3699
+ ...(options.rowId && options.rowId.length > 0 ? { row_ids: options.rowId } : {}),
3700
+ // Preview by default; --approved is the only path that writes.
3701
+ ...(options.approved ? { approved: true } : { dry_run: true }),
3702
+ ...(maxConcurrency ? { max_concurrency: maxConcurrency } : {}),
3703
+ },
3704
+ }));
3705
+ }));
3706
+ tablesCommand.addCommand(new Command("dedupe")
3707
+ .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).")
3708
+ .argument("<table>", "Table id or slug to dedupe.")
3709
+ .requiredOption("--on <cols>", "Key column(s): one column, or two comma-separated for a composite key (e.g. email or first_name,company).")
3710
+ .option("--normalize <mode>", "Match mode: exact (default), email, domain, linkedin, or fuzzy-label. Fuzzy allowed only as a single key.", "exact")
3711
+ .option("--keep <policy>", "Survivor per duplicate group: oldest (default), newest, or most-complete.", "oldest")
3712
+ .option("--merge-values <mode>", "With --apply, fill the survivor's blank cells from the losers first: fill-empty.")
3713
+ .option("--scan-limit <n>", "Max rows to scan. Defaults to 50000, capped at 200000.")
3714
+ .option("--apply", "Delete the duplicate rows (losers). Without this flag the command only previews.")
3715
+ .option("--approved", "Confirm the deletion (required with --apply); without it the API returns the preview to inspect first.")
3716
+ .option("--against <table>", "Cross-table check: report rows whose --on value already exists in this other table (read-only).")
3717
+ .option("--against-column <col>", "Column in the --against table to match against. Required with --against.")
3718
+ .option("--json", "Print a JSON envelope.")
3719
+ .action(async (table, options) => {
3720
+ await handleAsyncAction("tables dedupe", options, () => {
3721
+ const request = buildDedupeRequest(table, options);
3722
+ return requestOxygen(request.endpoint, { method: "POST", body: request.body });
3723
+ });
3724
+ }));
3725
+ tablesCommand.addCommand(new Command("send")
3726
+ .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.")
3727
+ .argument("<source>", "Source table id or slug.")
3728
+ .argument("<target>", "Target table id or slug (must be a different table).")
3729
+ .option("--map <pairs>", "target=source column pairs, comma-separated and/or repeated (e.g. --map company=company_name,website=domain).", collectRepeatable, [])
3730
+ .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.')
3731
+ .option("--automap", "Match columns automatically (exact key, then case-insensitive label). The resolved mapping is echoed back.")
3732
+ .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.')
3733
+ .option("--flatten-path <path>", "Dot-path to the array inside the --flatten column's value.")
3734
+ .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.')
3735
+ .option("--mode <mode>", "insert (append, default) or upsert (requires --upsert-key).", "insert")
3736
+ .option("--upsert-key <key>", "Mapped target column that identifies existing rows for upsert.")
3737
+ .option("--page-size <n>", "Rows copied per durable page. Defaults to 500, max 2000.")
3738
+ .option("--dry-run", "Validate and preview only; nothing is written.")
3739
+ .option("--json", "Print a JSON envelope.")
3740
+ .action(async (source, target, options) => {
3741
+ try {
3742
+ const body = buildTablesSendRequest(source, target, options);
3743
+ const data = await requestOxygen("/api/cli/tables/send", { method: "POST", body });
3744
+ emitSuccess("tables send", data, options);
3745
+ writeTablesSendHint(data);
3746
+ }
3747
+ catch (error) {
3748
+ emitCliFailure("tables send", error);
3749
+ }
3750
+ }));
2902
3751
  tablesCommand.addCommand(new Command("webhook")
2903
3752
  .description("Create and manage direct table webhooks.")
2904
3753
  .addCommand(new Command("list")
@@ -2932,7 +3781,7 @@ export function createProgram() {
2932
3781
  .option("--event-type-path <path>", "Dot path to the event type. Defaults to type or event.")
2933
3782
  .option("--occurred-at-path <path>", "Dot path to the event timestamp.")
2934
3783
  .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())
3784
+ .option("--auto-run-max-credits <n>", "Credit ceiling per webhook-triggered auto-run batch; items beyond it are skipped with credit_limit_reached.")
2936
3785
  .option("--auto-run-max-concurrency <n>", "Maximum concurrent row items for webhook-triggered auto-runs.")
2937
3786
  .option("--auto-run-force", "Run auto-run columns even when the target cell already has a value.")
2938
3787
  .option("--auto-run-connection-id <connection_id>", "Optional provider integration connection id for auto-runs.")
@@ -2963,7 +3812,7 @@ export function createProgram() {
2963
3812
  .option("--status <status>", "active or disabled.")
2964
3813
  .option("--auth-mode <mode>", "none or secret. Setting secret rotates and returns a new webhook secret.")
2965
3814
  .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())
3815
+ .option("--auto-run-max-credits <n>", "Credit ceiling per webhook-triggered auto-run batch; items beyond it are skipped with credit_limit_reached.")
2967
3816
  .option("--auto-run-max-concurrency <n>", "Maximum concurrent row items for webhook-triggered auto-runs.")
2968
3817
  .option("--auto-run-force", "Run auto-run columns even when the target cell already has a value.")
2969
3818
  .option("--auto-run-connection-id <connection_id>", "Optional provider integration connection id for auto-runs.")
@@ -3067,6 +3916,125 @@ export function createProgram() {
3067
3916
  .argument("<view>", "View id.")
3068
3917
  .option("--json", "Print a JSON envelope.")
3069
3918
  .action((table, view, options) => handleAsyncAction("tables views delete", options, () => requestOxygen(`/api/cli/tables/views?table=${encodeURIComponent(table)}&view=${encodeURIComponent(view)}`, { method: "DELETE" })))));
3919
+ tablesCommand.addCommand(new Command("auto-run")
3920
+ .description("Inspect and manage a table's standing auto-run configuration (columns queued automatically when new rows are written).")
3921
+ .addCommand(new Command("get")
3922
+ .description("Show a table's standing auto-run configuration (columns queued automatically when new rows are written).")
3923
+ .argument("<table>", "Table id or slug.")
3924
+ .option("--json", "Print a JSON envelope.")
3925
+ .action((table, options) => handleAsyncAction("tables auto-run get", options, () => {
3926
+ const params = new URLSearchParams({ table });
3927
+ return requestOxygen(`/api/cli/tables/auto-run?${params.toString()}`);
3928
+ })))
3929
+ .addCommand(new Command("set")
3930
+ .description("Enable a standing auto-run: automatically queue the given columns for rows written to this table (imports, inserts, upserts). Default off.")
3931
+ .argument("<table>", "Table id or slug.")
3932
+ .requiredOption("--columns <csv>", "Comma-separated column keys to auto-run on newly written rows (tool, AI, formula, enrichment, bind, or lookup columns).")
3933
+ .option("--sources <csv>", "Which write sources trigger the auto-run: any of import, insert, upsert, send. Defaults to all when omitted.")
3934
+ .option("--force", "Re-run the columns even for rows that already have values.")
3935
+ .option("--max-credits <n>", "Credit ceiling PER enqueued auto-run batch; rows beyond it are skipped with credit_limit_reached.")
3936
+ .option("--max-concurrency <n>", "Maximum concurrent row items per auto-run batch.")
3937
+ .option("--json", "Print a JSON envelope.")
3938
+ .action((table, options) => {
3939
+ const columns = readCsvOption(options.columns);
3940
+ const sources = readCsvOption(options.sources);
3941
+ const maxCredits = readPositiveNumber(options.maxCredits);
3942
+ const maxConcurrency = readPositiveInt(options.maxConcurrency);
3943
+ const force = Boolean(options.force);
3944
+ return handleAsyncAction("tables auto-run set", options, () => requestOxygen("/api/cli/tables/auto-run", {
3945
+ method: "POST",
3946
+ body: {
3947
+ table,
3948
+ columns,
3949
+ ...(sources.length ? { sources } : {}),
3950
+ ...(force ? { force: true } : {}),
3951
+ ...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
3952
+ ...(maxConcurrency ? { max_concurrency: maxConcurrency } : {}),
3953
+ },
3954
+ }));
3955
+ }))
3956
+ .addCommand(new Command("disable")
3957
+ .description("Disable (clear) a table's standing auto-run configuration.")
3958
+ .argument("<table>", "Table id or slug.")
3959
+ .option("--json", "Print a JSON envelope.")
3960
+ .action((table, options) => handleAsyncAction("tables auto-run disable", options, () => requestOxygen("/api/cli/tables/auto-run", {
3961
+ method: "DELETE",
3962
+ body: { table },
3963
+ })))));
3964
+ tablesCommand.addCommand(new Command("auto-dedupe")
3965
+ .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).")
3966
+ .addCommand(new Command("get")
3967
+ .description("Show a table's standing auto-dedupe configuration.")
3968
+ .argument("<table>", "Table id or slug.")
3969
+ .option("--json", "Print a JSON envelope.")
3970
+ .action((table, options) => handleAsyncAction("tables auto-dedupe get", options, () => {
3971
+ const params = new URLSearchParams({ table });
3972
+ return requestOxygen(`/api/cli/tables/auto-dedupe?${params.toString()}`);
3973
+ })))
3974
+ .addCommand(new Command("set")
3975
+ .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.")
3976
+ .argument("<table>", "Table id or slug.")
3977
+ .requiredOption("--on <cols>", "Key column(s): one column, or two comma-separated for a composite key (e.g. email or first_name,company).")
3978
+ .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).")
3979
+ .option("--keep <policy>", "Survivor per duplicate group (existing rows included): oldest (default), newest, or most-complete.", "oldest")
3980
+ .option("--merge-values <mode>", "Fill the survivor's blank cells from the losers before deleting them: fill-empty.")
3981
+ .option("--json", "Print a JSON envelope.")
3982
+ .action((table, options) => handleAsyncAction("tables auto-dedupe set", options, () => requestOxygen("/api/cli/tables/auto-dedupe", {
3983
+ method: "POST",
3984
+ body: buildTablesAutoDedupeSetBody(table, options),
3985
+ }))))
3986
+ .addCommand(new Command("disable")
3987
+ .description("Disable (clear) a table's standing auto-dedupe configuration.")
3988
+ .argument("<table>", "Table id or slug.")
3989
+ .option("--json", "Print a JSON envelope.")
3990
+ .action((table, options) => handleAsyncAction("tables auto-dedupe disable", options, () => requestOxygen("/api/cli/tables/auto-dedupe", {
3991
+ method: "DELETE",
3992
+ body: { table },
3993
+ })))));
3994
+ tablesCommand.addCommand(new Command("schedule")
3995
+ .description("Scheduled column refresh: a cron workflow re-runs one column on a schedule (only empty/failed cells unless --force). One schedule per column.")
3996
+ .addCommand(new Command("set")
3997
+ .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.")
3998
+ .argument("<table>", "Table id or slug.")
3999
+ .argument("<column>", "Column key or id (tool, AI, formula, or enrichment kind).")
4000
+ .option("--cron <expr>", 'Cron expression in UTC, e.g. "0 9 * * *".')
4001
+ .option("--every <sugar>", "Shorthand schedule: hourly, daily@<hour>, or weekly@<day><hour> (e.g. weekly@mon9). Hours 0-23 UTC.")
4002
+ .option("--max-credits <n>", "Credit ceiling PER scheduled refresh; required with --approved for paid columns.")
4003
+ .option("--approved", "Approve the recurring paid runs; required with --max-credits for paid columns.")
4004
+ .option("--force", "Re-run cells that already have values on every tick (full refresh instead of fill-missing).")
4005
+ .option("--json", "Print a JSON envelope.")
4006
+ .action((table, column, options) => {
4007
+ const maxCredits = readPositiveNumber(options.maxCredits);
4008
+ return handleAsyncAction("tables schedule set", options, () => requestOxygen("/api/cli/tables/schedule", {
4009
+ method: "POST",
4010
+ body: {
4011
+ table,
4012
+ column,
4013
+ ...(readOption(options.cron) ? { cron: readOption(options.cron) } : {}),
4014
+ ...(readOption(options.every) ? { every: readOption(options.every) } : {}),
4015
+ ...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
4016
+ ...(options.approved ? { approved: true } : {}),
4017
+ ...(options.force ? { force: true } : {}),
4018
+ },
4019
+ }));
4020
+ }))
4021
+ .addCommand(new Command("list")
4022
+ .description("List a table's scheduled column refreshes.")
4023
+ .argument("<table>", "Table id or slug.")
4024
+ .option("--json", "Print a JSON envelope.")
4025
+ .action((table, options) => handleAsyncAction("tables schedule list", options, () => {
4026
+ const params = new URLSearchParams({ table });
4027
+ return requestOxygen(`/api/cli/tables/schedule?${params.toString()}`);
4028
+ })))
4029
+ .addCommand(new Command("remove")
4030
+ .description("Remove a column's scheduled refresh (deletes the backing cron workflow; future scheduled runs stop).")
4031
+ .argument("<table>", "Table id or slug.")
4032
+ .argument("<column>", "Column key or id.")
4033
+ .option("--json", "Print a JSON envelope.")
4034
+ .action((table, column, options) => handleAsyncAction("tables schedule remove", options, () => requestOxygen("/api/cli/tables/schedule", {
4035
+ method: "DELETE",
4036
+ body: { table, column },
4037
+ })))));
3070
4038
  program
3071
4039
  .command("context")
3072
4040
  .description("Workspace-level GTM context commands.")
@@ -3505,7 +4473,7 @@ export function createProgram() {
3505
4473
  .option("--enabled", "Enable unattended synthesis.")
3506
4474
  .option("--disabled", "Disable unattended synthesis.")
3507
4475
  .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).")
4476
+ .option("--max-credits-per-day <n>", "Managed-credit cap per UTC day (0-10,000, default 100).")
3509
4477
  .option("--json", "Print a JSON envelope.")
3510
4478
  .action(async (options) => {
3511
4479
  await handleAsyncAction("knowledge agent set", options, () => {
@@ -3560,7 +4528,7 @@ export function createProgram() {
3560
4528
  }));
3561
4529
  program
3562
4530
  .command("blueprints")
3563
- .description("Portable Oxygen blueprints: bundle a workflow + tables + columns + prompts as shareable JSON.")
4531
+ .description("Scaffolding bundles: a workflow + tables + columns + prompts as shareable JSON. For guided GTM plays see `oxygen recipes`.")
3564
4532
  .addCommand(new Command("list")
3565
4533
  .description("List Oxygen blueprints visible in this workspace (seeds + saved).")
3566
4534
  .argument("[query]", "Search text.")
@@ -3794,6 +4762,64 @@ export function createProgram() {
3794
4762
  return requestOxygen(`/api/blueprints/marketplace${qs}`, { requireAuth: false });
3795
4763
  });
3796
4764
  }));
4765
+ program
4766
+ .command("recipes")
4767
+ .description("Business-case GTM playbooks: proven plays with prerequisites, credit posture, and approval gates spelled out.")
4768
+ .addCommand(new Command("list")
4769
+ .description("List recipes, optionally filtered by text, business-case category, journey stage, or audience.")
4770
+ .argument("[query]", "Search text (matches slug/title/business case/tags).")
4771
+ .option("--category <category>", "Business-case category (e.g. start-here, pipeline-from-zero).")
4772
+ .option("--stage <stage>", "Journey stage: day-1, day-7, day-30, or ongoing.")
4773
+ .option("--audience <audience>", "founder, gtm-operator, or agency-operator.")
4774
+ .option("--json", "Print a JSON envelope.")
4775
+ .action(async (query, options) => {
4776
+ await handleAsyncAction("recipes list", options, () => {
4777
+ const params = new URLSearchParams();
4778
+ if (query)
4779
+ params.set("query", query);
4780
+ const category = readOption(options.category);
4781
+ if (category)
4782
+ params.set("category", category);
4783
+ const stage = readOption(options.stage);
4784
+ if (stage)
4785
+ params.set("stage", stage);
4786
+ const audience = readOption(options.audience);
4787
+ if (audience)
4788
+ params.set("audience", audience);
4789
+ const qs = params.toString() ? `?${params.toString()}` : "";
4790
+ return requestOxygen(`/api/cli/recipes${qs}`);
4791
+ });
4792
+ }))
4793
+ .addCommand(new Command("show")
4794
+ .description("Show one recipe: the full playbook body plus prerequisites, credits, and approval gates.")
4795
+ .argument("<slug>", "Recipe slug, e.g. outbound-pilot-50.")
4796
+ .option("--json", "Print a JSON envelope.")
4797
+ .action(async (slug, options) => {
4798
+ await handleAsyncAction("recipes show", options, () => requestOxygen("/api/cli/recipes/get", {
4799
+ method: "POST",
4800
+ body: { slug },
4801
+ }));
4802
+ }))
4803
+ .addCommand(new Command("install")
4804
+ .description("Install a recipe into the workspace wiki as a playbook page (revisioned, retrieval-grounded, with per-version provenance).")
4805
+ .argument("<slug>", "Recipe slug, e.g. outbound-pilot-50.")
4806
+ .option("--slug <wiki_slug>", "Override the target wiki slug (default playbook-<recipe-slug>).")
4807
+ .option("--draft", "Install as a draft page (drafts never ground AI actions until activated).")
4808
+ .option("--force", "Overwrite a customized installed copy (edits survive as a page revision).")
4809
+ .option("--json", "Print a JSON envelope.")
4810
+ .action(async (slug, options) => {
4811
+ await handleAsyncAction("recipes install", options, () => {
4812
+ const body = { slug };
4813
+ const wikiSlug = readOption(options.slug);
4814
+ if (wikiSlug)
4815
+ body.wiki_slug = wikiSlug;
4816
+ if (options.draft)
4817
+ body.draft = true;
4818
+ if (options.force)
4819
+ body.force = true;
4820
+ return requestOxygen("/api/cli/recipes/install", { method: "POST", body });
4821
+ });
4822
+ }));
3797
4823
  program.addCommand(buildPromptTemplatesCommand("prompts", "Reusable prompt templates layered into AI columns at run time."));
3798
4824
  // The deprecated `templates` alias tree was removed at its registry sunset
3799
4825
  // (v1.290.0) — `oxygen prompts` has been canonical since v1.80.0.
@@ -3873,7 +4899,7 @@ export function createProgram() {
3873
4899
  .option("--label <label>", "Display label for the new column. Required unless --prompt-key supplies a default title.")
3874
4900
  .option("--key <key>", "Optional stable column key. Defaults to a normalized label.")
3875
4901
  .option("--data-type <type>", "Column data type: text, numeric, boolean, jsonb, or timestamptz.")
3876
- .option("--kind <kind>", "Column kind. Defaults to manual.")
4902
+ .option("--kind <kind>", "Column kind: manual, ai, formula, enrichment, tool, bind, or lookup. Defaults to manual.")
3877
4903
  .option("--semantic-type <type>", "Optional semantic type such as company_domain.")
3878
4904
  .option("--definition-json <json>", "Optional JSON object with column definition metadata.")
3879
4905
  .option("--prompt-key <key>", "OXYGEN prompt-library key (e.g. email_draft_v1). Materializes prompt + output_schema and forces kind=ai.")
@@ -3884,6 +4910,16 @@ export function createProgram() {
3884
4910
  .option("--run-condition-columns <csv>", "Comma-separated column keys referenced by --run-condition.")
3885
4911
  .option("--output-schema-json <json>", "AI column output JSON schema as inline JSON.")
3886
4912
  .option("--output-schema-file <path>", "AI column output JSON schema read from a file path.")
4913
+ .option("--bind-object <slug>", "Bind column CRM object slug (companies, people, deals, ...). Sets kind=bind and resolves rows to CRM records by identity.")
4914
+ .option("--bind-map <pairs>", "Bind identity mapping as identity=column pairs, e.g. domain=website,linkedin_url=li.")
4915
+ .option("--bind-create", "Bind columns: assert a new CRM record for unmatched rows (onNoMatch=create; a truth write, needs --approved on run).")
4916
+ .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).")
4917
+ .option("--lookup-match <pair>", "Lookup join as localColumn=sourceColumn, e.g. company_domain=domain.")
4918
+ .option("--lookup-normalize <mode>", "Lookup key normalization: exact, lower-trim (default), email, domain, or linkedin.")
4919
+ .option("--lookup-mode <mode>", "Lookup mode: first-match (default), count, exists, or aggregate.")
4920
+ .option("--lookup-return <csv>", "Lookup first-match: source column keys to pull (single -> scalar cell, multiple -> object).")
4921
+ .option("--lookup-order <column:dir>", "Lookup first-match tie-break when a key matches many source rows, e.g. created_at:desc.")
4922
+ .option("--lookup-aggregate <fn:column>", "Lookup aggregate reducer as fn:column, e.g. sum:amount (fn: sum, avg, min, or max).")
3887
4923
  .option("--json", "Print a JSON envelope.")
3888
4924
  // skipcq: JS-R1005 — intentional per-option branching to assemble the columns-add request body
3889
4925
  .action(async (table, options) => {
@@ -3914,6 +4950,39 @@ export function createProgram() {
3914
4950
  const definition = isRecord(column.definition) ? column.definition : {};
3915
4951
  column.definition = applyAiColumnConfig(definition, options);
3916
4952
  }
4953
+ if (readOption(options.bindObject) || readOption(options.bindMap) || options.bindCreate) {
4954
+ const definition = isRecord(column.definition) ? column.definition : {};
4955
+ column.kind = "bind";
4956
+ // Bind columns write a jsonb status cell; the server rejects any other
4957
+ // data type (invalid_column_definition). Default it so callers don't have
4958
+ // to pass --data-type jsonb by hand alongside --bind-object.
4959
+ if (!options.dataType)
4960
+ column.data_type = "jsonb";
4961
+ column.definition = applyBindColumnConfig(definition, options);
4962
+ }
4963
+ if (readOption(options.lookupTable)
4964
+ || readOption(options.lookupMatch)
4965
+ || readOption(options.lookupMode)
4966
+ || readOption(options.lookupReturn)
4967
+ || readOption(options.lookupOrder)
4968
+ || readOption(options.lookupAggregate)
4969
+ || readOption(options.lookupNormalize)) {
4970
+ const definition = isRecord(column.definition) ? column.definition : {};
4971
+ column.kind = "lookup";
4972
+ const lookupDefinition = applyLookupColumnConfig(definition, options);
4973
+ column.definition = lookupDefinition;
4974
+ // Derive the output data type from the mode so callers don't have to know
4975
+ // it (the bind lesson): count/aggregate -> numeric, exists -> boolean,
4976
+ // first_match -> jsonb (holds a scalar OR the multi-return object).
4977
+ if (!options.dataType) {
4978
+ const mode = typeof lookupDefinition.mode === "string" ? lookupDefinition.mode : "first_match";
4979
+ column.data_type = mode === "count" || mode === "aggregate"
4980
+ ? "numeric"
4981
+ : mode === "exists"
4982
+ ? "boolean"
4983
+ : "jsonb";
4984
+ }
4985
+ }
3917
4986
  const body = { table, column };
3918
4987
  if (options.promptKey)
3919
4988
  body.prompt_key = options.promptKey;
@@ -3925,7 +4994,7 @@ export function createProgram() {
3925
4994
  }));
3926
4995
  }))
3927
4996
  .addCommand(new Command("run")
3928
- .description("Run an executable AI, tool, formula, or local custom HTTP column for one row or a bounded batch.")
4997
+ .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
4998
  .argument("<table>", "Table id or slug.")
3930
4999
  .argument("<column>", "Column id or key.")
3931
5000
  .option("--row-id <row_id>", "Workspace row id to run.")
@@ -3935,7 +5004,7 @@ export function createProgram() {
3935
5004
  .option("--force", "Run even when the target cell already has a value.")
3936
5005
  .option("--connection-id <connection_id>", "Optional provider integration connection id.")
3937
5006
  .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.")
5007
+ .option("--approved", "Confirm a paid background run, or a bind create-mode run (onNoMatch=create), after inspecting a dry run or preview.")
3939
5008
  .option("--max-credits <n>", "Maximum managed/provider credits to reserve for a background run.")
3940
5009
  .option("--max-concurrency <n>", "Maximum concurrent row items for a background run. Defaults to 250 for AI columns and 50 otherwise.")
3941
5010
  .option("--local", "Run a custom HTTP column in this CLI process so env-var secrets stay local.")
@@ -4891,7 +5960,7 @@ export function createProgram() {
4891
5960
  .command("billing")
4892
5961
  .description("Plan and managed credit commands.")
4893
5962
  .addCommand(new Command("balance")
4894
- .description("Show the current plan and managed credit balance.")
5963
+ .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
5964
  .option("--json", "Print a JSON envelope.")
4896
5965
  .action(async (options) => {
4897
5966
  await handleAsyncAction("billing balance", options, () => requestOxygen("/api/cli/billing/balance"));
@@ -5014,6 +6083,18 @@ export function createProgram() {
5014
6083
  method: "POST",
5015
6084
  body: { action: "resume" },
5016
6085
  }));
6086
+ }))
6087
+ .addCommand(new Command("topup")
6088
+ .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.")
6089
+ .argument("[pack]", "Pack size in USD: 10, 25, 100, or 250.")
6090
+ .option("--json", "Print a JSON envelope.")
6091
+ .action(async (pack, options) => {
6092
+ await handleAsyncAction("billing topup", options, () => pack
6093
+ ? requestOxygen("/api/cli/billing/topup", {
6094
+ method: "POST",
6095
+ body: { pack },
6096
+ })
6097
+ : requestOxygen("/api/cli/billing/topup"));
5017
6098
  }));
5018
6099
  program
5019
6100
  .command("budget")
@@ -5331,9 +6412,9 @@ export function createProgram() {
5331
6412
  })));
5332
6413
  program
5333
6414
  .command("observability")
5334
- .description("Redacted operation event commands for the current organization.")
6415
+ .description("Workspace observability: redacted operation events, plus the cross-primitive runs lens and approvals inbox (list-only — decisions route to the owning primitive).")
5335
6416
  .addCommand(new Command("events")
5336
- .description("List recent redacted operation events and failures.")
6417
+ .description("List recent redacted operation events and failures. For staff ticket-change envelopes, use this CLI's `support admin events` command.")
5337
6418
  // Keep in sync with OBSERVABILITY_STATUS_FILTERS in
5338
6419
  // apps/web/src/lib/observability.ts and the MCP tool enum in
5339
6420
  // packages/mcp-server/src/tools/observability.ts. The API rejects any
@@ -5373,6 +6454,112 @@ export function createProgram() {
5373
6454
  const suffix = params.toString() ? `?${params.toString()}` : "";
5374
6455
  return requestOxygen(`/api/cli/observability/events${suffix}`);
5375
6456
  });
6457
+ }))
6458
+ .addCommand(new Command("runs")
6459
+ .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.")
6460
+ // Keep --status / --source values in sync with OBSERVABILITY_CONSOLE_STATES
6461
+ // and OBSERVABILITY_RUN_SOURCES in apps/web/src/lib/observability-console.ts
6462
+ // and the MCP tool enums. The API rejects any other value with
6463
+ // invalid_request so a typo fails loudly.
6464
+ .option("--status <statuses>", "Comma-separated normalized states: running, queued, waiting_approval, failed, completed, canceled. Defaults to active + failed.")
6465
+ .option("--source <sources>", "Comma-separated sources: workflow, table_run.")
6466
+ .option("--limit <n>", "Maximum items per source (default 25, max 100). Counts reflect the returned page only.")
6467
+ .option("--json", "Print a JSON envelope.")
6468
+ .action(async (options) => {
6469
+ await handleAsyncAction("observability runs", options, async () => {
6470
+ const params = new URLSearchParams();
6471
+ const status = readOption(options.status);
6472
+ const source = readOption(options.source);
6473
+ const limit = readPositiveInt(options.limit);
6474
+ if (status)
6475
+ params.set("status", status);
6476
+ if (source)
6477
+ params.set("source", source);
6478
+ if (limit)
6479
+ params.set("limit", String(limit));
6480
+ const suffix = params.toString() ? `?${params.toString()}` : "";
6481
+ const data = await requestOxygen(`/api/cli/observability/runs${suffix}`);
6482
+ if (!options.json)
6483
+ writeObservabilityCapsNotice(data);
6484
+ return data;
6485
+ });
6486
+ }))
6487
+ .addCommand(new Command("approvals")
6488
+ .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.")
6489
+ // Keep --source values in sync with OBSERVABILITY_APPROVAL_SOURCES in
6490
+ // apps/web/src/lib/observability-console.ts and the MCP tool enum. The API
6491
+ // rejects any other value with invalid_request so a typo fails loudly.
6492
+ .option("--source <sources>", "Comma-separated sources: workflow, publishing, message_review, inbox_draft.")
6493
+ .option("--limit <n>", "Maximum items per source (default 25, max 100). Counts reflect the returned page only.")
6494
+ .option("--json", "Print a JSON envelope.")
6495
+ .action(async (options) => {
6496
+ await handleAsyncAction("observability approvals", options, async () => {
6497
+ const params = new URLSearchParams();
6498
+ const source = readOption(options.source);
6499
+ const limit = readPositiveInt(options.limit);
6500
+ if (source)
6501
+ params.set("source", source);
6502
+ if (limit)
6503
+ params.set("limit", String(limit));
6504
+ const suffix = params.toString() ? `?${params.toString()}` : "";
6505
+ const data = await requestOxygen(`/api/cli/observability/approvals${suffix}`);
6506
+ if (!options.json)
6507
+ writeObservabilityCapsNotice(data);
6508
+ return data;
6509
+ });
6510
+ }));
6511
+ // Singular `agent`, deliberately: this is ONE governed roster surface — a lens
6512
+ // over the built-in specialist agents that already run inside the workspace as
6513
+ // approval-gated, workflow-owned behavior — not a 14th primitive and not a
6514
+ // build-your-own-agent runtime. (Distinct from `oxygen knowledge agent`, which
6515
+ // configures the knowledge-synthesis specialist itself.)
6516
+ program
6517
+ .command("agent")
6518
+ .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.")
6519
+ .addCommand(new Command("list")
6520
+ .description("List the built-in specialist agents with their enabled state and recent run history.")
6521
+ .option("--json", "Print a JSON envelope.")
6522
+ .action(async (options) => {
6523
+ await handleAsyncAction("agent list", options, () => requestOxygen("/api/cli/agent"));
6524
+ }))
6525
+ .addCommand(new Command("get")
6526
+ .description("Show one specialist agent: its state, approval boundary, config command, and recent runs.")
6527
+ .argument("<slug>", "Specialist slug: inbox-reply-drafts, meeting-notetaker, or knowledge-synthesis.")
6528
+ .option("--json", "Print a JSON envelope.")
6529
+ .action(async (slug, options) => {
6530
+ await handleAsyncAction("agent get", options, () => requestOxygen(`/api/cli/agent/${encodeURIComponent(slug)}`));
6531
+ }))
6532
+ .addCommand(new Command("enable")
6533
+ .description("Enable a specialist agent so it resumes its approval-gated drafts/runs.")
6534
+ .argument("<slug>", "Specialist slug: inbox-reply-drafts, meeting-notetaker, or knowledge-synthesis.")
6535
+ .option("--json", "Print a JSON envelope.")
6536
+ .action(async (slug, options) => {
6537
+ await handleAsyncAction("agent enable", options, () => requestOxygen(`/api/cli/agent/${encodeURIComponent(slug)}/enable`, { method: "POST" }));
6538
+ }))
6539
+ .addCommand(new Command("disable")
6540
+ .description("Disable a specialist agent so it stops its drafts/runs on the next worker tick.")
6541
+ .argument("<slug>", "Specialist slug: inbox-reply-drafts, meeting-notetaker, or knowledge-synthesis.")
6542
+ .option("--json", "Print a JSON envelope.")
6543
+ .action(async (slug, options) => {
6544
+ await handleAsyncAction("agent disable", options, () => requestOxygen(`/api/cli/agent/${encodeURIComponent(slug)}/disable`, { method: "POST" }));
6545
+ }))
6546
+ .addCommand(new Command("runs")
6547
+ .description("List recent runs across the specialist agents (or one via <slug>), newest first.")
6548
+ .argument("[slug]", "Optional specialist slug to filter to one agent.")
6549
+ .option("--limit <n>", "Maximum runs to return (1-100). Defaults to 25.")
6550
+ .option("--json", "Print a JSON envelope.")
6551
+ .action(async (slug, options) => {
6552
+ await handleAsyncAction("agent runs", options, () => {
6553
+ const params = new URLSearchParams();
6554
+ const target = readOption(slug);
6555
+ const limit = readPositiveInt(options.limit);
6556
+ if (target)
6557
+ params.set("slug", target);
6558
+ if (limit)
6559
+ params.set("limit", String(limit));
6560
+ const suffix = params.toString() ? `?${params.toString()}` : "";
6561
+ return requestOxygen(`/api/cli/agent/runs${suffix}`);
6562
+ });
5376
6563
  }));
5377
6564
  program
5378
6565
  .command("runs")
@@ -6311,10 +7498,10 @@ export function createProgram() {
6311
7498
  });
6312
7499
  })));
6313
7500
  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.")
7501
+ .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
7502
  .addCommand(new Command("get")
6316
7503
  .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.")
7504
+ .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
7505
  .option("--account <ref>", "Sender account to read through (sender id, connection id, or Unipile account id). Omit for the org default.")
6319
7506
  .option("--json", "Print a JSON envelope.")
6320
7507
  .action(async (options) => {
@@ -6530,14 +7717,14 @@ export function createProgram() {
6530
7717
  });
6531
7718
  }))
6532
7719
  .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.")
7720
+ .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
7721
  .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.")
7722
+ .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.")
7723
+ .requiredOption("--kind <kind>", "post | profile_viewers | followers | connections.")
6537
7724
  .requiredOption("--account <ref>", "Reading LinkedIn sender (sender id, connection id, or Unipile account id).")
6538
7725
  .option("--post <social_id>", "Composite post social_id from `oxygen posts get` (required for --kind post).")
6539
7726
  .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.")
7727
+ .option("--source <source>", "cookieless | unipile. Defaults to cookieless for post; profile_viewers/followers/connections are always unipile.")
6541
7728
  .option("--table <id_or_slug>", "Existing table to land harvested people into. Omit to create one.")
6542
7729
  .option("--sequence <id_or_slug>", "Sequence to auto-enroll harvested people into (required with --auto-enroll).")
6543
7730
  .option("--auto-enroll", "Auto-enroll newly harvested people into --sequence under the standing approval.")
@@ -6839,7 +8026,7 @@ export function createProgram() {
6839
8026
  .option("--status <keys>", "Comma-separated status keys (e.g. interested,meeting_booked). Cross-channel — filters email + LinkedIn + WhatsApp by the shared taxonomy.")
6840
8027
  .option("--since <iso>", "Only conversations whose last message is on/after this ISO date/timestamp. Cross-channel.")
6841
8028
  .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.")
8029
+ .option("--sequence-id <ids>", "Email only: comma-separated campaign (sequence) UUIDs (not slugs — get the id from `sequences get <slug>`).")
6843
8030
  .option("--provider <providers>", "Email only: comma-separated providers (google,microsoft).")
6844
8031
  .option("--domain <domains>", "Email only: comma-separated counterpart domains to include.")
6845
8032
  .option("--exclude-domain <domains>", "Email only: comma-separated counterpart domains to exclude.")
@@ -7300,11 +8487,12 @@ export function createProgram() {
7300
8487
  });
7301
8488
  }))
7302
8489
  .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.")
8490
+ .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
8491
  .option("--sequence-id <ids>", "Comma-separated campaign (sequence) ids to scope to.")
7305
8492
  .option("--channel <channel>", "Channel: all (default), email, linkedin, or whatsapp.")
7306
- .option("--since <iso>", "Only messages sent at or after this ISO timestamp.")
8493
+ .option("--since <iso>", "Only messages sent at or after this ISO timestamp. Defaults to 30 days ago.")
7307
8494
  .option("--until <iso>", "Only messages sent before this ISO timestamp.")
8495
+ .option("--all-time", "Scan full message history instead of the default trailing 30 days. Ignored if --since or --until is also set.")
7308
8496
  .option("--json", "Print a JSON envelope.")
7309
8497
  .action(async (options) => {
7310
8498
  await handleAsyncAction("messages stats", options, () => {
@@ -7319,6 +8507,8 @@ export function createProgram() {
7319
8507
  if (value)
7320
8508
  params.set(key, value);
7321
8509
  }
8510
+ if (options.allTime)
8511
+ params.set("all_time", "true");
7322
8512
  const suffix = params.toString();
7323
8513
  return requestOxygen(`/api/cli/messages/stats${suffix ? `?${suffix}` : ""}`);
7324
8514
  });
@@ -7369,7 +8559,7 @@ export function createProgram() {
7369
8559
  .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
8560
  .requiredOption("--name <name>", "Human-readable sequence name.")
7371
8561
  .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).")
8562
+ .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
8563
  .option("--channels <list>", "Comma-separated channels: linkedin,email,whatsapp. Defaults to the channels the journey touches.")
7374
8564
  .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
8565
  .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 +8580,11 @@ export function createProgram() {
7390
8580
  .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
8581
  .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
8582
  .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.")
8583
+ .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.")
8584
+ .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
8585
  .option("--no-stop-on-bounce", "Keep a lead's enrollment running after a hard bounce (default: stop it). The bounce is still recorded as an email_bounced signal either way.")
8586
+ .option("--exclude-contacted", "Default every enroll into this sequence to cross-campaign exclusion — skip leads any OTHER active sequence is already contacting. A per-enroll `sequences enroll --exclude-contacted` / `--no-exclude-contacted` always overrides this default. Off by default.")
8587
+ .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
8588
  .option("--json", "Print a JSON envelope.")
7395
8589
  .action(async (options) => {
7396
8590
  await handleAsyncAction("sequences create", options, () => {
@@ -7421,12 +8615,39 @@ export function createProgram() {
7421
8615
  },
7422
8616
  });
7423
8617
  });
8618
+ }))
8619
+ .addCommand(new Command("draft")
8620
+ .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.")
8621
+ .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.")
8622
+ .option("--audience <audience>", "Who the sequence targets (e.g. \"RevOps leaders at 20-100 person B2B SaaS\"). Sharpens the Knowledge Graph retrieval.")
8623
+ .option("--table <id>", "Source table id whose column keys become the {{column|fallback}} personalization tokens the copy may use.")
8624
+ .option("--steps <n>", "How many emails in the journey (1-7). Defaults to 4.")
8625
+ .option("--variants <n>", "Copy variants per email for A/B testing (1-3). Defaults to 1.")
8626
+ .requiredOption("--max-credits <n>", "Credit cap for the AI call (required — this is a paid action).")
8627
+ .option("--create", "Save the draft as a DRAFT sequence (requires --name and --slug). Without this, the draft is only previewed.")
8628
+ .option("--name <name>", "Human-readable sequence name (required with --create).")
8629
+ .option("--slug <slug>", "Unique slug for the sequence (required with --create).")
8630
+ .option("--json", "Print a JSON envelope.")
8631
+ .action(async (options) => {
8632
+ try {
8633
+ const data = await requestOxygen("/api/cli/sequences/draft", {
8634
+ method: "POST",
8635
+ body: buildSequenceDraftBody(options),
8636
+ });
8637
+ emitSuccess("sequences draft", data, options);
8638
+ if (!options.json)
8639
+ writeSequenceDraftPreview(data);
8640
+ writeCreditsReceipt(data);
8641
+ }
8642
+ catch (error) {
8643
+ emitCliFailure("sequences draft", error);
8644
+ }
7424
8645
  }))
7425
8646
  .addCommand(new Command("update")
7426
8647
  .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
8648
  .argument("<sequence>", "Sequence id or slug.")
7428
8649
  .option("--name <name>", "New human-readable sequence name.")
7429
- .option("--steps-file <path>", "Path to a JSON file: { \"steps\": [...] } replacing the journey.")
8650
+ .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
8651
  .option("--channels <list>", "Comma-separated channels: linkedin,email,whatsapp.")
7431
8652
  .option("--whatsapp-cold-initiate", "WhatsApp: allow cold-initiating new chats (no prior conversation). Required to start a WhatsApp sequence live (draft-editable).")
7432
8653
  .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 +8667,11 @@ export function createProgram() {
7446
8667
  .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
8668
  .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
8669
  .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.")
8670
+ .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.")
8671
+ .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
8672
  .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.")
8673
+ .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.")
8674
+ .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
8675
  .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
8676
  .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
8677
  .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 +8717,7 @@ export function createProgram() {
7492
8717
  body.email = email;
7493
8718
  }
7494
8719
  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).");
8720
+ throw new Error("Provide at least one field to update (--name, --steps-file, --channels, --senders, --email-*, --clear-email, --max-credits, --max-live-sends, --max-emails-per-mailbox-per-day, --send-window-file, --no-stop-on-bounce, --[no-]exclude-contacted, or --[no-]tracking-opens / --[no-]tracking-clicks).");
7496
8721
  }
7497
8722
  return requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}`, {
7498
8723
  method: "PATCH",
@@ -7512,7 +8737,8 @@ export function createProgram() {
7512
8737
  .argument("<sequence>", "Sequence id or slug.")
7513
8738
  .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
8739
  .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.")
8740
+ .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.")
8741
+ .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
8742
  .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
8743
  .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
8744
  .option("--json", "Print a JSON envelope.")
@@ -7527,7 +8753,9 @@ export function createProgram() {
7527
8753
  }
7528
8754
  const suppressList = parseSuppressListOption(options.suppressList);
7529
8755
  const sharedFlags = {
7530
- ...(options.excludeContacted ? { exclude_contacted: true } : {}),
8756
+ // Tri-state: --exclude-contacted (true) / --no-exclude-contacted (false)
8757
+ // / absent (undefined → the route falls back to the sequence's default).
8758
+ ...(options.excludeContacted === undefined ? {} : { exclude_contacted: options.excludeContacted }),
7531
8759
  ...(suppressList.length > 0 ? { suppress_list: suppressList } : {}),
7532
8760
  ...(options.ignoreSenderBindings ? { ignore_sender_bindings: true } : {}),
7533
8761
  };
@@ -7579,6 +8807,16 @@ export function createProgram() {
7579
8807
  .option("--json", "Print a JSON envelope.")
7580
8808
  .action(async (sequence, options) => {
7581
8809
  await handleSequenceSignalAction(sequence, options);
8810
+ }))
8811
+ .addCommand(new Command("duplicate")
8812
+ .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.")
8813
+ .argument("<sequence>", "Source sequence id or slug.")
8814
+ .requiredOption("--name <name>", "Name for the copy.")
8815
+ .option("--slug <slug>", "Slug for the copy. Defaults to the source slug + '-copy'.")
8816
+ .option("--no-senders", "Do not copy the source's sender pool onto the copy.")
8817
+ .option("--json", "Print a JSON envelope.")
8818
+ .action(async (sequence, options) => {
8819
+ await handleSequenceDuplicateAction(sequence, options);
7582
8820
  }))
7583
8821
  .addCommand(new Command("pause")
7584
8822
  .description("Pause an active sequence (stops new dispatches; enrollments resume on un-pause).")
@@ -7632,11 +8870,23 @@ export function createProgram() {
7632
8870
  .option("--json", "Print a JSON envelope.")
7633
8871
  .action(async (sequence, options) => {
7634
8872
  await handleAsyncAction("sequences stats", options, () => requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/stats`));
8873
+ }))
8874
+ .addCommand(new Command("events")
8875
+ .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.")
8876
+ .argument("<sequence>", "Sequence id or slug.")
8877
+ .option("--enrollment <id>", "Scope the feed to one enrollment id.")
8878
+ .option("--kind <list>", "Comma-separated event kinds to include (sent,failed,skipped,open,click,replied,bounced,unsubscribed,positive).")
8879
+ .option("--include-bots", "Include bot-attributed opens/clicks (excluded by default).")
8880
+ .option("--limit <n>", "Maximum events to return (1-200, default 50).")
8881
+ .option("--before <iso>", "Keyset cursor: only events strictly before this ISO timestamp (pass the previous page's next_before).")
8882
+ .option("--json", "Print a JSON envelope.")
8883
+ .action(async (sequence, options) => {
8884
+ await handleSequenceEventsAction(sequence, options);
7635
8885
  }))
7636
8886
  .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.")
8887
+ .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
8888
  .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.")
8889
+ .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
8890
  .option("--pause <variant>", "Manually pause one variant (requires --step). Overrides the auto-winner.")
7641
8891
  .option("--activate <variant>", "Manually reactivate one paused variant (requires --step).")
7642
8892
  .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 +8929,7 @@ export function createProgram() {
7679
8929
  });
7680
8930
  })));
7681
8931
  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.")
8932
+ .description("Do-not-contact + email blocklist. People/multichannel: lead provider ids the sequencer enroller skips at plan time (list | add | remove). Email blocklist (Instantly parity): bulk-import addresses + whole-domain blocks the native email dispatcher skips before sending (import | domains | remove-domain). Consumes 0 credits.")
7683
8933
  .addCommand(new Command("list")
7684
8934
  .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
8935
  .option("--reason <reason>", "Filter by reason: manual, replied, unsubscribed, bounced, do_not_contact, friends.")
@@ -7729,19 +8979,132 @@ export function createProgram() {
7729
8979
  });
7730
8980
  });
7731
8981
  }))
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.")
8982
+ .addCommand(new Command("remove")
8983
+ .description("Remove a lead provider id from the org do-not-contact list (re-enable contact).")
8984
+ .requiredOption("--lead <provider_id>", "The lead provider id to un-suppress.")
8985
+ .option("--json", "Print a JSON envelope.")
8986
+ .action(async (options) => {
8987
+ await handleAsyncAction("suppressions remove", options, () => {
8988
+ const lead = readOption(options.lead);
8989
+ if (!lead)
8990
+ throw new Error("--lead is required.");
8991
+ return requestOxygen(`/api/cli/suppressions?lead_provider_id=${encodeURIComponent(lead)}`, {
8992
+ method: "DELETE",
8993
+ });
8994
+ });
8995
+ }))
8996
+ .addCommand(new Command("import")
8997
+ .description("Bulk-import an email blocklist from a file of emails and/or bare domains (newline / comma / whitespace separated, max 5000). An entry with '@' goes on the per-address list; a bare domain (e.g. acme.com) blocks the WHOLE domain. A domain block is a sharp tool, so --reason is REQUIRED when the file contains any domains; a pure-email import defaults to manual. Idempotent. Consumes 0 credits.")
8998
+ .requiredOption("--file <path>", "Path to a file of emails and/or bare domains, separated by newlines, commas, or whitespace.")
8999
+ .option("--reason <reason>", "Suppression reason. Emails: hard_bounce | complaint | unsubscribe | manual. Domains: manual | complaint | policy. Required when the file contains any domains.")
9000
+ .option("--source <text>", "Optional provenance note stored on every imported row (e.g. instantly_export).")
9001
+ .option("--json", "Print a JSON envelope.")
9002
+ .action(async (options) => {
9003
+ try {
9004
+ const file = readOption(options.file);
9005
+ if (!file)
9006
+ throw new Error("--file is required.");
9007
+ const text = readFileSync(resolve(file), "utf8");
9008
+ const entries = [
9009
+ ...new Set(text
9010
+ .split(/[\s,]+/)
9011
+ .map((entry) => entry.trim())
9012
+ .filter((entry) => entry.length > 0)),
9013
+ ];
9014
+ if (entries.length === 0)
9015
+ throw new Error(`No emails or domains found in ${file}.`);
9016
+ const reason = readOption(options.reason);
9017
+ const source = readOption(options.source);
9018
+ const data = await requestOxygen("/api/cli/suppressions/import", {
9019
+ method: "POST",
9020
+ body: {
9021
+ entries,
9022
+ ...(reason ? { reason } : {}),
9023
+ ...(source ? { source } : {}),
9024
+ },
9025
+ });
9026
+ if (options.json) {
9027
+ writeJson(success("suppressions import", data));
9028
+ }
9029
+ else {
9030
+ // Compact human view: the counts plus up to 10 rejected entries (the
9031
+ // full list is always in the --json envelope).
9032
+ const parsed = (data ?? {});
9033
+ const rejected = Array.isArray(parsed.rejected) ? parsed.rejected : [];
9034
+ writeJson({
9035
+ imported_emails: parsed.imported_emails ?? 0,
9036
+ imported_domains: parsed.imported_domains ?? 0,
9037
+ rejected_count: rejected.length,
9038
+ rejected: rejected.slice(0, 10),
9039
+ ...(rejected.length > 10
9040
+ ? { rejected_note: `+${rejected.length - 10} more (use --json for the full list)` }
9041
+ : {}),
9042
+ deep_link: parsed.deep_link,
9043
+ });
9044
+ }
9045
+ writeCreditsReceipt(data);
9046
+ }
9047
+ catch (error) {
9048
+ emitCliFailure("suppressions import", error);
9049
+ }
9050
+ }))
9051
+ .addCommand(new Command("domains")
9052
+ .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).")
9053
+ .option("--reason <reason>", "Filter by reason: manual, complaint, policy.")
9054
+ .option("--search <text>", "Case-insensitive substring match on the domain.")
9055
+ .option("--limit <n>", "Maximum domain blocks to return (1-500; default 100).")
9056
+ .option("--offset <n>", "Pagination offset (0-based).")
9057
+ .option("--json", "Print a JSON envelope.")
9058
+ .action(async (options) => {
9059
+ await handleAsyncAction("suppressions domains", options, () => {
9060
+ const params = new URLSearchParams();
9061
+ const reason = readOption(options.reason);
9062
+ if (reason)
9063
+ params.set("reason", reason);
9064
+ const search = readOption(options.search);
9065
+ if (search)
9066
+ params.set("search", search);
9067
+ const limit = readOption(options.limit);
9068
+ if (limit)
9069
+ params.set("limit", limit);
9070
+ const offset = readOption(options.offset);
9071
+ if (offset)
9072
+ params.set("offset", offset);
9073
+ const suffix = params.toString();
9074
+ return requestOxygen(`/api/cli/suppressions/domains${suffix ? `?${suffix}` : ""}`);
9075
+ });
9076
+ }))
9077
+ .addCommand(new Command("remove-domain")
9078
+ .description("Remove a whole-domain email block (re-enable sending to the domain).")
9079
+ .argument("<domain>", "The bare domain to un-block (e.g. acme.com).")
7735
9080
  .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)}`, {
9081
+ .action(async (domain, options) => {
9082
+ await handleAsyncAction("suppressions remove-domain", options, () => {
9083
+ const value = domain?.trim();
9084
+ if (!value)
9085
+ throw new Error("A domain is required.");
9086
+ return requestOxygen(`/api/cli/suppressions/domains?domain=${encodeURIComponent(value)}`, {
7742
9087
  method: "DELETE",
7743
9088
  });
7744
9089
  });
9090
+ }))
9091
+ .addCommand(new Command("remove-address")
9092
+ .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`.")
9093
+ .argument("<email>", "The blocked address, e.g. ada@acme.com.")
9094
+ .option("--json", "Print a JSON envelope.")
9095
+ .action(async (email, options) => {
9096
+ try {
9097
+ const data = await requestOxygen(`/api/cli/suppressions/addresses?email=${encodeURIComponent(email)}`, { method: "DELETE" });
9098
+ if (options.json) {
9099
+ writeJson(success("suppressions remove-address", data));
9100
+ return;
9101
+ }
9102
+ const link = data.deepLink ?? data.web_url;
9103
+ process.stdout.write(`${data.removed ? "Removed" : "Not found:"} ${data.email ?? email}.${link ? ` ${link}` : ""}\n`);
9104
+ }
9105
+ catch (error) {
9106
+ emitCliFailure("suppressions remove-address", error);
9107
+ }
7745
9108
  })));
7746
9109
  program.addCommand(new Command("email")
7747
9110
  .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 +9148,17 @@ export function createProgram() {
7785
9148
  });
7786
9149
  })));
7787
9150
  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.")
9151
+ .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.")
9152
+ .addCommand(new Command("verify")
9153
+ .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.")
9154
+ .option("--json", "Print a JSON envelope.")
9155
+ .action(async (options) => {
9156
+ await handleAsyncAction("managed-inboxes verify", options, () => requestOxygen("/api/cli/managed-inboxes/verify"));
9157
+ }))
7789
9158
  .addCommand(new Command("subscribe")
7790
9159
  .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).")
9160
+ .argument("[domain]", "Sending domain to register + host the mailboxes (e.g. send.acme.com). May also be passed as --domain.")
9161
+ .option("--domain <domain>", "Sending domain (alternative to the positional argument).")
7792
9162
  .requiredOption("--provider <provider>", "Mailbox PLATFORM: google, microsoft, or azure. (google/microsoft cap at 5 mailboxes per domain; azure allows 100.)")
7793
9163
  .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
9164
  .option("--mailboxes <json>", "JSON array of mailboxes: [{\"username\",\"first_name\",\"last_name\"}].")
@@ -7801,9 +9171,12 @@ export function createProgram() {
7801
9171
  .option("--approved", "Place the order (requires --quote). Without it, a priced preview is returned.")
7802
9172
  .option("--quote <id>", "The quote_id from a fresh preview. Required with --approved.")
7803
9173
  .option("--json", "Print a JSON envelope.")
7804
- .action(async (options) => {
9174
+ .action(async (domainArg, options) => {
7805
9175
  await handleAsyncAction("managed-inboxes subscribe", options, () => {
7806
- const domain = readOption(options.domain);
9176
+ // The domain may arrive either way. `subscribe` took --domain while get/warmup/
9177
+ // cancel took a positional, so the same value had two spellings depending on the
9178
+ // verb — `get --domain x` simply failed. Both work everywhere now.
9179
+ const domain = readOption(domainArg) ?? readOption(options.domain);
7807
9180
  const provider = readOption(options.provider);
7808
9181
  if (!domain)
7809
9182
  throw new Error("--domain is required.");
@@ -7862,20 +9235,26 @@ export function createProgram() {
7862
9235
  }))
7863
9236
  .addCommand(new Command("get")
7864
9237
  .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).")
9238
+ .argument("[domain]", "The managed inbox domain (e.g. send.acme.com). May also be passed as --domain.")
9239
+ .option("--domain <domain>", "The managed inbox domain (alternative to the positional argument).")
7866
9240
  .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)}`));
9241
+ .action(async (domainArg, options) => {
9242
+ await handleAsyncAction("managed-inboxes get", options, () => {
9243
+ const domain = requireDomainArg(domainArg, options.domain);
9244
+ return requestOxygen(`/api/cli/managed-inboxes/${encodeURIComponent(domain)}`);
9245
+ });
7869
9246
  }))
7870
9247
  .addCommand(new Command("warmup")
7871
9248
  .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).")
9249
+ .argument("[domain]", "The managed inbox domain (e.g. send.acme.com). May also be passed as --domain.")
9250
+ .option("--domain <domain>", "The managed inbox domain (alternative to the positional argument).")
7873
9251
  .option("--enable", "Turn warmup on (a recurring monthly per-mailbox add-on).")
7874
9252
  .option("--disable", "Turn warmup off and stop billing for it.")
7875
9253
  .option("--approved", "Apply the change (otherwise a priced preview is returned).")
7876
9254
  .option("--json", "Print a JSON envelope.")
7877
- .action(async (domain, options) => {
9255
+ .action(async (domainArg, options) => {
7878
9256
  await handleAsyncAction("managed-inboxes warmup", options, () => {
9257
+ const domain = requireDomainArg(domainArg, options.domain);
7879
9258
  if (options.enable === options.disable) {
7880
9259
  throw new Error("Pass exactly one of --enable or --disable.");
7881
9260
  }
@@ -7891,17 +9270,21 @@ export function createProgram() {
7891
9270
  }))
7892
9271
  .addCommand(new Command("cancel")
7893
9272
  .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.")
9273
+ .argument("[domain]", "The managed inbox domain to cancel. May also be passed as --domain.")
9274
+ .option("--domain <domain>", "The managed inbox domain (alternative to the positional argument).")
7895
9275
  .option("--approved", "Actually cancel (otherwise a preview is returned).")
7896
9276
  .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
- }));
9277
+ .action(async (domainArg, options) => {
9278
+ await handleAsyncAction("managed-inboxes cancel", options, () => {
9279
+ const domain = requireDomainArg(domainArg, options.domain);
9280
+ return requestOxygen("/api/cli/managed-inboxes/cancel", {
9281
+ method: "POST",
9282
+ body: {
9283
+ domain,
9284
+ ...(options.approved ? { approved: true } : {}),
9285
+ },
9286
+ });
9287
+ });
7905
9288
  })));
7906
9289
  program.addCommand(new Command("mailboxes")
7907
9290
  .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 +9569,6 @@ export function createProgram() {
8186
9569
  deep_link: "https://oxygen-agent.com/billing",
8187
9570
  },
8188
9571
  }));
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
9572
  })));
8211
9573
  program.addCommand(new Command("deliverability")
8212
9574
  .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 +9934,11 @@ export function createProgram() {
8572
9934
  .description("List workflow automations.")
8573
9935
  .option("--json", "Print a JSON envelope.")
8574
9936
  .action(async (options) => {
8575
- await handleAsyncAction("workflows list", options, () => requestOxygen("/api/cli/workflows"));
9937
+ await handleAsyncAction("workflows list", options, async () => {
9938
+ const data = await requestOxygen("/api/cli/workflows");
9939
+ writeDisabledWorkflowNotices(data);
9940
+ return data;
9941
+ });
8576
9942
  }))
8577
9943
  .addCommand(new Command("get")
8578
9944
  .description("Get one workflow automation.")
@@ -8580,10 +9946,14 @@ export function createProgram() {
8580
9946
  .option("--include-bundle", "Include durable recipe bundles in JSON output.")
8581
9947
  .option("--json", "Print a JSON envelope.")
8582
9948
  .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));
9949
+ await handleAsyncAction("workflows get", options, async () => {
9950
+ const data = await requestOxygen("/api/cli/workflows/get", {
9951
+ method: "POST",
9952
+ body: { workflow },
9953
+ });
9954
+ writeDisabledWorkflowNotices(data);
9955
+ return prepareWorkflowCliOutput(data, options);
9956
+ });
8587
9957
  }))
8588
9958
  .addCommand(new Command("duplicate")
8589
9959
  .description("Duplicate a workflow automation as a disabled copy by default.")
@@ -8780,31 +10150,39 @@ export function createProgram() {
8780
10150
  .addCommand(new Command("enable")
8781
10151
  .description("Enable a workflow automation and its current trigger. Prints the schedule's projected automation-action burn; refuses a cron cadence the plan's monthly allowance cannot sustain.")
8782
10152
  .argument("<workflow>", "Workflow id, slug, or name.")
10153
+ .option("--reason <text>", "Why you are enabling it. Recorded on the workflow and shown to the workspace.")
8783
10154
  .option("--json", "Print a JSON envelope.")
8784
10155
  .action(async (workflow, options) => {
8785
10156
  await handleAsyncAction("workflows enable", options, async () => {
8786
10157
  const data = await requestOxygen("/api/cli/workflows/enable", {
8787
10158
  method: "POST",
8788
- body: { workflow },
10159
+ body: {
10160
+ workflow,
10161
+ ...(readOption(options.reason) ? { reason: readOption(options.reason) } : {}),
10162
+ },
8789
10163
  });
8790
10164
  writeAutomationProjection(data);
8791
10165
  return data;
8792
10166
  });
8793
10167
  }))
8794
10168
  .addCommand(new Command("disable")
8795
- .description("Disable a workflow automation and its current trigger.")
10169
+ .description("Disable a workflow automation and its current trigger. Records who disabled it, from where, and why — the workspace sees it on the workflow.")
8796
10170
  .argument("<workflow>", "Workflow id, slug, or name.")
10171
+ .option("--reason <text>", "Why you are disabling it. Recorded on the workflow and shown to the workspace.")
8797
10172
  .option("--json", "Print a JSON envelope.")
8798
10173
  .action(async (workflow, options) => {
8799
10174
  await handleAsyncAction("workflows disable", options, () => requestOxygen("/api/cli/workflows/disable", {
8800
10175
  method: "POST",
8801
- body: { workflow },
10176
+ body: {
10177
+ workflow,
10178
+ ...(readOption(options.reason) ? { reason: readOption(options.reason) } : {}),
10179
+ },
8802
10180
  }));
8803
10181
  }))
8804
10182
  .addCommand(new Command("mcp")
8805
10183
  .description("Publish workflows as dynamic MCP tools (oxygen_workflow_<slug>) — the 'Clay Functions' pattern.")
8806
10184
  .addCommand(new Command("enable")
8807
- .description("Publish an active workflow as a callable MCP tool so any MCP client can run it.")
10185
+ .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
10186
  .argument("<workflow>", "Workflow id, slug, or name.")
8809
10187
  .option("--display-name <name>", "Display name for the published MCP tool.")
8810
10188
  .option("--description <text>", "Description for the published MCP tool.")
@@ -8820,15 +10198,22 @@ export function createProgram() {
8820
10198
  ...(readOption(options.description) ? { description: readOption(options.description) } : {}),
8821
10199
  },
8822
10200
  });
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.
10201
+ // Surface the resolved MCP tool name. Prefer the server's, fall
10202
+ // back to the canonical builder on the server-resolved slug.
8827
10203
  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`);
10204
+ const toolName = data?.tool_name
10205
+ ?? (typeof slug === "string" ? workflowMcpToolName(slug) : null);
10206
+ if (toolName) {
10207
+ // MCP tool names are capped at MAX_MCP_TOOL_NAME_LENGTH. Over the
10208
+ // limit the flag is set but the tool is SKIPPED in tools/list until
10209
+ // the slug is shortened — always warn (stderr, JSON stays clean).
10210
+ // Under it, confirm the callable name in human output only (the
10211
+ // JSON envelope already carries tool_name for --json callers).
10212
+ if (toolName.length > MAX_MCP_TOOL_NAME_LENGTH) {
10213
+ 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`);
10214
+ }
10215
+ else if (!options.json) {
10216
+ process.stderr.write(`Published as MCP tool ${toolName} — call it from any MCP client.\n`);
8832
10217
  }
8833
10218
  }
8834
10219
  return data;
@@ -9337,11 +10722,25 @@ function waitForWorkflowRun(runId, options) {
9337
10722
  const runUrl = readRecordString(latestEnvelope, "runUrl");
9338
10723
  const webUrl = readRecordString(latestEnvelope, "web_url");
9339
10724
  const deepLink = readRecordString(latestEnvelope, "deepLink");
10725
+ // `waiting` stops the poll but the run is NOT over — it resumes on its own
10726
+ // at ready_at. Say so, and say when, or the caller reads `terminal: true`
10727
+ // and concludes the run died.
10728
+ const parked = status === "waiting";
10729
+ const resumeAt = parked ? readRecordString(run, "readyAt") ?? readRecordString(run, "ready_at") : null;
9340
10730
  return {
9341
10731
  run,
9342
10732
  workflowRunId: readRecordString(run, "id") ?? runId,
9343
10733
  status,
9344
- terminal: true,
10734
+ terminal: !parked,
10735
+ ...(parked
10736
+ ? {
10737
+ parked: true,
10738
+ ...(resumeAt ? { resumes_at: resumeAt } : {}),
10739
+ message: resumeAt
10740
+ ? `Run is parked until ${resumeAt} (a provider quota or a retry backoff). It resumes automatically — no action needed.`
10741
+ : "Run is parked and resumes automatically — no action needed.",
10742
+ }
10743
+ : {}),
9345
10744
  polls,
9346
10745
  elapsedMs,
9347
10746
  ...(workflowUrl ? { workflowUrl } : {}),
@@ -9442,10 +10841,18 @@ function normalizeWorkflowRunErrors(value) {
9442
10841
  return output;
9443
10842
  }
9444
10843
  function isTerminalWorkflowRunStatus(status) {
10844
+ // Terminal here means "stop polling", not "the run is over".
10845
+ //
9445
10846
  // 'awaiting_approval' pauses the run indefinitely for a human decision (lease
9446
10847
  // cleared, excluded from claim + lease sweep), so it must stop the tail and
9447
10848
  // surface the approval rather than poll forever.
9448
- return status === "completed" || status === "failed" || status === "canceled" || status === "awaiting_approval";
10849
+ //
10850
+ // 'waiting' is the same shape with a clock instead of a human: the run is
10851
+ // parked until `ready_at` — typically a provider's own quota reset, which can
10852
+ // be ~20 hours out. Polling that is pointless; the tail stops and reports the
10853
+ // resume time (see readWorkflowRunPauseReason).
10854
+ return status === "completed" || status === "failed" || status === "canceled"
10855
+ || status === "awaiting_approval" || status === "waiting";
9449
10856
  }
9450
10857
  function tableWebhookListPath(options) {
9451
10858
  const params = new URLSearchParams();
@@ -9590,6 +10997,447 @@ function applyAiColumnConfig(definition, options) {
9590
10997
  }
9591
10998
  return definition;
9592
10999
  }
11000
+ // Parse a `--bind-map identity=column,identity2=column2` value into an
11001
+ // inputMapping of column refs. Server-side normalizeBindColumnDefinition also
11002
+ // accepts a bare column-key string, but we emit the explicit ref shape so the
11003
+ // wire payload is self-describing. Full JSON control stays available via
11004
+ // --definition-json.
11005
+ function parseBindMapOption(value) {
11006
+ const mapping = {};
11007
+ for (const entry of readCsvOption(value)) {
11008
+ const separator = entry.indexOf("=");
11009
+ if (separator <= 0 || separator === entry.length - 1) {
11010
+ 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 });
11011
+ }
11012
+ const identity = entry.slice(0, separator).trim();
11013
+ const columnKey = entry.slice(separator + 1).trim();
11014
+ mapping[identity] = { type: "column", columnKey };
11015
+ }
11016
+ return mapping;
11017
+ }
11018
+ // `columns add` bind sugar: assemble the { version, object, inputMapping,
11019
+ // onNoMatch } definition from --bind-object / --bind-map / --bind-create. The API
11020
+ // column normalizer does all validation (identity slugs, ref shapes); the CLI only
11021
+ // marshals. --definition-json remains the escape hatch for createValues /
11022
+ // minConfidence / runCondition.
11023
+ function applyBindColumnConfig(definition, options) {
11024
+ definition.version = 1;
11025
+ const object = readOption(options.bindObject);
11026
+ if (object)
11027
+ definition.object = object;
11028
+ const mapping = parseBindMapOption(options.bindMap);
11029
+ if (Object.keys(mapping).length > 0) {
11030
+ const existing = isRecord(definition.inputMapping) ? definition.inputMapping : {};
11031
+ definition.inputMapping = { ...existing, ...mapping };
11032
+ }
11033
+ if (options.bindCreate)
11034
+ definition.onNoMatch = "create";
11035
+ return definition;
11036
+ }
11037
+ const LOOKUP_NORMALIZE_ALIASES = {
11038
+ exact: "exact",
11039
+ "lower-trim": "lower_trim",
11040
+ lower_trim: "lower_trim",
11041
+ email: "email_v1",
11042
+ domain: "domain_v1",
11043
+ linkedin: "linkedin_url_v1",
11044
+ };
11045
+ const LOOKUP_MODE_ALIASES = {
11046
+ "first-match": "first_match",
11047
+ first_match: "first_match",
11048
+ count: "count",
11049
+ exists: "exists",
11050
+ aggregate: "aggregate",
11051
+ };
11052
+ // Parse `--lookup-match localColumn=sourceColumn` into its two halves (LHS is the
11053
+ // working-row column, RHS the source table's join column).
11054
+ function parseLookupMatchOption(value) {
11055
+ const trimmed = readOption(value);
11056
+ if (!trimmed)
11057
+ return null;
11058
+ const separator = trimmed.indexOf("=");
11059
+ if (separator <= 0 || separator === trimmed.length - 1) {
11060
+ throw new OxygenError("invalid_lookup_match", "--lookup-match must be localColumn=sourceColumn, e.g. company_domain=domain.", { details: { value: trimmed }, exitCode: 1 });
11061
+ }
11062
+ return { localColumn: trimmed.slice(0, separator).trim(), sourceColumn: trimmed.slice(separator + 1).trim() };
11063
+ }
11064
+ function parseLookupNormalizeOption(value) {
11065
+ const raw = readOption(value);
11066
+ if (!raw)
11067
+ return null;
11068
+ const mapped = LOOKUP_NORMALIZE_ALIASES[raw.trim().toLowerCase()];
11069
+ if (!mapped) {
11070
+ throw new OxygenError("invalid_lookup_normalize", "--lookup-normalize must be exact, lower-trim, email, domain, or linkedin.", { details: { value: raw }, exitCode: 1 });
11071
+ }
11072
+ return mapped;
11073
+ }
11074
+ function parseLookupModeOption(value) {
11075
+ const raw = readOption(value);
11076
+ if (!raw)
11077
+ return null;
11078
+ const mapped = LOOKUP_MODE_ALIASES[raw.trim().toLowerCase()];
11079
+ if (!mapped) {
11080
+ throw new OxygenError("invalid_lookup_mode", "--lookup-mode must be first-match, count, exists, or aggregate.", { details: { value: raw }, exitCode: 1 });
11081
+ }
11082
+ return mapped;
11083
+ }
11084
+ function parseLookupOrderOption(value) {
11085
+ const raw = readOption(value);
11086
+ if (!raw)
11087
+ return null;
11088
+ const separator = raw.indexOf(":");
11089
+ const column = (separator === -1 ? raw : raw.slice(0, separator)).trim();
11090
+ const directionRaw = (separator === -1 ? "asc" : raw.slice(separator + 1)).trim().toLowerCase();
11091
+ if (!column || (directionRaw !== "asc" && directionRaw !== "desc")) {
11092
+ 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 });
11093
+ }
11094
+ return { column, direction: directionRaw };
11095
+ }
11096
+ function parseLookupAggregateOption(value) {
11097
+ const raw = readOption(value);
11098
+ if (!raw)
11099
+ return null;
11100
+ const separator = raw.indexOf(":");
11101
+ if (separator <= 0 || separator === raw.length - 1) {
11102
+ throw new OxygenError("invalid_lookup_aggregate", "--lookup-aggregate must be fn:column, e.g. sum:amount.", { details: { value: raw }, exitCode: 1 });
11103
+ }
11104
+ const fn = raw.slice(0, separator).trim().toLowerCase();
11105
+ const column = raw.slice(separator + 1).trim();
11106
+ if (fn !== "sum" && fn !== "avg" && fn !== "min" && fn !== "max") {
11107
+ throw new OxygenError("invalid_lookup_aggregate", "--lookup-aggregate fn must be sum, avg, min, or max.", { details: { value: raw }, exitCode: 1 });
11108
+ }
11109
+ return { fn, column };
11110
+ }
11111
+ // `columns add` lookup sugar: assemble the { version, sourceTable, match,
11112
+ // inputMapping, mode, returnColumns, orderBy, aggregate } definition from the
11113
+ // --lookup-* flags. The API column normalizer does all validation (mode-specific
11114
+ // requirements, self-lookup, ref shapes); the CLI only marshals. --definition-json
11115
+ // remains the escape hatch for runCondition and literal match_value refs.
11116
+ function applyLookupColumnConfig(definition, options) {
11117
+ definition.version = 1;
11118
+ const sourceTable = readOption(options.lookupTable);
11119
+ if (sourceTable)
11120
+ definition.sourceTable = sourceTable;
11121
+ const match = parseLookupMatchOption(options.lookupMatch);
11122
+ const normalization = parseLookupNormalizeOption(options.lookupNormalize);
11123
+ if (match || normalization) {
11124
+ const existingMatch = isRecord(definition.match) ? definition.match : {};
11125
+ definition.match = {
11126
+ ...existingMatch,
11127
+ ...(match ? { sourceColumn: match.sourceColumn } : {}),
11128
+ ...(normalization ? { normalization } : {}),
11129
+ };
11130
+ }
11131
+ if (match) {
11132
+ definition.inputMapping = { match_value: { type: "column", columnKey: match.localColumn } };
11133
+ }
11134
+ const mode = parseLookupModeOption(options.lookupMode);
11135
+ if (mode)
11136
+ definition.mode = mode;
11137
+ const returnColumns = readCsvOption(options.lookupReturn);
11138
+ if (returnColumns.length > 0)
11139
+ definition.returnColumns = returnColumns;
11140
+ const orderBy = parseLookupOrderOption(options.lookupOrder);
11141
+ if (orderBy)
11142
+ definition.orderBy = orderBy;
11143
+ const aggregate = parseLookupAggregateOption(options.lookupAggregate);
11144
+ if (aggregate)
11145
+ definition.aggregate = aggregate;
11146
+ return definition;
11147
+ }
11148
+ // Parse a `tables promote --map` value into the wire mappings [{column, attribute}].
11149
+ // Accepts the CSV pair form `column=attribute,column2=attribute2` (LHS is the working
11150
+ // column, RHS the CRM attribute — the reverse of --bind-map's identity=column), a
11151
+ // JSON array of {column, attribute}, or a JSON object { column: attribute }.
11152
+ function parsePromoteMapOption(value) {
11153
+ const trimmed = readOption(value);
11154
+ if (!trimmed) {
11155
+ throw new OxygenError("invalid_promote_map", "--map is required: column=attribute pairs or a JSON array/object.", { exitCode: 1 });
11156
+ }
11157
+ if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
11158
+ let parsed;
11159
+ try {
11160
+ parsed = JSON.parse(trimmed);
11161
+ }
11162
+ catch {
11163
+ throw new OxygenError("invalid_promote_map", "--map JSON is not valid JSON.", { details: { value: trimmed }, exitCode: 1 });
11164
+ }
11165
+ const entries = Array.isArray(parsed)
11166
+ ? parsed.map((entry) => {
11167
+ const record = isRecord(entry) ? entry : {};
11168
+ return {
11169
+ column: typeof record.column === "string" ? record.column.trim() : "",
11170
+ attribute: typeof record.attribute === "string" ? record.attribute.trim() : "",
11171
+ };
11172
+ })
11173
+ : isRecord(parsed)
11174
+ ? Object.entries(parsed).map(([column, attribute]) => ({ column: column.trim(), attribute: String(attribute).trim() }))
11175
+ : [];
11176
+ const mappings = entries.filter((mapping) => mapping.column && mapping.attribute);
11177
+ if (mappings.length === 0) {
11178
+ throw new OxygenError("invalid_promote_map", "--map must contain at least one column-to-attribute mapping.", { exitCode: 1 });
11179
+ }
11180
+ return mappings;
11181
+ }
11182
+ const mappings = [];
11183
+ for (const entry of readCsvOption(value)) {
11184
+ const separator = entry.indexOf("=");
11185
+ if (separator <= 0 || separator === entry.length - 1) {
11186
+ 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 });
11187
+ }
11188
+ mappings.push({ column: entry.slice(0, separator).trim(), attribute: entry.slice(separator + 1).trim() });
11189
+ }
11190
+ if (mappings.length === 0) {
11191
+ throw new OxygenError("invalid_promote_map", "--map must contain at least one column-to-attribute mapping.", { exitCode: 1 });
11192
+ }
11193
+ return mappings;
11194
+ }
11195
+ // Map the hyphenated CLI policy flag to the wire enum.
11196
+ function normalizePromotePolicy(value) {
11197
+ const normalized = (readOption(value) ?? "fill-empty-only").replace(/-/g, "_");
11198
+ if (normalized === "fill_empty_only" || normalized === "overwrite" || normalized === "skip_conflicts") {
11199
+ return normalized;
11200
+ }
11201
+ throw new OxygenError("invalid_promote_policy", "--policy must be fill-empty-only, overwrite, or skip-conflicts.", {
11202
+ details: { policy: value },
11203
+ exitCode: 1,
11204
+ });
11205
+ }
11206
+ // Assemble the `tables send` request: --map pairs (headline sugar) become
11207
+ // column-copy mapping entries; --mapping JSON is the literal/path escape hatch;
11208
+ // --automap asks the server to resolve the mapping (echoed back). Exactly one
11209
+ // mapping source must be chosen, locally, so the error names CLI flags instead
11210
+ // of API fields.
11211
+ function buildTablesSendRequest(source, target, options) {
11212
+ const mapping = parseTablesSendMapping(options);
11213
+ if (mapping && options.automap) {
11214
+ throw new OxygenError("invalid_send", "Pass either a mapping (--map/--mapping) or --automap, not both.", { exitCode: 1 });
11215
+ }
11216
+ if (!mapping && !options.automap) {
11217
+ throw new OxygenError("invalid_send", "Pass --map target=source pairs, --mapping <json>, or --automap.", { exitCode: 1 });
11218
+ }
11219
+ const mode = normalizeTablesSendMode(options.mode);
11220
+ const upsertKey = readOption(options.upsertKey);
11221
+ if (mode === "upsert" && !upsertKey) {
11222
+ throw new OxygenError("invalid_send", "--mode upsert requires --upsert-key <target column>.", { exitCode: 1 });
11223
+ }
11224
+ if (mode === "insert" && upsertKey) {
11225
+ throw new OxygenError("invalid_send", "--upsert-key is only valid with --mode upsert.", { exitCode: 1 });
11226
+ }
11227
+ const flattenColumn = readOption(options.flatten);
11228
+ const flattenPath = readOption(options.flattenPath);
11229
+ if (flattenPath && !flattenColumn) {
11230
+ throw new OxygenError("invalid_send", "--flatten-path requires --flatten <column>.", { exitCode: 1 });
11231
+ }
11232
+ const filters = parseTablesSendFilters(options.filter);
11233
+ const pageSize = readPositiveInt(options.pageSize);
11234
+ return {
11235
+ source,
11236
+ target,
11237
+ ...(mapping ? { mapping } : {}),
11238
+ ...(options.automap ? { automap: true } : {}),
11239
+ ...(flattenColumn ? { flatten: { column: flattenColumn, ...(flattenPath ? { path: flattenPath } : {}) } } : {}),
11240
+ ...(filters ? { filters } : {}),
11241
+ mode,
11242
+ ...(upsertKey ? { upsert_key: upsertKey } : {}),
11243
+ ...(pageSize ? { page_size: pageSize } : {}),
11244
+ ...(options.dryRun ? { dry_run: true } : {}),
11245
+ };
11246
+ }
11247
+ function parseTablesSendMapping(options) {
11248
+ const pairs = (options.map ?? []).flatMap((entry) => readCsvOption(entry));
11249
+ const mappingJson = readOption(options.mapping);
11250
+ if (pairs.length > 0 && mappingJson) {
11251
+ throw new OxygenError("invalid_send", "Pass either --map pairs or --mapping <json>, not both.", { exitCode: 1 });
11252
+ }
11253
+ if (mappingJson) {
11254
+ let parsed;
11255
+ try {
11256
+ parsed = JSON.parse(mappingJson);
11257
+ }
11258
+ catch {
11259
+ throw new OxygenError("invalid_send", "--mapping is not valid JSON.", { details: { mapping: mappingJson }, exitCode: 1 });
11260
+ }
11261
+ if (!Array.isArray(parsed) || parsed.length === 0 || !parsed.every(isRecord)) {
11262
+ throw new OxygenError("invalid_send", "--mapping must be a non-empty JSON array of { target, source, path? } entries.", { exitCode: 1 });
11263
+ }
11264
+ return parsed;
11265
+ }
11266
+ if (pairs.length === 0)
11267
+ return null;
11268
+ return pairs.map((entry) => {
11269
+ const separator = entry.indexOf("=");
11270
+ if (separator <= 0 || separator === entry.length - 1) {
11271
+ throw new OxygenError("invalid_send", "--map entries must be target=source column pairs, e.g. company=company_name,website=domain.", { details: { entry }, exitCode: 1 });
11272
+ }
11273
+ return {
11274
+ target: entry.slice(0, separator).trim(),
11275
+ source: { type: "column", key: entry.slice(separator + 1).trim() },
11276
+ };
11277
+ });
11278
+ }
11279
+ function parseTablesSendFilters(value) {
11280
+ const raw = readOption(value);
11281
+ if (!raw)
11282
+ return null;
11283
+ let parsed;
11284
+ try {
11285
+ parsed = JSON.parse(raw);
11286
+ }
11287
+ catch {
11288
+ throw new OxygenError("invalid_send", "--filter is not valid JSON.", { details: { filter: raw }, exitCode: 1 });
11289
+ }
11290
+ const entries = Array.isArray(parsed) ? parsed : [parsed];
11291
+ if (entries.length === 0 || !entries.every(isRecord)) {
11292
+ throw new OxygenError("invalid_send", "--filter must be a JSON array of { column, op, value? } objects.", { exitCode: 1 });
11293
+ }
11294
+ return entries;
11295
+ }
11296
+ function normalizeTablesSendMode(value) {
11297
+ const normalized = (readOption(value) ?? "insert").toLowerCase();
11298
+ if (normalized === "insert" || normalized === "upsert")
11299
+ return normalized;
11300
+ throw new OxygenError("invalid_send", "--mode must be insert or upsert.", {
11301
+ details: { mode: value },
11302
+ exitCode: 1,
11303
+ });
11304
+ }
11305
+ // After a live send starts, point the terminal at the durable run's inspection
11306
+ // surfaces (stderr, so the machine-read stdout envelope stays clean).
11307
+ function writeTablesSendHint(data) {
11308
+ if (!isRecord(data) || typeof data.ingestion_run_id !== "string")
11309
+ return;
11310
+ const runUrl = typeof data.run_web_url === "string" ? ` (or open ${data.run_web_url})` : "";
11311
+ process.stderr.write(`hint: watch the copy with oxygen table-ingestions wait ${data.ingestion_run_id}${runUrl}\n`);
11312
+ }
11313
+ // Route `tables dedupe` to the preview / apply / cross-check API by flag: --against
11314
+ // switches to the read-only cross-table check (rejecting --apply); --apply targets
11315
+ // the destructive delete (approved only when --approved is set); otherwise preview.
11316
+ function buildDedupeRequest(table, options) {
11317
+ const onColumns = readCsvOption(options.on);
11318
+ if (onColumns.length === 0) {
11319
+ throw new OxygenError("invalid_dedupe", "--on requires at least one column.", { exitCode: 1 });
11320
+ }
11321
+ const normalizer = normalizeDedupeNormalizer(options.normalize);
11322
+ const scanLimit = readPositiveInt(options.scanLimit);
11323
+ const against = readOption(options.against);
11324
+ if (against) {
11325
+ if (options.apply) {
11326
+ throw new OxygenError("invalid_dedupe", "--apply cannot be combined with --against; the cross-table check is read-only.", { exitCode: 1 });
11327
+ }
11328
+ const againstColumn = readOption(options.againstColumn);
11329
+ if (!againstColumn) {
11330
+ throw new OxygenError("invalid_dedupe", "--against-column is required with --against.", { exitCode: 1 });
11331
+ }
11332
+ if (onColumns.length > 1) {
11333
+ throw new OxygenError("invalid_dedupe", "The cross-table check matches on a single --on column.", { exitCode: 1 });
11334
+ }
11335
+ return {
11336
+ endpoint: "/api/cli/tables/dedupe/cross-check",
11337
+ body: {
11338
+ table,
11339
+ column: onColumns[0],
11340
+ against_table: against,
11341
+ against_column: againstColumn,
11342
+ normalizer,
11343
+ ...(scanLimit ? { scan_limit: scanLimit } : {}),
11344
+ },
11345
+ };
11346
+ }
11347
+ const keys = onColumns.map((column) => ({ column, normalizer }));
11348
+ const keep = normalizeDedupeKeep(options.keep);
11349
+ if (options.apply) {
11350
+ const mergeValues = normalizeDedupeMergeValues(options.mergeValues);
11351
+ return {
11352
+ endpoint: "/api/cli/tables/dedupe/apply",
11353
+ body: {
11354
+ table,
11355
+ keys,
11356
+ keep,
11357
+ ...(mergeValues ? { merge_values: mergeValues } : {}),
11358
+ ...(scanLimit ? { scan_limit: scanLimit } : {}),
11359
+ // --approved is the only path that deletes; without it the API 409s with the preview.
11360
+ ...(options.approved ? { approved: true } : {}),
11361
+ },
11362
+ };
11363
+ }
11364
+ return {
11365
+ endpoint: "/api/cli/tables/dedupe/preview",
11366
+ body: { table, keys, keep, ...(scanLimit ? { scan_limit: scanLimit } : {}) },
11367
+ };
11368
+ }
11369
+ // Build the standing auto-dedupe `set` body: --on columns paired positionally
11370
+ // with --normalize modes (unpaired columns default to exact). fuzzy-label maps
11371
+ // through so the API rejects it with the on-demand pointer — one source of
11372
+ // truth for the hot-path restriction.
11373
+ function buildTablesAutoDedupeSetBody(table, options) {
11374
+ const onColumns = readCsvOption(options.on);
11375
+ if (onColumns.length === 0) {
11376
+ throw new OxygenError("invalid_auto_dedupe", "--on requires at least one column.", { exitCode: 1 });
11377
+ }
11378
+ const normalizers = readCsvOption(options.normalize);
11379
+ const keys = onColumns.map((column, index) => ({
11380
+ column,
11381
+ normalizer: normalizeDedupeNormalizer(normalizers[index]),
11382
+ }));
11383
+ const keep = normalizeDedupeKeep(options.keep);
11384
+ const mergeValues = normalizeDedupeMergeValues(options.mergeValues);
11385
+ return {
11386
+ table,
11387
+ keys,
11388
+ keep,
11389
+ ...(mergeValues ? { merge_values: mergeValues } : {}),
11390
+ };
11391
+ }
11392
+ // Map the CLI --normalize flag to the wire normalizer vocabulary.
11393
+ function normalizeDedupeNormalizer(value) {
11394
+ const normalized = (readOption(value) ?? "exact").toLowerCase();
11395
+ switch (normalized) {
11396
+ case "exact":
11397
+ return "exact";
11398
+ case "email":
11399
+ return "email";
11400
+ case "domain":
11401
+ return "domain";
11402
+ case "linkedin":
11403
+ case "linkedin-url":
11404
+ case "linkedin_url":
11405
+ return "linkedin_url";
11406
+ case "fuzzy":
11407
+ case "fuzzy-label":
11408
+ case "fuzzy_label":
11409
+ return "fuzzy_label";
11410
+ default:
11411
+ throw new OxygenError("invalid_dedupe_normalize", "--normalize must be exact, email, domain, linkedin, or fuzzy-label.", {
11412
+ details: { normalize: value },
11413
+ exitCode: 1,
11414
+ });
11415
+ }
11416
+ }
11417
+ function normalizeDedupeKeep(value) {
11418
+ const normalized = (readOption(value) ?? "oldest").replace(/-/g, "_");
11419
+ if (normalized === "oldest" || normalized === "newest" || normalized === "most_complete") {
11420
+ return normalized;
11421
+ }
11422
+ throw new OxygenError("invalid_dedupe_keep", "--keep must be oldest, newest, or most-complete.", {
11423
+ details: { keep: value },
11424
+ exitCode: 1,
11425
+ });
11426
+ }
11427
+ function normalizeDedupeMergeValues(value) {
11428
+ const raw = readOption(value);
11429
+ if (!raw)
11430
+ return null;
11431
+ const normalized = raw.replace(/-/g, "_");
11432
+ if (normalized === "fill_empty")
11433
+ return "fill_empty";
11434
+ if (normalized === "none")
11435
+ return null;
11436
+ throw new OxygenError("invalid_dedupe_merge", "--merge-values must be fill-empty.", {
11437
+ details: { merge_values: value },
11438
+ exitCode: 1,
11439
+ });
11440
+ }
9593
11441
  function tableRunsListPath(options) {
9594
11442
  const table = readOption(options.table);
9595
11443
  if (!table) {
@@ -12077,24 +13925,20 @@ async function buildPostLoginHint(activeProfile) {
12077
13925
  }
12078
13926
  async function promptForToken(options) {
12079
13927
  const fallbackLoginUrl = createCliLoginUrl(options.apiUrl);
13928
+ const settingsUrl = cliSettingsUrl(options.apiUrl);
12080
13929
  if (options.json) {
12081
- throw new OxygenError("missing_token", "Pass a CLI API token with --token when using --json.", {
13930
+ throw new OxygenError("missing_token", `Pass a CLI API token with --token when using --json. Create one at ${settingsUrl}.`, {
12082
13931
  details: { login_url: fallbackLoginUrl },
12083
13932
  exitCode: 1,
12084
13933
  });
12085
13934
  }
12086
13935
  if (!process.stdin.isTTY) {
12087
- throw new OxygenError("missing_token", "Pass a CLI API token with --token, or run `oxygen login` in an interactive terminal.", {
13936
+ throw new OxygenError("missing_token", `Pass a CLI API token with --token, or run \`oxygen login\` in an interactive terminal. Create a token at ${settingsUrl}.`, {
12088
13937
  details: { login_url: fallbackLoginUrl },
12089
13938
  exitCode: 1,
12090
13939
  });
12091
13940
  }
12092
- const browserSession = options.browser
12093
- ? await createBrowserLoginSession(options.apiUrl).catch((error) => {
12094
- output.write(`Browser login unavailable: ${readErrorMessage(error)}\n`);
12095
- return null;
12096
- })
12097
- : null;
13941
+ const browserSession = options.browser ? await createLoginSession(options.apiUrl) : null;
12098
13942
  if (browserSession) {
12099
13943
  const opened = openBrowser(browserSession.verificationUrl);
12100
13944
  if (opened) {
@@ -12104,7 +13948,19 @@ async function promptForToken(options) {
12104
13948
  output.write("Open this URL in your browser:\n");
12105
13949
  output.write(`${browserSession.verificationUrl}\n`);
12106
13950
  }
12107
- const spinner = startOxygenSpinner("Waiting for browser authorization");
13951
+ if ("confirmationCode" in browserSession) {
13952
+ output.write(`Confirmation code: ${browserSession.confirmationCode}\n`);
13953
+ output.write("Approve the login in your browser — it must show this exact code.\n");
13954
+ }
13955
+ let spinner = startOxygenSpinner("Waiting for browser authorization");
13956
+ // One-shot fallback hint: after 15s of silence, tell the user how to finish
13957
+ // manually (blocked browsers and remote terminals used to look like a hang).
13958
+ const hintTimer = setTimeout(() => {
13959
+ spinner.stop();
13960
+ output.write(`Still waiting? To finish manually: open ${settingsUrl}, create a session, and paste the \`oxygen login --token <token>\` command it shows.\n`);
13961
+ spinner = startOxygenSpinner("Waiting for browser authorization");
13962
+ }, BROWSER_LOGIN_FALLBACK_HINT_MS);
13963
+ hintTimer.unref?.();
12108
13964
  try {
12109
13965
  const token = (await waitForBrowserToken(browserSession)).trim();
12110
13966
  if (token) {
@@ -12114,15 +13970,38 @@ async function promptForToken(options) {
12114
13970
  }
12115
13971
  catch (error) {
12116
13972
  spinner.fail(`Browser login unavailable: ${readErrorMessage(error)}`);
12117
- output.write("Paste a CLI API token instead.\n");
13973
+ output.write(`Paste a CLI API token instead (create one at ${settingsUrl}).\n`);
12118
13974
  }
12119
13975
  finally {
13976
+ clearTimeout(hintTimer);
12120
13977
  spinner.stop();
12121
13978
  await browserSession.close();
12122
13979
  }
12123
13980
  }
12124
13981
  return promptForManualToken();
12125
13982
  }
13983
+ /**
13984
+ * Relay login first (server-side approve + claim — works in every browser and
13985
+ * for remote/SSH terminals); legacy loopback only when the relay request cannot
13986
+ * even be created (older server without the endpoint, network policy). Any
13987
+ * create failure falls back — an old server answers with an HTML 404 that
13988
+ * surfaces as invalid_response, not not_found.
13989
+ */
13990
+ async function createLoginSession(apiUrl) {
13991
+ try {
13992
+ return await createRelayLoginSession(apiUrl);
13993
+ }
13994
+ catch {
13995
+ // Fall through to the legacy loopback handoff.
13996
+ }
13997
+ try {
13998
+ return await createBrowserLoginSession(apiUrl);
13999
+ }
14000
+ catch (error) {
14001
+ output.write(`Browser login unavailable: ${readErrorMessage(error)}\n`);
14002
+ return null;
14003
+ }
14004
+ }
12126
14005
  async function promptForManualToken() {
12127
14006
  const restoreEcho = muteTokenEcho();
12128
14007
  const rl = createInterface({ input, output });
@@ -12164,6 +14043,9 @@ function createCliLoginUrl(apiUrl) {
12164
14043
  url.searchParams.set("source", "oxygen_cli");
12165
14044
  return url.toString();
12166
14045
  }
14046
+ function cliSettingsUrl(apiUrl) {
14047
+ return new URL("/settings/cli", apiUrl).toString();
14048
+ }
12167
14049
  function startOxygenSpinner(message) {
12168
14050
  if (!output.isTTY) {
12169
14051
  output.write(`${message}...\n`);
@@ -12424,11 +14306,56 @@ function formatProfileUseSuccess(profile, options) {
12424
14306
  }
12425
14307
  return lines.join("\n");
12426
14308
  }
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.
14309
+ // `sequences duplicate` is deliberately CLIENT-SIDE composition (get + create):
14310
+ // it adds a real workflow convenience (Instantly/Lemlist/HeyReach all have
14311
+ // campaign duplication) without growing the API/MCP surface — an MCP agent
14312
+ // composes the same two calls, and the tools/list payload is at its size
14313
+ // budget. The Instantly email BINDING is never copied: two campaigns must not
14314
+ // share one provider campaign id; the create-side validation tells the user
14315
+ // exactly that if the copied steps require a binding.
14316
+ async function handleSequenceDuplicateAction(sequence, options) {
14317
+ try {
14318
+ const name = readOption(options.name);
14319
+ if (!name)
14320
+ throw new Error("--name is required.");
14321
+ const source = await requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}`, { method: "GET" });
14322
+ const src = source.sequence;
14323
+ if (!src || src.definition === undefined) {
14324
+ throw new Error(`Sequence '${sequence}' was found but returned no definition to copy.`);
14325
+ }
14326
+ const slug = readOption(options.slug) ?? `${src.slug ?? sequence}-copy`;
14327
+ const copySenders = options.senders !== false;
14328
+ const created = await requestOxygen("/api/cli/sequences", {
14329
+ method: "POST",
14330
+ body: {
14331
+ slug,
14332
+ name,
14333
+ definition: src.definition,
14334
+ ...(src.channels ? { channels: src.channels } : {}),
14335
+ ...(src.sourceTableId ? { sourceTableId: src.sourceTableId } : {}),
14336
+ ...(src.linkedinUrlColumnKey ? { linkedinUrlColumnKey: src.linkedinUrlColumnKey } : {}),
14337
+ ...(src.settings ? { settings: src.settings } : {}),
14338
+ ...(src.maxCredits != null ? { maxCredits: src.maxCredits } : {}),
14339
+ ...(src.maxLiveSends != null ? { maxLiveSends: src.maxLiveSends } : {}),
14340
+ ...(copySenders && src.senderAccountIds && src.senderAccountIds.length > 0
14341
+ ? { senderRefs: src.senderAccountIds }
14342
+ : {}),
14343
+ },
14344
+ });
14345
+ if (options.json) {
14346
+ writeJson(success("sequences duplicate", created));
14347
+ return;
14348
+ }
14349
+ const link = created.deepLink ?? created.web_url;
14350
+ const note = src.emailProvider
14351
+ ? " Note: the source's email binding was NOT copied — re-bind with `sequences update` if needed."
14352
+ : "";
14353
+ process.stdout.write(`Duplicated '${sequence}' as draft '${created.sequence?.slug ?? slug}'.${note}${link ? ` ${link}` : ""}\n`);
14354
+ }
14355
+ catch (error) {
14356
+ emitCliFailure("sequences duplicate", error);
14357
+ }
14358
+ }
12432
14359
  async function handleSequenceSignalAction(sequence, options) {
12433
14360
  try {
12434
14361
  const signal = readOption(options.signal);
@@ -12460,6 +14387,74 @@ async function handleSequenceSignalAction(sequence, options) {
12460
14387
  emitCliFailure("sequences signal", error);
12461
14388
  }
12462
14389
  }
14390
+ // `sequences events` reads the per-sequence activity feed (Instantly "Sending
14391
+ // Activities" parity). Read-only: it forwards the filters as query params and
14392
+ // renders one line per event, with a keyset `next:` hint when a further page
14393
+ // exists.
14394
+ async function handleSequenceEventsAction(sequence, options) {
14395
+ try {
14396
+ const params = new URLSearchParams();
14397
+ const enrollment = readOption(options.enrollment);
14398
+ if (enrollment)
14399
+ params.set("enrollment", enrollment);
14400
+ const kinds = readCsvOption(options.kind);
14401
+ if (kinds.length > 0)
14402
+ params.set("kinds", kinds.join(","));
14403
+ if (options.includeBots)
14404
+ params.set("include_bots", "true");
14405
+ const limit = readOption(options.limit);
14406
+ if (limit)
14407
+ params.set("limit", limit);
14408
+ const before = readOption(options.before);
14409
+ if (before)
14410
+ params.set("before", before);
14411
+ const suffix = params.toString();
14412
+ const data = await requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/events${suffix ? `?${suffix}` : ""}`);
14413
+ if (options.json) {
14414
+ writeJson(success("sequences events", data));
14415
+ return;
14416
+ }
14417
+ process.stdout.write(formatSequenceEvents(data));
14418
+ }
14419
+ catch (error) {
14420
+ emitCliFailure("sequences events", error);
14421
+ }
14422
+ }
14423
+ const SEQUENCE_EVENT_KIND_WIDTH = 12;
14424
+ // Render occurred_at as a compact minute-precision UTC stamp for the terminal
14425
+ // (the full-precision ISO stays in the JSON envelope and the --before cursor).
14426
+ function formatEventTime(iso) {
14427
+ if (!iso)
14428
+ return "—";
14429
+ const date = new Date(iso);
14430
+ if (Number.isNaN(date.getTime()))
14431
+ return iso;
14432
+ return `${date.toISOString().slice(0, 16)}Z`;
14433
+ }
14434
+ function formatSequenceEvents(data) {
14435
+ const events = Array.isArray(data.events) ? data.events : [];
14436
+ if (events.length === 0)
14437
+ return "No events yet.\n";
14438
+ const lines = events.map((event) => {
14439
+ const time = formatEventTime(event.occurredAt);
14440
+ const kind = (event.kind ?? "—").padEnd(SEQUENCE_EVENT_KIND_WIDTH);
14441
+ const segments = [];
14442
+ if (event.stepIndex != null) {
14443
+ segments.push(`step ${event.stepIndex}${event.variantId ? ` (${event.variantId})` : ""}`);
14444
+ }
14445
+ if (event.actionKind)
14446
+ segments.push(event.actionKind);
14447
+ if (event.url)
14448
+ segments.push(event.url);
14449
+ if (event.detail)
14450
+ segments.push(`— ${event.detail}`);
14451
+ const tail = segments.join(" ");
14452
+ return [time, kind, tail].filter((part) => part.trim().length > 0).join(" ").replace(/\s+$/, "");
14453
+ });
14454
+ if (data.next_before)
14455
+ lines.push(`next: --before ${data.next_before}`);
14456
+ return `${lines.join("\n")}\n`;
14457
+ }
12463
14458
  async function handleSequenceVariantsAction(sequence, options) {
12464
14459
  try {
12465
14460
  const path = `/api/cli/sequences/${encodeURIComponent(sequence)}/variants`;
@@ -12938,6 +14933,17 @@ function withSupportListQuery(path, options) {
12938
14933
  const query = params.toString();
12939
14934
  return query ? `${path}?${query}` : path;
12940
14935
  }
14936
+ function withSupportEventsQuery(path, options) {
14937
+ const params = new URLSearchParams();
14938
+ const after = readOption(options.after);
14939
+ const limit = readOption(options.limit);
14940
+ if (after)
14941
+ params.set("after", after);
14942
+ if (limit)
14943
+ params.set("limit", limit);
14944
+ const query = params.toString();
14945
+ return query ? `${path}?${query}` : path;
14946
+ }
12941
14947
  // Workflow text flags (--verify-notes/--plan/--draft) bypass readOption: an
12942
14948
  // explicitly-passed empty string clears the field server-side, while an absent
12943
14949
  // flag leaves it untouched, so "" must survive to the request body.
@@ -14045,6 +16051,12 @@ function readSequenceSettings(options) {
14045
16051
  if (options.stopOnBounce === false) {
14046
16052
  settings.stop_on_bounce = false;
14047
16053
  }
16054
+ // exclude_contacted: sequence-level cross-campaign exclusion default. Tri-state
16055
+ // (--exclude-contacted / --no-exclude-contacted); undefined leaves it unset so a
16056
+ // settings PATCH stays clean and enroll keeps today's per-call-only behavior.
16057
+ if (options.excludeContacted !== undefined) {
16058
+ settings.exclude_contacted = options.excludeContacted;
16059
+ }
14048
16060
  const maxEmailsPerDay = readPositiveInt(options.maxEmailsPerDay);
14049
16061
  if (maxEmailsPerDay !== undefined)
14050
16062
  settings.max_emails_per_day = maxEmailsPerDay;
@@ -14073,6 +16085,22 @@ function readSequenceSettings(options) {
14073
16085
  if (emailMinGap !== undefined)
14074
16086
  settings.email_min_gap_minutes = emailMinGap;
14075
16087
  }
16088
+ // opportunity_value is a non-negative dollar amount (may be fractional and may be
16089
+ // 0), so it is parsed as a plain finite number rather than a positive integer;
16090
+ // the server enforces the >= 0 rule (validateSequenceSettings) and reports a clear
16091
+ // error for a negative value, so send any finite number through.
16092
+ const opportunityValueRaw = readOption(options.opportunityValue);
16093
+ if (opportunityValueRaw !== null) {
16094
+ const opportunityValue = Number(opportunityValueRaw);
16095
+ if (Number.isFinite(opportunityValue))
16096
+ settings.opportunity_value = opportunityValue;
16097
+ }
16098
+ // Per-sequence sending-account allowlist (--mailboxes id1,id2). Split + trim +
16099
+ // drop empties client-side (light); the server validates each is a UUID and caps
16100
+ // the count. An all-empty list omits the key so a stray "--mailboxes ," is a no-op.
16101
+ const mailboxIds = readCsvOption(options.mailboxes);
16102
+ if (mailboxIds.length > 0)
16103
+ settings.mailbox_ids = mailboxIds;
14076
16104
  return Object.keys(settings).length > 0 ? settings : undefined;
14077
16105
  }
14078
16106
  function muteTokenEcho() {