@oxygen-agent/cli 1.286.13 → 1.309.0

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 (45) hide show
  1. package/README.md +1 -1
  2. package/dist/cli-values.d.ts +18 -0
  3. package/dist/cli-values.js +66 -0
  4. package/dist/credentials.d.ts +22 -2
  5. package/dist/credentials.js +80 -17
  6. package/dist/help.js +1 -1
  7. package/dist/http-client.d.ts +2 -0
  8. package/dist/http-client.js +68 -30
  9. package/dist/index.js +789 -243
  10. package/dist/knowledge-mirror.d.ts +10 -0
  11. package/dist/knowledge-mirror.js +18 -0
  12. package/dist/run-wait.js +2 -26
  13. package/dist/runtime.d.ts +63 -4
  14. package/dist/runtime.js +113 -3
  15. package/node_modules/@oxygen/shared/dist/deprecation-registry.js +2 -18
  16. package/node_modules/@oxygen/shared/dist/error-redaction.d.ts +80 -0
  17. package/node_modules/@oxygen/shared/dist/error-redaction.js +223 -0
  18. package/node_modules/@oxygen/shared/dist/file-import.js +9 -27
  19. package/node_modules/@oxygen/shared/dist/identifiers.d.ts +23 -0
  20. package/node_modules/@oxygen/shared/dist/identifiers.js +48 -0
  21. package/node_modules/@oxygen/shared/dist/index.d.ts +7 -1
  22. package/node_modules/@oxygen/shared/dist/index.js +7 -1
  23. package/node_modules/@oxygen/shared/dist/knowledge-constants.d.ts +2 -0
  24. package/node_modules/@oxygen/shared/dist/knowledge-constants.js +4 -0
  25. package/node_modules/@oxygen/shared/dist/knowledge-seed-content.d.ts +24 -0
  26. package/node_modules/@oxygen/shared/dist/knowledge-seed-content.js +301 -0
  27. package/node_modules/@oxygen/shared/dist/linkedin-url.d.ts +19 -0
  28. package/node_modules/@oxygen/shared/dist/linkedin-url.js +105 -0
  29. package/node_modules/@oxygen/shared/dist/log.d.ts +3 -0
  30. package/node_modules/@oxygen/shared/dist/log.js +65 -6
  31. package/node_modules/@oxygen/shared/dist/redaction.d.ts +1 -0
  32. package/node_modules/@oxygen/shared/dist/redaction.js +15 -3
  33. package/node_modules/@oxygen/shared/dist/sequences.d.ts +11 -3
  34. package/node_modules/@oxygen/shared/dist/sequences.js +11 -2
  35. package/node_modules/@oxygen/shared/dist/timing.d.ts +10 -0
  36. package/node_modules/@oxygen/shared/dist/timing.js +12 -0
  37. package/node_modules/@oxygen/shared/dist/type-guards.d.ts +15 -0
  38. package/node_modules/@oxygen/shared/dist/type-guards.js +17 -0
  39. package/node_modules/@oxygen/shared/dist/version.d.ts +2 -1
  40. package/node_modules/@oxygen/shared/dist/version.js +33 -2
  41. package/node_modules/@oxygen/workflows/dist/index.d.ts +1 -1
  42. package/node_modules/@oxygen/workflows/dist/index.js +1 -0
  43. package/node_modules/@oxygen/workflows/dist/usage-estimate.d.ts +41 -0
  44. package/node_modules/@oxygen/workflows/dist/usage-estimate.js +203 -0
  45. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -9,15 +9,16 @@ 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, success, toFailure, } from "@oxygen/shared";
12
+ import { AGENCY_DIRECTORY_REGIONS, AGENCY_DIRECTORY_SERVICES, formatCellForDisplay, formatPublicBudgetScopes, exitCodeForOxygenError, isVersionGreater, isVersionLess, OXYGEN_VERSION, OxygenError, parseKnowledgePageMarkdown, sleep, success, toFailure, } from "@oxygen/shared";
13
13
  import { inferImportColumnLabels, inferRowsFileFormat, normalizeImportColumnKey, normalizeRowsForNewTable, normalizeRowsFormat, parseRowsFileBuffer, } from "@oxygen/shared/file-import";
14
14
  import { assertRecipeBundleSafe, assertWorkflowManifest, buildRecipeManifest, compileWorkflowDefinition, isAnyWorkflowManifest, isRecipeManifest, isWorkflowDefinition, isWorkflowManifest, } from "@oxygen/workflows";
15
15
  import { isRecipeDefinition } from "@oxygen/recipe-sdk";
16
16
  import { createBrowserLoginSession, 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
- import { acquireMirrorLock, clearConflictFiles, deletePageFile, emptyMirrorState, findMirrorSlugByPageId, isFileDirty, listConflictFiles, localPageSha256, markMirrorStale, mirrorExists, pageFilePath, planMirrorPush, purgeMirror, resetMirrorForFullResync, quarantineDirtyFile, readMirrorState, releaseMirrorLock, resolveDefaultConfigDir, resolveMirrorDir, writeGeneratedIndexFile, writeGeneratedLogFile, writeMirrorState, writePageFile, } from "./knowledge-mirror.js";
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";
20
20
  import { waitForCliRun } from "./run-wait.js";
21
+ import { assertModeFlagsExclusive, parseJsonObject, readJsonObjectOption, readPositiveInt, readRecordString, resolveLiveDryRunMode, } from "./cli-values.js";
21
22
  import { runLocalCustomHttpColumn } from "./local-custom-http-column.js";
22
23
  import { captureCurrentTranscript, collectFeedbackEnvironment, TranscriptCaptureError, } from "./transcript.js";
23
24
  import { addSessionOutput, addSessionStatus, getSessionUsage, startSession, updateSessionStep, } from "./session.js";
@@ -125,12 +126,18 @@ async function handleAsyncAction(command, options, action) {
125
126
  writeCreditsReceipt(data);
126
127
  }
127
128
  catch (error) {
128
- const failure = toFailure(command, error);
129
- writeJson(failure);
130
- writeMaxCreditsHint(error);
131
- process.exitCode = error instanceof OxygenError ? exitCodeForOxygenError(error) : 1;
129
+ emitCliFailure(command, error);
132
130
  }
133
131
  }
132
+ // Single-source the CLI failure-emit contract: write the machine-readable
133
+ // failure envelope to stdout, surface any spend-gate hint on stderr, and set the
134
+ // process exit code from the error. Command handlers that don't route through
135
+ // handleAsyncAction catch straight into this so the shape can't drift.
136
+ function emitCliFailure(command, error) {
137
+ writeJson(toFailure(command, error));
138
+ writeMaxCreditsHint(error);
139
+ process.exitCode = error instanceof OxygenError ? exitCodeForOxygenError(error) : 1;
140
+ }
134
141
  // Paid envelopes (push 3 legibility) carry a `credits` block: quote on
135
142
  // dry_run, receipt on live, remaining balance on both. Mirror it as one
136
143
  // stderr line so spend stays visible in a terminal without polluting the
@@ -153,6 +160,53 @@ function writeCreditsReceipt(data) {
153
160
  process.stderr.write(`estimated ${block.estimated_credits.toLocaleString("en-US")} credits for a live run${remaining !== null ? `, ${remaining} available` : ""}\n`);
154
161
  }
155
162
  }
163
+ // Arming a cron commits recurring spend, so `workflows enable` mirrors its
164
+ // `automation` block as stderr lines: what the schedule burns per day, what
165
+ // share of the monthly allowance that is, and when it runs out. Observed burn
166
+ // (this workflow's own billed runs) is the honest number and is preferred; the
167
+ // manifest floor is a MINIMUM and is labelled as one, because it excludes the
168
+ // per-row billing that is ~93% of the meter in practice.
169
+ function writeAutomationProjection(data) {
170
+ if (!data || typeof data !== "object" || Array.isArray(data))
171
+ return;
172
+ const automation = data.automation;
173
+ if (!automation || typeof automation !== "object" || Array.isArray(automation))
174
+ return;
175
+ const block = automation;
176
+ const estimate = block.estimate;
177
+ const observed = block.observed;
178
+ const allowance = block.allowance;
179
+ const runsPer30Days = typeof estimate?.scheduledRunsPer30Days === "number"
180
+ ? estimate.scheduledRunsPer30Days
181
+ : null;
182
+ if (runsPer30Days === null)
183
+ return; // On-demand: no schedule, no burn to project.
184
+ const projected = typeof observed?.projectedActionsPer30Days === "number"
185
+ ? observed.projectedActionsPer30Days
186
+ : typeof estimate?.scheduledActionsPer30DaysFloor === "number"
187
+ ? estimate.scheduledActionsPer30DaysFloor
188
+ : null;
189
+ if (projected === null)
190
+ return;
191
+ const fromHistory = typeof observed?.projectedActionsPer30Days === "number";
192
+ const perDay = Math.round(projected / 30);
193
+ const included = typeof allowance?.includedActions === "number" ? allowance.includedActions : null;
194
+ // A cheap schedule on a big plan rounds to "0% of the allowance", which reads
195
+ // as a bug rather than as reassurance. Floor the display at "<1%".
196
+ const sharePct = included !== null && included > 0 ? (projected / included) * 100 : null;
197
+ const share = sharePct === null
198
+ ? ""
199
+ : ` (${sharePct < 1 ? "<1" : Math.round(sharePct)}% of the ${included.toLocaleString("en-US")}/mo allowance)`;
200
+ const basis = fromHistory
201
+ ? `based on ${typeof observed?.runSample === "number" ? observed.runSample : 0} past billed runs`
202
+ : "MINIMUM — excludes per-row billing, real cost will be higher";
203
+ process.stderr.write(`schedule: ${runsPer30Days.toLocaleString("en-US")} runs/30d, ~${perDay.toLocaleString("en-US")} automation actions/day${share}\n`);
204
+ process.stderr.write(` ${basis}\n`);
205
+ const exhaustion = observed?.projectedExhaustionAt;
206
+ if (typeof exhaustion === "string") {
207
+ process.stderr.write(` allowance runs out ${exhaustion} at this pace — the schedule pauses until the window resets\n`);
208
+ }
209
+ }
156
210
  // Paid live runs are refused server-side with typed spend-gate errors
157
211
  // (max_credits_required, approval_required, spend_cap_required,
158
212
  // spend_cap_too_low). Surface each as a one-line stderr hint with the concrete
@@ -196,24 +250,6 @@ function readDetailsNumber(details, key) {
196
250
  const value = details[key];
197
251
  return typeof value === "number" && Number.isFinite(value) ? value : null;
198
252
  }
199
- function parseJsonObject(value) {
200
- let parsed;
201
- try {
202
- parsed = JSON.parse(value);
203
- }
204
- catch (error) {
205
- throw new OxygenError("invalid_json", "Input must be valid JSON.", {
206
- details: { reason: error instanceof Error ? error.message : "unknown" },
207
- exitCode: 1,
208
- });
209
- }
210
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
211
- throw new OxygenError("invalid_json", "Input JSON must be an object.", {
212
- exitCode: 1,
213
- });
214
- }
215
- return parsed;
216
- }
217
253
  function parseJsonArray(value) {
218
254
  let parsed;
219
255
  try {
@@ -452,9 +488,7 @@ function parseSuppressListOption(value) {
452
488
  ];
453
489
  }
454
490
  function resolveComposioRunMode(options) {
455
- if (options.live === true && options.dryRun === true) {
456
- throw new OxygenError("conflicting_flags", "Pass either --live or --dry-run, not both.", { exitCode: 1 });
457
- }
491
+ assertModeFlagsExclusive(options);
458
492
  if (options.live === true)
459
493
  return "live";
460
494
  if (options.dryRun === true)
@@ -472,7 +506,7 @@ const DEFAULT_CRM_SETUP_OBJECTS = ["companies", "people"];
472
506
  function buildCrmSetupBody(options) {
473
507
  return {
474
508
  objects: readCrmSetupObjects(options.objects),
475
- mode: resolveCrmSetupMode(options),
509
+ mode: resolveLiveDryRunMode(options),
476
510
  ...(readOption(options.project) ? { project: readOption(options.project) } : {}),
477
511
  };
478
512
  }
@@ -480,12 +514,6 @@ function readCrmSetupObjects(value) {
480
514
  const objects = readCsvOption(value);
481
515
  return objects.length > 0 ? [...new Set(objects)] : DEFAULT_CRM_SETUP_OBJECTS;
482
516
  }
483
- function resolveCrmSetupMode(options) {
484
- if (options.live === true && options.dryRun === true) {
485
- throw new OxygenError("conflicting_flags", "Pass either --live or --dry-run, not both.", { exitCode: 1 });
486
- }
487
- return options.live === true ? "live" : "dry_run";
488
- }
489
517
  function buildCrmObjectCreateBody(options) {
490
518
  const slug = readOption(options.slug);
491
519
  if (!slug) {
@@ -502,7 +530,7 @@ function buildCrmObjectCreateBody(options) {
502
530
  display_name: displayName,
503
531
  columns,
504
532
  identities,
505
- mode: resolveCrmSetupMode(options),
533
+ mode: resolveLiveDryRunMode(options),
506
534
  ...(readOption(options.singularName) ? { singular_name: readOption(options.singularName) } : {}),
507
535
  ...(readOption(options.pluralName) ? { plural_name: readOption(options.pluralName) } : {}),
508
536
  ...(readOption(options.labelColumn) ? { label_column: readOption(options.labelColumn) } : {}),
@@ -519,7 +547,7 @@ function buildCrmObjectAddAttrBody(options) {
519
547
  }
520
548
  return {
521
549
  column,
522
- mode: resolveCrmSetupMode(options),
550
+ mode: resolveLiveDryRunMode(options),
523
551
  ...(options.asIdentityJson ? { identity: parseJsonObject(options.asIdentityJson) } : {}),
524
552
  };
525
553
  }
@@ -528,14 +556,14 @@ function buildCrmAssertBody(object, options) {
528
556
  object,
529
557
  identity: parseCrmIdentityOption(options.identity),
530
558
  values: options.valuesJson ? parseJsonObject(options.valuesJson) : {},
531
- mode: resolveCrmSetupMode(options),
559
+ mode: resolveLiveDryRunMode(options),
532
560
  };
533
561
  }
534
562
  function buildCrmSyncImportBody(provider, options) {
535
563
  return {
536
564
  provider,
537
565
  object: options.object,
538
- mode: resolveCrmSetupMode(options),
566
+ mode: resolveLiveDryRunMode(options),
539
567
  ...(options.into ? { into: options.into } : {}),
540
568
  ...(readPositiveInt(options.limit) !== undefined ? { limit: readPositiveInt(options.limit) } : {}),
541
569
  ...(readPositiveNumber(options.maxCredits) !== undefined ? { max_credits: readPositiveNumber(options.maxCredits) } : {}),
@@ -546,7 +574,7 @@ function buildCrmSyncConfigureBody(options) {
546
574
  return {
547
575
  provider: options.provider,
548
576
  object: options.object,
549
- mode: resolveCrmSetupMode(options),
577
+ mode: resolveLiveDryRunMode(options),
550
578
  ...(readOption(options.direction) ? { direction: readOption(options.direction) } : {}),
551
579
  ...(readOption(options.cron) ? { cron: readOption(options.cron) } : {}),
552
580
  ...(readOption(options.timezone) ? { timezone: readOption(options.timezone) } : {}),
@@ -559,7 +587,7 @@ function buildCrmSyncRunBody(options) {
559
587
  return {
560
588
  provider: options.provider,
561
589
  object: options.object,
562
- mode: resolveCrmSetupMode(options),
590
+ mode: resolveLiveDryRunMode(options),
563
591
  ...(readPositiveInt(options.maxRows) !== undefined ? { max_rows_per_cycle: readPositiveInt(options.maxRows) } : {}),
564
592
  ...(readPositiveNumber(options.maxCredits) !== undefined ? { max_credits: readPositiveNumber(options.maxCredits) } : {}),
565
593
  ...(options.approved ? { approved: true } : {}),
@@ -620,7 +648,7 @@ function buildCrmSearchBody(query, options) {
620
648
  };
621
649
  }
622
650
  function buildCrmMergeBody(object, survivorRowId, loserRowId, options) {
623
- const mode = resolveCrmSetupMode(options);
651
+ const mode = resolveLiveDryRunMode(options);
624
652
  if (mode === "live" && options.confirm !== true) {
625
653
  throw new OxygenError("confirm_required", "crm merge --live requires --confirm after inspecting the dry-run field diff.", { exitCode: 1 });
626
654
  }
@@ -635,7 +663,7 @@ function buildCrmMergeBody(object, survivorRowId, loserRowId, options) {
635
663
  }
636
664
  function buildNotetakerSetupBody(options) {
637
665
  return {
638
- mode: resolveNotetakerMode(options),
666
+ mode: resolveLiveDryRunMode(options),
639
667
  enabled: options.disabled === true ? false : true,
640
668
  capture_mode: "invited_bot",
641
669
  auto_record_scope: "manual",
@@ -647,7 +675,7 @@ function buildNotetakerSetupBody(options) {
647
675
  }
648
676
  function buildNotetakerScheduleBody(meetingUrl, options) {
649
677
  return {
650
- mode: resolveNotetakerMode(options),
678
+ mode: resolveLiveDryRunMode(options),
651
679
  meeting_url: meetingUrl,
652
680
  approved: options.approved === true,
653
681
  ...(readOption(options.title) ? { title: readOption(options.title) } : {}),
@@ -657,12 +685,6 @@ function buildNotetakerScheduleBody(meetingUrl, options) {
657
685
  ...(readOption(options.crmLinksJson) ? { crm_links: parseJsonArray(readOption(options.crmLinksJson) ?? "") } : {}),
658
686
  };
659
687
  }
660
- function resolveNotetakerMode(options) {
661
- if (options.live === true && options.dryRun === true) {
662
- throw new OxygenError("conflicting_flags", "Pass either --live or --dry-run, not both.", { exitCode: 1 });
663
- }
664
- return options.live === true ? "live" : "dry_run";
665
- }
666
688
  function buildPublishingPostsListPath(options) {
667
689
  const query = new URLSearchParams();
668
690
  const status = readOption(options.status);
@@ -803,16 +825,93 @@ function readPublishingPostText(options, required) {
803
825
  return null;
804
826
  throw new OxygenError("invalid_request", "Pass --text or --text-file.", { exitCode: 1 });
805
827
  }
806
- // Read --text-file, sanitizing the OS error so a missing/unreadable file reports
807
- // only the basename the user passed — never the resolved absolute filesystem path
808
- // Node's ENOENT would otherwise leak into the CLI error output.
809
- function readPublishingTextFile(textFile) {
828
+ // Read a file-backed flag, sanitizing the OS error so a missing/unreadable file
829
+ // reports only the basename the user passed — never the resolved absolute filesystem
830
+ // path Node's ENOENT would otherwise leak into the CLI error output. `flag` names the
831
+ // option in the error so --source-file doesn't report itself as --text-file.
832
+ function readPublishingTextFile(textFile, flag = "--text-file") {
810
833
  try {
811
834
  return readFileSync(resolve(textFile), "utf8").trimEnd();
812
835
  }
813
836
  catch {
814
- throw new OxygenError("text_file_unreadable", `Couldn't read --text-file '${basename(textFile)}'. Check the path exists and is readable.`, { exitCode: 1 });
837
+ throw new OxygenError("text_file_unreadable", `Couldn't read ${flag} '${basename(textFile)}'. Check the path exists and is readable.`, { exitCode: 1 });
838
+ }
839
+ }
840
+ // --source-text / --source-file for the AI draft templates that rewrite a source
841
+ // (summarize, repurpose, podcast_clip, hashtags). Mirrors readPublishingPostText.
842
+ function readPublishingSourceText(options) {
843
+ const inlineText = readOption(options.sourceText);
844
+ const sourceFile = readOption(options.sourceFile);
845
+ if (inlineText && sourceFile) {
846
+ throw new OxygenError("conflicting_flags", "Pass either --source-text or --source-file, not both.", {
847
+ exitCode: 1,
848
+ });
815
849
  }
850
+ if (sourceFile)
851
+ return readPublishingTextFile(sourceFile, "--source-file");
852
+ return inlineText?.trim() ? inlineText : null;
853
+ }
854
+ function buildPublishingPostsDraftBody(options) {
855
+ const body = {
856
+ template: options.template,
857
+ max_credits: readPositiveNumber(options.maxCredits),
858
+ };
859
+ const topic = readOption(options.topic);
860
+ const channels = readOption(options.channels);
861
+ const variants = readPositiveInt(options.variants);
862
+ const sourceText = readPublishingSourceText(options);
863
+ const sourceUrl = readOption(options.sourceUrl);
864
+ if (topic)
865
+ body.topic = topic;
866
+ if (channels) {
867
+ body.channels = channels.split(",").map((channel) => channel.trim()).filter(Boolean);
868
+ }
869
+ if (variants !== undefined)
870
+ body.variants = variants;
871
+ // Commander's --no-ground sets ground=false; the default (true) is the API's too,
872
+ // so only the explicit opt-out is worth sending.
873
+ if (options.ground === false)
874
+ body.ground = false;
875
+ if (sourceText)
876
+ body.source_text = sourceText;
877
+ if (sourceUrl)
878
+ body.source_url = sourceUrl;
879
+ return body;
880
+ }
881
+ function buildPublishingDraftsListPath(options) {
882
+ const query = new URLSearchParams();
883
+ const kind = readOption(options.kind);
884
+ const status = readOption(options.status);
885
+ const limit = readOption(options.limit);
886
+ if (kind)
887
+ query.set("kind", kind);
888
+ if (status)
889
+ query.set("status", status);
890
+ if (limit)
891
+ query.set("limit", limit);
892
+ const suffix = query.toString();
893
+ return suffix ? `/api/cli/publishing/drafts?${suffix}` : "/api/cli/publishing/drafts";
894
+ }
895
+ function buildPublishingDraftsAcceptBody(options) {
896
+ const body = {
897
+ variant_index: readNonNegativeInt(options.variant) ?? 0,
898
+ };
899
+ const publishAt = readOption(options.publishAt);
900
+ const channel = readOption(options.channel);
901
+ const sender = readOption(options.sender);
902
+ const providerConnection = readOption(options.providerConnection);
903
+ const timezone = readOption(options.timezone);
904
+ if (publishAt)
905
+ body.publish_at = publishAt;
906
+ if (channel)
907
+ body.channel = channel;
908
+ if (sender)
909
+ body.sender_account_id = sender;
910
+ if (providerConnection)
911
+ body.provider_connection_id = providerConnection;
912
+ if (timezone)
913
+ body.timezone = timezone;
914
+ return body;
816
915
  }
817
916
  function buildCrmRelationshipUpsertBody(object, rowId, options) {
818
917
  return {
@@ -824,12 +923,12 @@ function buildCrmRelationshipUpsertBody(object, rowId, options) {
824
923
  row_id: options.targetRowId,
825
924
  },
826
925
  ...(readOption(options.role) ? { role: readOption(options.role) } : {}),
827
- mode: resolveCrmSetupMode(options),
926
+ mode: resolveLiveDryRunMode(options),
828
927
  };
829
928
  }
830
929
  function buildCrmActivityBody(object, rowId, options) {
831
930
  const body = {
832
- mode: resolveCrmSetupMode(options),
931
+ mode: resolveLiveDryRunMode(options),
833
932
  activity_type: options.type,
834
933
  links: [{ object, row_id: rowId, role: options.role ?? "actor" }],
835
934
  };
@@ -853,7 +952,7 @@ function buildCrmActivityBody(object, rowId, options) {
853
952
  }
854
953
  function buildCrmActivityNoteBody(object, rowId, text, options) {
855
954
  return {
856
- mode: resolveCrmSetupMode(options),
955
+ mode: resolveLiveDryRunMode(options),
857
956
  activity_type: "note",
858
957
  summary: text,
859
958
  links: [{ object, row_id: rowId, role: "actor" }],
@@ -936,6 +1035,17 @@ function buildCrmLeadsTodayPath(options) {
936
1035
  const query = params.toString();
937
1036
  return query ? `/api/cli/crm/signals/leads-today?${query}` : "/api/cli/crm/signals/leads-today";
938
1037
  }
1038
+ function buildSignalsListPath(options) {
1039
+ const params = new URLSearchParams();
1040
+ if (options.types)
1041
+ params.set("types", options.types);
1042
+ if (options.since)
1043
+ params.set("since", options.since);
1044
+ if (options.limit)
1045
+ params.set("limit", options.limit);
1046
+ const query = params.toString();
1047
+ return query ? `/api/cli/signals?${query}` : "/api/cli/signals";
1048
+ }
939
1049
  function buildCrmAutomationSetBody(templateId, options) {
940
1050
  if (options.armed === true && options.disarmed === true) {
941
1051
  throw new OxygenError("conflicting_flags", "Pass either --armed or --disarmed, not both.", { exitCode: 1 });
@@ -946,7 +1056,7 @@ function buildCrmAutomationSetBody(templateId, options) {
946
1056
  const body = {
947
1057
  template_id: templateId,
948
1058
  armed: options.armed === true,
949
- mode: resolveCrmSetupMode(options),
1059
+ mode: resolveLiveDryRunMode(options),
950
1060
  };
951
1061
  if (options.configJson)
952
1062
  body.config = parseJsonObject(options.configJson);
@@ -966,7 +1076,7 @@ function buildCrmListCreateBody(slug, options) {
966
1076
  slug,
967
1077
  base_object: options.base,
968
1078
  kind: options.kind,
969
- mode: resolveCrmSetupMode(options),
1079
+ mode: resolveLiveDryRunMode(options),
970
1080
  };
971
1081
  if (options.displayName)
972
1082
  body.display_name = options.displayName;
@@ -980,7 +1090,7 @@ function buildCrmListEntryBody(object, rowId, options) {
980
1090
  return {
981
1091
  object,
982
1092
  row_id: rowId,
983
- mode: resolveCrmSetupMode(options),
1093
+ mode: resolveLiveDryRunMode(options),
984
1094
  };
985
1095
  }
986
1096
  function buildCrmListMembersPath(list, options) {
@@ -1126,6 +1236,7 @@ export function createProgram() {
1126
1236
  .option("--token <token>", "CLI API token created in the Oxygen dashboard.")
1127
1237
  .option("--api-url <url>", "Oxygen API URL. Defaults to OXYGEN_API_URL or https://oxygen-agent.com.")
1128
1238
  .option("--profile <name>", "Store credentials under a named CLI profile and make it active.")
1239
+ .option("--force", "Repoint an existing profile at a different API host. Refused without this flag.")
1129
1240
  .option("--no-browser", "Skip the browser handoff and paste the token manually.")
1130
1241
  .option("--json", "Print a JSON envelope.")
1131
1242
  .action(async (options) => {
@@ -1139,6 +1250,7 @@ export function createProgram() {
1139
1250
  .requiredOption("--token <token>", "CLI API token.")
1140
1251
  .option("--api-url <url>", "Oxygen API URL. Defaults to OXYGEN_API_URL or https://oxygen-agent.com.")
1141
1252
  .option("--profile <name>", "Store credentials under a named CLI profile and make it active.")
1253
+ .option("--force", "Repoint an existing profile at a different API host. Refused without this flag.")
1142
1254
  .option("--json", "Print a JSON envelope.")
1143
1255
  .action(async (options) => {
1144
1256
  await handleAuthUseTokenAction(options);
@@ -1834,6 +1946,98 @@ export function createProgram() {
1834
1946
  await handleAsyncAction("publishing posts retry", options, () => requestOxygen(`/api/cli/publishing/posts/${encodeURIComponent(postId)}/retry`, {
1835
1947
  method: "POST",
1836
1948
  }));
1949
+ }))
1950
+ .addCommand(new Command("delete")
1951
+ .description("Delete a never-attempted post from the queue (draft, scheduled, or queued with no publish attempts). A post that was ever attempted — published, publishing, failed, or re-armed after a provider error — is refused: cancel it instead, so its attempt history is kept. To remove a LinkedIn post that already went live, use `oxygen posts delete`.")
1952
+ .argument("<post_id>", "Scheduled post id.")
1953
+ .option("--json", "Print a JSON envelope.")
1954
+ .action(async (postId, options) => {
1955
+ await handleAsyncAction("publishing posts delete", options, () => requestOxygen(`/api/cli/publishing/posts/${encodeURIComponent(postId)}`, {
1956
+ method: "DELETE",
1957
+ }));
1958
+ }))
1959
+ .addCommand(new Command("draft")
1960
+ .description("Draft posts with AI, grounded in the workspace Knowledge Graph. PAID (one AI call) — requires --max-credits. Writes drafts only: nothing is scheduled or published until you accept a draft and approve the post.")
1961
+ .requiredOption("--template <key>", "Draft template: summarize, repurpose, changelog, feature_launch, case_study, personal_story, startup_advice, company_milestone, hiring, fundraise, hook_generator, popular_questions, tech_stack, podcast_clip, or hashtags.")
1962
+ .requiredOption("--max-credits <n>", "Credit cap for the AI call (required — this is a paid action).")
1963
+ .option("--topic <topic>", "What the post is about. Required by most templates.")
1964
+ .option("--channels <list>", "Comma-separated target channels: linkedin, x, instagram, tiktok, facebook, youtube.")
1965
+ .option("--variants <n>", "How many angles to draft (1-5). Defaults to 3.")
1966
+ .option("--no-ground", "Skip Knowledge-Graph page retrieval. The canonical brand voice still applies.")
1967
+ .option("--source-text <text>", "Source text for summarize, repurpose, podcast_clip, and hashtags.")
1968
+ .option("--source-file <path>", "Read the source text from a local file.")
1969
+ .option("--source-url <url>", "Source URL to reference.")
1970
+ .option("--json", "Print a JSON envelope.")
1971
+ .action(async (options) => {
1972
+ await handleAsyncAction("publishing posts draft", options, () => requestOxygen("/api/cli/publishing/posts/draft", {
1973
+ method: "POST",
1974
+ body: buildPublishingPostsDraftBody(options),
1975
+ }));
1976
+ }))
1977
+ .addCommand(new Command("review")
1978
+ .description("Pre-publish review of a post: deterministic channel lint (free, and blocking at approve) plus an AI voice/claims check against the Knowledge Graph. PAID (one AI call) — requires --max-credits. AI findings never block approval.")
1979
+ .argument("<post_id>", "Scheduled post id.")
1980
+ .requiredOption("--max-credits <n>", "Credit cap for the AI review call (required — this is a paid action).")
1981
+ .option("--json", "Print a JSON envelope.")
1982
+ .action(async (postId, options) => {
1983
+ await handleAsyncAction("publishing posts review", options, () => requestOxygen(`/api/cli/publishing/posts/${encodeURIComponent(postId)}/review`, {
1984
+ method: "POST",
1985
+ body: { max_credits: readPositiveNumber(options.maxCredits) },
1986
+ }));
1987
+ })))
1988
+ .addCommand(new Command("drafts")
1989
+ .description("Review the AI post-draft queue before anything reaches the publish queue.")
1990
+ .addCommand(new Command("list")
1991
+ .description("List post drafts. Defaults to the open queue (queued + edited).")
1992
+ .option("--kind <kind>", "Filter by ai or idea.")
1993
+ .option("--status <list>", "Comma-separated statuses: queued, edited, accepted, rejected.")
1994
+ .option("--limit <n>", "Maximum drafts to return.")
1995
+ .option("--json", "Print a JSON envelope.")
1996
+ .action(async (options) => {
1997
+ await handleAsyncAction("publishing drafts list", options, () => requestOxygen(buildPublishingDraftsListPath(options)));
1998
+ }))
1999
+ .addCommand(new Command("accept")
2000
+ .description("Accept a draft variant into the publish queue. The post is created needs-approval — approve it separately before it can publish.")
2001
+ .argument("<draft_id>", "Post draft id.")
2002
+ .option("--variant <i>", "Which variant to accept (0-based). Defaults to 0.")
2003
+ .option("--publish-at <iso>", "ISO date-time to schedule the post for. Defaults to now (still needs approval).")
2004
+ .option("--channel <channel>", "Channel override. Defaults to the draft's first target channel.")
2005
+ .option("--sender <sender_account_id>", "LinkedIn sender account id.")
2006
+ .option("--provider-connection <connection_id>", "Oxygen integration connection id for Composio-backed providers.")
2007
+ .option("--timezone <tz>", "Display timezone for the scheduled date.")
2008
+ .option("--json", "Print a JSON envelope.")
2009
+ .action(async (draftId, options) => {
2010
+ await handleAsyncAction("publishing drafts accept", options, () => requestOxygen(`/api/cli/publishing/drafts/${encodeURIComponent(draftId)}/accept`, {
2011
+ method: "POST",
2012
+ body: buildPublishingDraftsAcceptBody(options),
2013
+ }));
2014
+ }))
2015
+ .addCommand(new Command("reject")
2016
+ .description("Reject a draft. The reason is filed to the Knowledge Graph so the next drafts learn from it.")
2017
+ .argument("<draft_id>", "Post draft id.")
2018
+ .option("--reason <reason>", "Why this draft was rejected.")
2019
+ .option("--json", "Print a JSON envelope.")
2020
+ .action(async (draftId, options) => {
2021
+ await handleAsyncAction("publishing drafts reject", options, () => requestOxygen(`/api/cli/publishing/drafts/${encodeURIComponent(draftId)}/reject`, {
2022
+ method: "POST",
2023
+ body: readOption(options.reason) ? { reason: readOption(options.reason) } : {},
2024
+ }));
2025
+ }))
2026
+ .addCommand(new Command("edit")
2027
+ .description("Rewrite one variant's text. The first edit snapshots the AI's original copy, so accepting it teaches the Knowledge Graph what you changed.")
2028
+ .argument("<draft_id>", "Post draft id.")
2029
+ .option("--variant <i>", "Which variant to edit (0-based). Defaults to 0.")
2030
+ .option("--text <text>", "The replacement post text.")
2031
+ .option("--text-file <path>", "Read the replacement text from a local file.")
2032
+ .option("--json", "Print a JSON envelope.")
2033
+ .action(async (draftId, options) => {
2034
+ await handleAsyncAction("publishing drafts edit", options, () => requestOxygen(`/api/cli/publishing/drafts/${encodeURIComponent(draftId)}`, {
2035
+ method: "PATCH",
2036
+ body: {
2037
+ variant_index: readNonNegativeInt(options.variant) ?? 0,
2038
+ text: readPublishingPostText(options, true),
2039
+ },
2040
+ }));
1837
2041
  })))
1838
2042
  .addCommand(new Command("media")
1839
2043
  .description("Manage Publishing media uploads.")
@@ -2326,6 +2530,52 @@ export function createProgram() {
2326
2530
  .action(async (object, rowId, options) => {
2327
2531
  await handleAsyncAction("crm sync links", options, () => requestOxygen(buildCrmSyncLinksPath(object, rowId)));
2328
2532
  })));
2533
+ program
2534
+ .command("signals")
2535
+ .description("The Signals primitive: the typed GTM intent-event stream — record signals, list the raw event feed, inspect the type registry, and read the ranked leads-to-contact-today queue.")
2536
+ .addCommand(new Command("list")
2537
+ .description("List the raw GTM signal-event feed newest-first (website_visit / profile_view / post_reaction / new_follower), each resolved onto the person it attached to. Read-only.")
2538
+ .option("--types <types>", "Comma-separated signal types to include. Defaults to all four.")
2539
+ .option("--since <days>", "Look-back window in days. Defaults to 7, max 30.")
2540
+ .option("--limit <limit>", "Maximum events to return. Defaults to 25, max 100.")
2541
+ .option("--json", "Print a JSON envelope.")
2542
+ .action(async (options) => {
2543
+ await handleAsyncAction("signals list", options, () => requestOxygen(buildSignalsListPath(options)));
2544
+ }))
2545
+ .addCommand(new Command("registry")
2546
+ .description("Show the signal-type registry: each registered type with its intent weight and capture status (all four registered types are live end-to-end — website_visit via the RB2B webhook, the engagement family via the engagement→signal bridge). Read-only.")
2547
+ .option("--json", "Print a JSON envelope.")
2548
+ .action(async (options) => {
2549
+ await handleAsyncAction("signals registry", options, () => requestOxygen("/api/cli/signals/registry"));
2550
+ }))
2551
+ .addCommand(new Command("record")
2552
+ .description("Record one inbound GTM signal (website_visit, profile_view, post_engager, new_follower). Internal write — free, no approval. Idempotent on --external-event-id.")
2553
+ .requiredOption("--event <event>", "Signal event: website_visit | profile_view | post_engager | new_follower.")
2554
+ .option("--external-event-id <id>", "Stable provider event id (redelivery/idempotency guard). Derived from --session-id when omitted.")
2555
+ .option("--email <email>", "Person email (identity).")
2556
+ .option("--linkedin-url <url>", "Person LinkedIn URL (identity when no email).")
2557
+ .option("--company-domain <domain>", "Company domain for the company assert.")
2558
+ .option("--company-name <name>", "Company name.")
2559
+ .option("--first-name <name>", "Person first name.")
2560
+ .option("--last-name <name>", "Person last name.")
2561
+ .option("--page-url <url>", "Page the signal fired on (website visit).")
2562
+ .option("--session-id <id>", "Provider session id (provenance + idempotency).")
2563
+ .option("--payload-json <json>", "JSON object merged into the signal payload (wins over the flags above).")
2564
+ .option("--json", "Print a JSON envelope.")
2565
+ .action(async (options) => {
2566
+ await handleAsyncAction("signals record", options, () => requestOxygen("/api/cli/signals", {
2567
+ method: "POST",
2568
+ body: buildCrmSignalRecordBody(options),
2569
+ }));
2570
+ }))
2571
+ .addCommand(new Command("leads-today")
2572
+ .description("List people to contact today, ranked by their most recent GTM intent signal. Do-not-contact filtering is best-effort (email-only leads surface unchecked).")
2573
+ .option("--limit <limit>", "Maximum leads to return. Defaults to 25, max 100.")
2574
+ .option("--within-days <days>", "Signal look-back window in days. Defaults to 7, max 30.")
2575
+ .option("--json", "Print a JSON envelope.")
2576
+ .action(async (options) => {
2577
+ await handleAsyncAction("signals leads-today", options, () => requestOxygen(buildCrmLeadsTodayPath(options)));
2578
+ }));
2329
2579
  const tablesCommand = program
2330
2580
  .command("tables")
2331
2581
  .description("Tenant workspace table commands.")
@@ -2518,7 +2768,7 @@ export function createProgram() {
2518
2768
  await handleAsyncAction("tables query", options, () => {
2519
2769
  const limit = readPositiveInt(options.limit);
2520
2770
  const filters = readFilterJsonOption(options.filterJson);
2521
- const filterTree = readFilterTreeJsonOption(options.filterTreeJson);
2771
+ const filterTree = readJsonObjectOption(options.filterTreeJson);
2522
2772
  const sorts = readSortJsonOption(options.sortJson);
2523
2773
  if (filters && (filterTree || sorts)) {
2524
2774
  throw new OxygenError("invalid_filter", "Pass either --filter-json (legacy) or --filter-tree-json/--sort-json, not both.", { exitCode: 1 });
@@ -2953,7 +3203,7 @@ export function createProgram() {
2953
3203
  .option("--body <text>", "Page body (Markdown, supports [[wikilinks]]).")
2954
3204
  .option("--data-json <json>", "Flexible JSON object for structured page data.")
2955
3205
  .option("--expected-revision <n>", "Fail the write if the current page revision differs (optimistic concurrency guard).")
2956
- .option("--approved", "Mark the write as human-approved.")
3206
+ .option("--approved", "Accepted for compatibility but never honored for canonical live copy: a gated edit always files a proposal - approve it with `oxygen knowledge proposals approve <id>`.")
2957
3207
  .option("--json", "Print a JSON envelope.")
2958
3208
  .action(async (options) => {
2959
3209
  await handleAsyncAction("knowledge page upsert", options, async () => {
@@ -3007,7 +3257,7 @@ export function createProgram() {
3007
3257
  .argument("<slug_or_id>", "Page slug or UUID.")
3008
3258
  .option("--canonical", "Pin as the canonical default for its type (default).")
3009
3259
  .option("--no-canonical", "Unpin instead of pin (clears the canonical default for this type).")
3010
- .option("--approved", "Mark the pin as human-approved (required for sensitive types: voice, brand, positioning).")
3260
+ .option("--approved", "Accepted for compatibility but never honored: pinning a sensitive type (voice, brand, positioning, playbooks) always files a proposal - approve it with `oxygen knowledge proposals approve <id>`.")
3011
3261
  .option("--json", "Print a JSON envelope.")
3012
3262
  .action(async (slugOrId, options) => {
3013
3263
  await handleAsyncAction("knowledge page pin", options, async () => {
@@ -3083,8 +3333,10 @@ export function createProgram() {
3083
3333
  await handleAsyncAction("knowledge index", options, () => requestOxygen("/api/cli/knowledge/index"));
3084
3334
  }))
3085
3335
  .addCommand(new Command("graph")
3086
- .description("Knowledge graph of pages and their [[wikilink]] edges.")
3336
+ .description("Knowledge graph of pages and their [[wikilink]] edges. Pass --center to render one page's local neighborhood instead of the whole graph.")
3087
3337
  .option("--max-nodes <n>", "Maximum nodes to include in the graph.")
3338
+ .option("--center <slug_or_id>", "Center the graph on one page and show only its neighborhood (local mode).")
3339
+ .option("--depth <n>", "Local-graph hop depth (1-3, default 1). Only applies with --center.")
3088
3340
  .option("--json", "Print a JSON envelope.")
3089
3341
  .action(async (options) => {
3090
3342
  await handleAsyncAction("knowledge graph", options, () => requestOxygen("/api/cli/knowledge/graph", {
@@ -3093,7 +3345,7 @@ export function createProgram() {
3093
3345
  }));
3094
3346
  }))
3095
3347
  .addCommand(new Command("lint")
3096
- .description("Structural wiki health report: orphans, unresolved links, missing canonicals, stale/oversized pages, aged proposals.")
3348
+ .description("Structural wiki health report: orphans, dead-ends, unresolved links, missing canonicals, untagged pages, duplicate titles, oversized hubs, unfilled seed stubs, decisions missing a reversal condition, stale/oversized pages, and aged proposals.")
3097
3349
  .option("--json", "Print a JSON envelope.")
3098
3350
  .action(async (options) => {
3099
3351
  await handleAsyncAction("knowledge lint", options, () => requestOxygen("/api/cli/knowledge/lint"));
@@ -3210,7 +3462,7 @@ export function createProgram() {
3210
3462
  }));
3211
3463
  })))
3212
3464
  .addCommand(new Command("seed")
3213
- .description("Seed the knowledge wiki with starter pages.")
3465
+ .description("Seed or upgrade the knowledge wiki scaffold: on first run creates the starter page set — a start-here guide, a conventions schema page, and stub hubs (icp, positioning, offers, competitors, playbooks, campaign-learnings, decisions), all [[wikilinked]]. Idempotent; never overwrites your edits.")
3214
3466
  .option("--json", "Print a JSON envelope.")
3215
3467
  .action(async (options) => {
3216
3468
  await handleAsyncAction("knowledge seed", options, () => requestOxygen("/api/cli/knowledge/seed", {
@@ -3240,14 +3492,45 @@ export function createProgram() {
3240
3492
  },
3241
3493
  }));
3242
3494
  }))
3495
+ .addCommand(new Command("agent")
3496
+ .description("The scheduled knowledge synthesis agent: unattended distillation of auto-filed sources into learnings pages (default off, spend-capped). Canonical changes still go through the proposal queue.")
3497
+ .addCommand(new Command("get")
3498
+ .description("Show the synthesis-agent config (enabled, cadence, per-day credit cap).")
3499
+ .option("--json", "Print a JSON envelope.")
3500
+ .action(async (options) => {
3501
+ await handleAsyncAction("knowledge agent get", options, () => requestOxygen("/api/cli/knowledge/agent"));
3502
+ }))
3503
+ .addCommand(new Command("set")
3504
+ .description("Configure the synthesis agent. Enabling lets the background worker distill new sources on the cadence; it only writes working learnings pages.")
3505
+ .option("--enabled", "Enable unattended synthesis.")
3506
+ .option("--disabled", "Disable unattended synthesis.")
3507
+ .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).")
3509
+ .option("--json", "Print a JSON envelope.")
3510
+ .action(async (options) => {
3511
+ await handleAsyncAction("knowledge agent set", options, () => {
3512
+ const body = {};
3513
+ if (options.enabled)
3514
+ body.enabled = true;
3515
+ if (options.disabled)
3516
+ body.enabled = false;
3517
+ // Presence decides inclusion so an absent flag keeps the stored value.
3518
+ if (options.cadenceHours !== undefined)
3519
+ body.cadence_hours = Number(options.cadenceHours);
3520
+ if (options.maxCreditsPerDay !== undefined)
3521
+ body.max_credits_per_day = Number(options.maxCreditsPerDay);
3522
+ return requestOxygen("/api/cli/knowledge/agent", { method: "POST", body });
3523
+ });
3524
+ })))
3243
3525
  .addCommand(new Command("sync")
3244
- .description("Clone or refresh the local knowledge mirror: server-rendered page markdown under the CLI config dir.")
3526
+ .description("Clone or refresh the local knowledge mirror: server-rendered page markdown under the CLI config dir. Pass --all to mirror every workspace the credential can access.")
3527
+ .option("--all", "Sync every organization the credential can access, each into its own mirror; one org's failure never aborts the rest.")
3245
3528
  .option("--full", "Purge the mirror (including its cursor) and re-clone every page.")
3246
3529
  .option("--if-stale", "Skip when the mirror completed a sync within the TTL (or one is already running).")
3247
3530
  .option("--ttl <minutes>", "Freshness window for --if-stale, in minutes. Defaults to 15.")
3248
3531
  .option("--json", "Print a JSON envelope.")
3249
3532
  .action(async (options) => {
3250
- await handleAsyncAction("knowledge sync", options, () => runKnowledgeMirrorSync(options));
3533
+ await handleAsyncAction("knowledge sync", options, () => options.all ? runKnowledgeMirrorSyncAll(options) : runKnowledgeMirrorSync(options));
3251
3534
  }))
3252
3535
  .addCommand(new Command("push")
3253
3536
  .description("Push locally edited or newly created mirror pages back to the workspace wiki. Edits to canonical voice/brand/positioning pages become approval proposals instead of writing live.")
@@ -3257,7 +3540,8 @@ export function createProgram() {
3257
3540
  await handleAsyncAction("knowledge push", options, () => runKnowledgeMirrorPush(options));
3258
3541
  }))
3259
3542
  .addCommand(new Command("status")
3260
- .description("Report the local knowledge mirror: path, page count, last sync, quarantined conflicts.")
3543
+ .description("Report the local knowledge mirror: path, page count, last sync, quarantined conflicts. Pass --all to report every locally mirrored workspace for the active API host.")
3544
+ .option("--all", "Report every locally mirrored workspace for the active API host, with a per-mirror freshness flag.")
3261
3545
  .option("--print-path", "Print only the mirror directory path.")
3262
3546
  .option("--clear-conflicts", "Delete quarantined conflict copies from the mirror sidecar.")
3263
3547
  .option("--json", "Print a JSON envelope.")
@@ -3266,7 +3550,7 @@ export function createProgram() {
3266
3550
  await handleKnowledgeMirrorPrintPathAction();
3267
3551
  return;
3268
3552
  }
3269
- await handleAsyncAction("knowledge status", options, () => runKnowledgeMirrorStatus(options));
3553
+ await handleAsyncAction("knowledge status", options, () => options.all ? runKnowledgeMirrorStatusAll() : runKnowledgeMirrorStatus(options));
3270
3554
  }))
3271
3555
  .addCommand(new Command("purge")
3272
3556
  .description("Delete the whole local knowledge mirror for the active API host and organization.")
@@ -3511,7 +3795,8 @@ export function createProgram() {
3511
3795
  });
3512
3796
  }));
3513
3797
  program.addCommand(buildPromptTemplatesCommand("prompts", "Reusable prompt templates layered into AI columns at run time."));
3514
- program.addCommand(buildPromptTemplatesCommand("templates", "Deprecated alias for 'oxygen prompts'. Will be removed in a future release."));
3798
+ // The deprecated `templates` alias tree was removed at its registry sunset
3799
+ // (v1.290.0) — `oxygen prompts` has been canonical since v1.80.0.
3515
3800
  program
3516
3801
  .command("reviews")
3517
3802
  .description("Human-in-the-loop reviews for AI-generated outreach messages.")
@@ -3869,6 +4154,20 @@ export function createProgram() {
3869
4154
  },
3870
4155
  });
3871
4156
  });
4157
+ }))
4158
+ .addCommand(new Command("deps")
4159
+ .description("Show the column dependency graph: which columns feed which (formulas, input mappings, run conditions, waterfall targets), transitive up/downstream, and cycles. Read-only.")
4160
+ .argument("<table>", "Table id or slug.")
4161
+ .option("--column <key>", "Focus on one column: its upstream inputs, downstream dependents, and edges.")
4162
+ .option("--json", "Print a JSON envelope.")
4163
+ .action(async (table, options) => {
4164
+ await handleAsyncAction("columns deps", options, () => {
4165
+ const params = new URLSearchParams({ table });
4166
+ const column = readOption(options.column);
4167
+ if (column)
4168
+ params.set("column", column);
4169
+ return requestOxygen(`/api/cli/tables/columns/deps?${params.toString()}`, { method: "GET" });
4170
+ });
3872
4171
  }));
3873
4172
  program
3874
4173
  .command("action-column")
@@ -3906,6 +4205,38 @@ export function createProgram() {
3906
4205
  });
3907
4206
  });
3908
4207
  }));
4208
+ program
4209
+ .command("formulas")
4210
+ .description("Formula language for formula columns and per-column run conditions (only-run-if).")
4211
+ .addCommand(new Command("functions")
4212
+ .description("List every formula function (name, signature, examples) and the operator grammar. The same language powers formula columns and --run-condition gates.")
4213
+ .option("--category <category>", "Filter by category: logic, string, number, date, url_email, array, json, null_handling, cross_row.")
4214
+ .option("--json", "Print a JSON envelope.")
4215
+ .action(async (options) => {
4216
+ await handleAsyncAction("formulas functions", options, () => {
4217
+ const category = readOption(options.category);
4218
+ const query = category ? `?category=${encodeURIComponent(category)}` : "";
4219
+ return requestOxygen(`/api/cli/tables/formulas/functions${query}`, { method: "GET" });
4220
+ });
4221
+ }))
4222
+ .addCommand(new Command("validate")
4223
+ .description("Validate a formula against a table and preview its output on sample rows — free, no writes. Draft → validate → attach with `oxygen columns add --kind formula`.")
4224
+ .argument("<table>", "Table id or slug.")
4225
+ .requiredOption("--expression <expr>", "The formula expression to validate, e.g. \"if(score > 80, 'hot', 'warm')\".")
4226
+ .option("--rows <n>", "How many sample rows to evaluate (0-25, default 5).")
4227
+ .option("--row-id <id...>", "Evaluate specific row ids instead of the first rows.")
4228
+ .option("--json", "Print a JSON envelope.")
4229
+ .action(async (table, options) => {
4230
+ await handleAsyncAction("formulas validate", options, () => requestOxygen("/api/cli/tables/formulas/validate", {
4231
+ method: "POST",
4232
+ body: {
4233
+ table,
4234
+ expression: options.expression,
4235
+ ...(readOption(options.rows) ? { sample_limit: Number(readOption(options.rows)) } : {}),
4236
+ ...(options.rowId && options.rowId.length > 0 ? { row_ids: options.rowId } : {}),
4237
+ },
4238
+ }));
4239
+ }));
3909
4240
  program
3910
4241
  .command("table-runs")
3911
4242
  .description("Durable background table action run commands.")
@@ -4665,6 +4996,24 @@ export function createProgram() {
4665
4996
  ...(readOption(options.note) ? { note: readOption(options.note) } : {}),
4666
4997
  },
4667
4998
  }));
4999
+ }))
5000
+ .addCommand(new Command("cancel")
5001
+ .description("Schedule the plan subscription to cancel at period end. During a trial the card is never charged; undo any time before then with billing resume.")
5002
+ .option("--json", "Print a JSON envelope.")
5003
+ .action(async (options) => {
5004
+ await handleAsyncAction("billing cancel", options, () => requestOxygen("/api/cli/billing/cancel", {
5005
+ method: "POST",
5006
+ body: { action: "cancel" },
5007
+ }));
5008
+ }))
5009
+ .addCommand(new Command("resume")
5010
+ .description("Remove a scheduled cancellation so the subscription renews (or the trial converts) normally.")
5011
+ .option("--json", "Print a JSON envelope.")
5012
+ .action(async (options) => {
5013
+ await handleAsyncAction("billing resume", options, () => requestOxygen("/api/cli/billing/cancel", {
5014
+ method: "POST",
5015
+ body: { action: "resume" },
5016
+ }));
4668
5017
  }));
4669
5018
  program
4670
5019
  .command("budget")
@@ -5414,6 +5763,7 @@ export function createProgram() {
5414
5763
  .option("--force", "Re-run rows with an existing target enrichment value.")
5415
5764
  .option("--max-concurrency <n>", "Maximum concurrent row items for this enrichment run. Defaults to 20.")
5416
5765
  .option("--json", "Print a JSON envelope.")
5766
+ .option("--approved", "Approve THIS run after inspecting `enrich-column preview` (free). Paid enrichment runs fail with approval_required (exit 7) without it.")
5417
5767
  .action(async (table, options) => {
5418
5768
  const maxConcurrency = readPositiveInt(options.maxConcurrency);
5419
5769
  await handleAsyncAction("enrich-column run", options, () => requestOxygen("/api/cli/enrich-column/run", {
@@ -5421,6 +5771,7 @@ export function createProgram() {
5421
5771
  body: {
5422
5772
  ...buildEnrichColumnBody(table, options),
5423
5773
  max_credits: readPositiveNumber(options.maxCredits),
5774
+ ...(options.approved ? { approved: true } : {}),
5424
5775
  ...(maxConcurrency ? { max_concurrency: maxConcurrency } : {}),
5425
5776
  ...(options.force ? { force: true } : {}),
5426
5777
  },
@@ -6029,6 +6380,36 @@ export function createProgram() {
6029
6380
  },
6030
6381
  });
6031
6382
  });
6383
+ }))
6384
+ .addCommand(new Command("delete")
6385
+ .description("Delete a LinkedIn post the connected account authored — a REAL, irreversible public write. Refuses without --approved (exit 7). --post is the composite social_id returned when the post was created (or by `oxygen posts get`), NOT the activity URN. To remove a post that is still only SCHEDULED in Oxygen, use `oxygen publishing posts delete` instead.")
6386
+ .requiredOption("--post <social_id>", "Composite post social_id from `oxygen posts get` (the id returned when the post was created).")
6387
+ .option("--account <ref>", "Sender account that authored the post (sender id, connection id, or Unipile account id). Omit for the org default.")
6388
+ .option("--approved", "Actually delete the post. Without it the command refuses and deletes nothing.")
6389
+ .option("--json", "Print a JSON envelope.")
6390
+ .action(async (options) => {
6391
+ await handleAsyncAction("posts delete", options, () => {
6392
+ const post = readOption(options.post);
6393
+ if (!post)
6394
+ throw new Error("--post is required (the composite social_id from `oxygen posts get`).");
6395
+ const account = readOption(options.account);
6396
+ // The DELETE route has no preview mode — it removes the live post on
6397
+ // the first call — so the approval gate lives here. Refuse rather than
6398
+ // silently deleting a real, public, irreversible artifact.
6399
+ if (!options.approved) {
6400
+ throw new OxygenError("approval_required", "Deleting a LinkedIn post is a real, irreversible public write. Re-run with --approved to delete it.", {
6401
+ details: { post, ...(account ? { account } : {}) },
6402
+ exitCode: 7,
6403
+ });
6404
+ }
6405
+ const params = new URLSearchParams();
6406
+ params.set("post", post);
6407
+ if (account)
6408
+ params.set("account", account);
6409
+ return requestOxygen(`/api/cli/linkedin/posts?${params.toString()}`, {
6410
+ method: "DELETE",
6411
+ });
6412
+ });
6032
6413
  })));
6033
6414
  program.addCommand(new Command("engagement")
6034
6415
  .description("Harvest a LinkedIn post's engagers (reactors + commenters) into an enrollable CRM static list. The harvest runs as a slow, durable background drip under a dedicated conservative read budget — it never bursts and never starves the sequencer's own reads.")
@@ -7007,6 +7388,8 @@ export function createProgram() {
7007
7388
  .option("--sequence-prioritization <mode>", "Under a tight daily budget, serve 'followups' or 'new_leads' first.")
7008
7389
  .option("--schedule-template <name>", "Name of a saved schedule template (oxygen schedules) backing the sending window.")
7009
7390
  .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
+ .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
+ .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.")
7010
7393
  .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.")
7011
7394
  .option("--json", "Print a JSON envelope.")
7012
7395
  .action(async (options) => {
@@ -7061,6 +7444,8 @@ export function createProgram() {
7061
7444
  .option("--sequence-prioritization <mode>", "Under a tight daily budget, serve 'followups' or 'new_leads' first.")
7062
7445
  .option("--schedule-template <name>", "Name of a saved schedule template (oxygen schedules) backing the sending window.")
7063
7446
  .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
+ .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
+ .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.")
7064
7449
  .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.")
7065
7450
  .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`).")
7066
7451
  .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).")
@@ -7123,9 +7508,10 @@ export function createProgram() {
7123
7508
  await handleAsyncAction("sequences get", options, () => requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}`));
7124
7509
  }))
7125
7510
  .addCommand(new Command("enroll")
7126
- .description("Enroll leads into a sequence from a JSON file of { leads: [...] }. When the sequence is bound to a source table, a lead's table_row_id auto-snapshots that row's columns (incl. AI/tool outputs) into row_values for {{column}} copy — explicit row_values win. Idempotent per table row. The org do-not-contact list is always enforced; --exclude-contacted and --suppress-list add further opt-in skips (reported under skipped_by_reason). Leads already owned by a sender account (from an earlier real send) are routed back to that same account; a lead owned by a sender NOT on this sequence is skipped (bound_to_other_sender) unless --ignore-sender-bindings.")
7511
+ .description("Enroll leads into a sequence. Enrolling spends no credits and sends nothing — dispatch only happens at `sequences start`. --from-table enrolls every not-yet-enrolled row of the sequence's bound source table (up to 500 per run; the response's from_table.has_more says whether to run again) — for a LinkedIn sequence the profile URL is read from the bound table's URL column, no pre-resolved provider ids needed. --leads-file enrolls an explicit JSON list { leads: [...] }: for an email/WhatsApp sequence a lead identified only by an email (row_values.email) or phone (row_values.phone) is auto-filed as a row in the sequence's leads table (auto-creating and binding one if there is none), deduped by email/phone, so no table has to exist first (the table is returned as leads_table with a web_url); a LinkedIn lead needs a lead_provider_id, a lead_profile_url, or a table_row_id whose row carries a LinkedIn URL. When the sequence is bound to a source table, a lead's table_row_id auto-snapshots that row's columns (incl. AI/tool outputs) into row_values for {{column}} copy — explicit row_values win. Idempotent per table row (and per email/phone for auto-filed leads). The org do-not-contact list is always enforced; --exclude-contacted and --suppress-list add further opt-in skips (reported under skipped_by_reason). Leads already owned by a sender account (from an earlier real send) are routed back to that same account; a lead owned by a sender NOT on this sequence is skipped (bound_to_other_sender) unless --ignore-sender-bindings.")
7127
7512
  .argument("<sequence>", "Sequence id or slug.")
7128
- .requiredOption("--leads-file <path>", "Path to a JSON file: { \"leads\": [{ lead_provider_id, lead_name, table_row_id, row_values }] }.")
7513
+ .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
+ .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.")
7129
7515
  .option("--exclude-contacted", "Also skip leads any OTHER active sequence is already contacting (cross-campaign exclusion). Off by default.")
7130
7516
  .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).")
7131
7517
  .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.")
@@ -7133,17 +7519,30 @@ export function createProgram() {
7133
7519
  .action(async (sequence, options) => {
7134
7520
  await handleAsyncAction("sequences enroll", options, () => {
7135
7521
  const leadsPath = readOption(options.leadsFile);
7136
- if (!leadsPath)
7137
- throw new Error("--leads-file is required.");
7138
- const parsed = readJsonFileValue(resolve(leadsPath), "--leads-file");
7522
+ if (options.fromTable && leadsPath) {
7523
+ throw new Error("Provide exactly one of --leads-file or --from-table.");
7524
+ }
7525
+ if (!options.fromTable && !leadsPath) {
7526
+ throw new Error("Provide exactly one of --leads-file or --from-table.");
7527
+ }
7139
7528
  const suppressList = parseSuppressListOption(options.suppressList);
7529
+ const sharedFlags = {
7530
+ ...(options.excludeContacted ? { exclude_contacted: true } : {}),
7531
+ ...(suppressList.length > 0 ? { suppress_list: suppressList } : {}),
7532
+ ...(options.ignoreSenderBindings ? { ignore_sender_bindings: true } : {}),
7533
+ };
7534
+ if (options.fromTable) {
7535
+ return requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/enroll`, {
7536
+ method: "POST",
7537
+ body: { from_table: true, ...sharedFlags },
7538
+ });
7539
+ }
7540
+ const parsed = readJsonFileValue(resolve(leadsPath), "--leads-file");
7140
7541
  return requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/enroll`, {
7141
7542
  method: "POST",
7142
7543
  body: {
7143
7544
  leads: parsed.leads ?? [],
7144
- ...(options.excludeContacted ? { exclude_contacted: true } : {}),
7145
- ...(suppressList.length > 0 ? { suppress_list: suppressList } : {}),
7146
- ...(options.ignoreSenderBindings ? { ignore_sender_bindings: true } : {}),
7545
+ ...sharedFlags,
7147
7546
  },
7148
7547
  });
7149
7548
  });
@@ -7174,7 +7573,7 @@ export function createProgram() {
7174
7573
  .addCommand(new Command("signal")
7175
7574
  .description("Record an external GTM signal (e.g. linkedin_connected, email_replied, company_raised_funds, job_change, web_visit, intent) onto a running sequence's enrollment(s) to drive signal-triggered branch/wait control. Target an enrollment by --enrollment or --lead (at least one is required); the server validates the signal name.")
7176
7575
  .argument("<sequence>", "Sequence id or slug.")
7177
- .requiredOption("--signal <name>", "Signal to record: linkedin_connected, linkedin_replied, email_sent, email_opened, email_clicked, email_replied, email_bounced, company_hiring, company_raised_funds, job_change, new_hire, web_visit, intent.")
7576
+ .requiredOption("--signal <name>", "Signal to record: linkedin_connected, linkedin_replied, whatsapp_replied, email_sent, email_opened, email_clicked, email_replied, email_bounced, company_hiring, company_raised_funds, job_change, new_hire, web_visit, intent.")
7178
7577
  .option("--enrollment <id>", "Target enrollment id.")
7179
7578
  .option("--lead <provider_id>", "Target enrollment by its lead provider id.")
7180
7579
  .option("--json", "Print a JSON envelope.")
@@ -7386,16 +7785,19 @@ export function createProgram() {
7386
7785
  });
7387
7786
  })));
7388
7787
  program.addCommand(new Command("managed-inboxes")
7389
- .description("Managed whitelabel sending inboxes on the OXYGEN-managed Cold Mail Reseller (CMR) account: subscribe a domain + N mailboxes (google/microsoft) as a recurring MONTHLY subscription billed to Oxygen credits, list/get your subscriptions, and cancel. Subscribe/cancel are approval-gated (preview → re-run with --approved --quote).")
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.")
7390
7789
  .addCommand(new Command("subscribe")
7391
- .description("Subscribe a domain + mailboxes as a managed monthly CMR inbox subscription. WITHOUT --approved this prints a priced PREVIEW with a quote_id (nothing ordered or charged); re-run with --approved --quote <id> to place the order. Fails closed until the managed CMR wallet (CMR_RESELLER_API_KEY) + founder-signed pricing (CMR_MANAGED_INBOX_CREDITS_PER_MAILBOX) are configured.")
7790
+ .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.")
7392
7791
  .requiredOption("--domain <domain>", "Sending domain to register + host the mailboxes (e.g. send.acme.com).")
7393
- .requiredOption("--provider <provider>", "Mailbox provider: google or microsoft.")
7394
- .option("--mailboxes <json>", "JSON array of mailboxes: [{\"username\",\"first_name\",\"last_name\",\"profile_picture?\"}].")
7792
+ .requiredOption("--provider <provider>", "Mailbox PLATFORM: google, microsoft, or azure. (google/microsoft cap at 5 mailboxes per domain; azure allows 100.)")
7793
+ .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
+ .option("--mailboxes <json>", "JSON array of mailboxes: [{\"username\",\"first_name\",\"last_name\"}].")
7395
7795
  .option("--file <path>", "Path to a JSON file { \"mailboxes\": [...] } (alternative to --mailboxes).")
7396
- .option("--years <n>", "Years to register the domain for (positive whole number). Defaults to 1.")
7397
- .option("--billing <path>", "Path to a JSON file with the CMR billing address (required on the FIRST subscribe for an org that has no CMR account yet).")
7398
- .option("--zapshield", "Add the Zapshield deliverability-protection add-on to the domain (a one-time per-domain add-on billed once its price is founder-signed; recorded but not charged until then).")
7796
+ .option("--years <n>", "Years to register the domain for (1-10). Defaults to 1.")
7797
+ .option("--redirect-url <url>", "Where the domain's web root redirects. Defaults to https://<domain>.")
7798
+ .option("--billing <path>", "Path to a JSON file with the registrant/WHOIS contact (first_name, last_name, phone, country, city, state, address_line_one, postal_code). Required — a registrar needs a real registrant.")
7799
+ .option("--warmup", "Add the vendor's warmup pool for every mailbox (a recurring monthly per-mailbox add-on).")
7800
+ .option("--infraguard", "Add deliverability monitoring for the domain — blacklist, DNS-drift, and bounce checks (a recurring monthly per-domain add-on).")
7399
7801
  .option("--approved", "Place the order (requires --quote). Without it, a priced preview is returned.")
7400
7802
  .option("--quote <id>", "The quote_id from a fresh preview. Required with --approved.")
7401
7803
  .option("--json", "Print a JSON envelope.")
@@ -7406,7 +7808,7 @@ export function createProgram() {
7406
7808
  if (!domain)
7407
7809
  throw new Error("--domain is required.");
7408
7810
  if (!provider)
7409
- throw new Error("--provider is required (google or microsoft).");
7811
+ throw new Error("--provider is required (google, microsoft, or azure).");
7410
7812
  let mailboxes;
7411
7813
  const mailboxesJson = readOption(options.mailboxes);
7412
7814
  const filePath = readOption(options.file);
@@ -7421,6 +7823,8 @@ export function createProgram() {
7421
7823
  throw new Error("Provide --mailboxes <json> or --file <path>.");
7422
7824
  }
7423
7825
  const years = readOption(options.years);
7826
+ const vendor = readOption(options.vendor);
7827
+ const redirectUrl = readOption(options.redirectUrl);
7424
7828
  const billingPath = readOption(options.billing);
7425
7829
  const billing = billingPath ? readJsonFileValue(resolve(billingPath), "--billing") : undefined;
7426
7830
  const quote = readOption(options.quote);
@@ -7430,9 +7834,12 @@ export function createProgram() {
7430
7834
  domain,
7431
7835
  provider,
7432
7836
  mailboxes,
7837
+ ...(vendor ? { vendor } : {}),
7433
7838
  ...(years ? { years: Number(years) } : {}),
7839
+ ...(redirectUrl ? { redirect_url: redirectUrl } : {}),
7434
7840
  ...(billing ? { billing } : {}),
7435
- ...(options.zapshield ? { zapshield: true } : {}),
7841
+ ...(options.warmup ? { warmup: true } : {}),
7842
+ ...(options.infraguard ? { infraguard: true } : {}),
7436
7843
  ...(options.approved ? { approved: true } : {}),
7437
7844
  ...(quote ? { quote_id: quote } : {}),
7438
7845
  },
@@ -7440,7 +7847,7 @@ export function createProgram() {
7440
7847
  });
7441
7848
  }))
7442
7849
  .addCommand(new Command("list")
7443
- .description("List the org's managed inbox subscriptions with provider, mailbox count, monthly price, CMR lifecycle status, and internal billing posture. Read-only, 0 Oxygen credits.")
7850
+ .description("List the org's managed inbox subscriptions with vendor, platform, mailbox count, monthly USD price, lifecycle status, and internal billing posture. Read-only, 0 Oxygen credits.")
7444
7851
  .option("--status <status>", "Filter by status: pending, active, renewing, past_due, expired, cancelled, or failed.")
7445
7852
  .option("--json", "Print a JSON envelope.")
7446
7853
  .action(async (options) => {
@@ -7454,14 +7861,36 @@ export function createProgram() {
7454
7861
  });
7455
7862
  }))
7456
7863
  .addCommand(new Command("get")
7457
- .description("Get one managed inbox subscription by domain (provider, mailbox count, monthly price, CMR subscription id, lifecycle status, auto-renew, period end, internal billing status). Read-only, 0 Oxygen credits.")
7864
+ .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.")
7458
7865
  .argument("<domain>", "The managed inbox domain (e.g. send.acme.com).")
7459
7866
  .option("--json", "Print a JSON envelope.")
7460
7867
  .action(async (domain, options) => {
7461
7868
  await handleAsyncAction("managed-inboxes get", options, () => requestOxygen(`/api/cli/managed-inboxes/${encodeURIComponent(domain)}`));
7869
+ }))
7870
+ .addCommand(new Command("warmup")
7871
+ .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).")
7873
+ .option("--enable", "Turn warmup on (a recurring monthly per-mailbox add-on).")
7874
+ .option("--disable", "Turn warmup off and stop billing for it.")
7875
+ .option("--approved", "Apply the change (otherwise a priced preview is returned).")
7876
+ .option("--json", "Print a JSON envelope.")
7877
+ .action(async (domain, options) => {
7878
+ await handleAsyncAction("managed-inboxes warmup", options, () => {
7879
+ if (options.enable === options.disable) {
7880
+ throw new Error("Pass exactly one of --enable or --disable.");
7881
+ }
7882
+ return requestOxygen("/api/cli/managed-inboxes/warmup", {
7883
+ method: "POST",
7884
+ body: {
7885
+ domain,
7886
+ enabled: options.enable === true,
7887
+ ...(options.approved ? { approved: true } : {}),
7888
+ },
7889
+ });
7890
+ });
7462
7891
  }))
7463
7892
  .addCommand(new Command("cancel")
7464
- .description("Cancel a managed inbox subscription by domain (stops CMR's recurring monthly charge and frees the domain at period end). WITHOUT --approved prints a preview; re-run with --approved to cancel. Cancelling is free — 0 Oxygen credits.")
7893
+ .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.")
7465
7894
  .argument("<domain>", "The managed inbox domain to cancel.")
7466
7895
  .option("--approved", "Actually cancel (otherwise a preview is returned).")
7467
7896
  .option("--json", "Print a JSON envelope.")
@@ -7744,32 +8173,19 @@ export function createProgram() {
7744
8173
  });
7745
8174
  })))
7746
8175
  .addCommand(new Command("order")
7747
- .description("Order NEW sending mailboxes (domain + inboxes + warmup) on the OXYGEN-managed Zapmail wallet, billed to Oxygen credits. Without --approved returns a priced preview + quote_id. FAILS CLOSED (409) until managed ordering is enabled.")
7748
- .requiredOption("--provider <provider>", "Mailbox provider: google or microsoft.")
7749
- .requiredOption("--domains <domains>", "Comma-separated sending domains (e.g. send.acme.com,mail.acme.io).")
7750
- .option("--mailboxes-per-domain <n>", "Mailboxes to provision per domain (positive whole number). Defaults to 1.")
7751
- .option("--workspace <id>", "Optional Zapmail workspace key to provision into.")
7752
- .option("--approved", "Place the order (requires --quote from a fresh preview).")
7753
- .option("--quote <id>", "The quote_id from a fresh preview (required with --approved).")
7754
- .option("--json", "Print a JSON envelope.")
7755
- .action(async (options) => {
7756
- await handleAsyncAction("mailboxes order", options, () => {
7757
- const domains = readOption(options.domains)?.split(",").map((d) => d.trim()).filter(Boolean) ?? [];
7758
- const perDomain = readOption(options.mailboxesPerDomain);
7759
- const workspace = readOption(options.workspace);
7760
- const quote = readOption(options.quote);
7761
- return requestOxygen("/api/cli/mailboxes/order", {
7762
- method: "POST",
7763
- body: {
7764
- provider: readOption(options.provider),
7765
- domains,
7766
- ...(perDomain ? { mailboxes_per_domain: Number(perDomain) } : {}),
7767
- ...(workspace ? { workspace_id: workspace } : {}),
7768
- ...(options.approved ? { approved: true } : {}),
7769
- ...(quote ? { quote_id: quote } : {}),
7770
- },
7771
- });
7772
- });
8176
+ .description("RETIRED — managed mailboxes are now purchased as monthly subscriptions via `oxygen managed-inboxes subscribe` (InboxKit-backed). This legacy Zapmail order path was never enabled anywhere and now always fails with code `gone`.")
8177
+ // Swallow the old flags (--provider/--domains/--approved/...) so a
8178
+ // stale invocation reaches the tombstone envelope instead of dying
8179
+ // on commander's unknown-option parse error.
8180
+ .allowUnknownOption(true)
8181
+ .allowExcessArguments(true)
8182
+ .action(() => {
8183
+ emitCliFailure("mailboxes order", new OxygenError("gone", "`oxygen mailboxes order` is retired — managed mailboxes are now purchased as monthly subscriptions via `oxygen managed-inboxes subscribe` (InboxKit-backed). Preview a quote, then re-run with --approved --quote <id>. Nothing was ordered or charged.", {
8184
+ details: {
8185
+ replacement: "oxygen managed-inboxes subscribe",
8186
+ deep_link: "https://oxygen-agent.com/billing",
8187
+ },
8188
+ }));
7773
8189
  }))
7774
8190
  .addCommand(new Command("orders")
7775
8191
  .description("List the org's managed mailbox provisioning orders (newest first).")
@@ -7953,14 +8369,14 @@ export function createProgram() {
7953
8369
  await handleAsyncAction("domains postmaster status", options, () => requestOxygen(`/api/cli/domains/${encodeURIComponent(domain)}/postmaster`));
7954
8370
  })))
7955
8371
  .addCommand(new Command("add")
7956
- .description("Onboard a domain registered elsewhere by creating a Cloudflare zone for it, so its DNS can be managed here. Returns the nameservers to set at your registrar. Idempotent.")
8372
+ .description("BYOK CLOUDFLARE: onboard a domain registered elsewhere by creating a zone in your connected Cloudflare account. For managed domain + inbox provisioning use `managed-inboxes subscribe`.")
7957
8373
  .argument("<domain>", "Apex domain to add, such as acme.com.")
7958
8374
  .option("--json", "Print a JSON envelope.")
7959
8375
  .action(async (domain, options) => {
7960
8376
  await handleAsyncAction("domains add", options, () => requestOxygen("/api/cli/domains/add", { method: "POST", body: { domain } }));
7961
8377
  }))
7962
8378
  .addCommand(new Command("search")
7963
- .description("Search Cloudflare Registrar for available domains with registration/renewal pricing. Free — nothing is purchased.")
8379
+ .description("BYOK CLOUDFLARE: search Registrar for available domains with registration/renewal pricing. Free — nothing is purchased.")
7964
8380
  .argument("<query>", "Search text, such as a brand or keyword.")
7965
8381
  .option("--json", "Print a JSON envelope.")
7966
8382
  .action(async (query, options) => {
@@ -7970,7 +8386,7 @@ export function createProgram() {
7970
8386
  });
7971
8387
  }))
7972
8388
  .addCommand(new Command("check")
7973
- .description("Check availability and pricing for up to 20 specific domains via Cloudflare Registrar. Free — nothing is purchased.")
8389
+ .description("BYOK CLOUDFLARE: check availability and pricing for up to 20 exact domains. Free — nothing is purchased.")
7974
8390
  .argument("<domains...>", "Domain names to check (max 20).")
7975
8391
  .option("--json", "Print a JSON envelope.")
7976
8392
  .action(async (domains, options) => {
@@ -7985,7 +8401,7 @@ export function createProgram() {
7985
8401
  });
7986
8402
  }))
7987
8403
  .addCommand(new Command("buy")
7988
- .description("Buy domains through Cloudflare Registrar. REAL MONEY, NON-REFUNDABLE — the preview determines billing: BYOK orgs (connected Cloudflare token) bill their own Cloudflare payment method (0 Oxygen credits); managed orgs (no connected token) bill Oxygen credits at a markup on the shared Oxygen Cloudflare account. The preview's `billing` note states which applies. Without --approved, returns a priced preview plus a quote_id; re-run with --approved --quote <id> to execute.")
8404
+ .description("BYOK CLOUDFLARE ONLY. REAL MONEY, NON-REFUNDABLE: approved purchases bill your connected Cloudflare account and use 0 Oxygen credits. Without --approved, returns a priced preview plus quote_id. For a managed domain + inbox bundle use `managed-inboxes subscribe` (InboxKit).")
7989
8405
  .argument("<domains...>", "Domain names to buy.")
7990
8406
  .option("--approved", "Execute the purchase. Without this flag, returns a preview only.")
7991
8407
  .option("--quote <id>", "Quote id from the preview (required with --approved).")
@@ -7998,7 +8414,7 @@ export function createProgram() {
7998
8414
  await handleAsyncAction("domains buy", options, () => runDomainsBuy(domains, options));
7999
8415
  }))
8000
8416
  .addCommand(new Command("adopt")
8001
- .description("STAFF: Import domains that already exist in the shared Oxygen-managed Cloudflare account into a workspace, then mirror them into the domain cache. FREE — no Oxygen credits and no Cloudflare purchase (the domains are already registered); this differs from `domains buy`. Idempotent. Without --yes, prints a preview of what would be adopted/skipped.")
8417
+ .description("STAFF LEGACY RECOVERY: attach domains that already exist in the shared Oxygen-managed Cloudflare account to their workspace. FREE; no purchase. New managed domain + inbox bundles use InboxKit. Without --yes, previews only.")
8002
8418
  .argument("[domains...]", "Specific domains to adopt. Omit and pass --all to adopt every zone in the managed account.")
8003
8419
  .option("--all", "Adopt every zone discovered in the managed Cloudflare account.")
8004
8420
  .option("--organization-id <id>", "STAFF override: adopt into this workspace instead of the calling org.")
@@ -8362,14 +8778,18 @@ export function createProgram() {
8362
8778
  }));
8363
8779
  }))
8364
8780
  .addCommand(new Command("enable")
8365
- .description("Enable a workflow automation and its current trigger.")
8781
+ .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.")
8366
8782
  .argument("<workflow>", "Workflow id, slug, or name.")
8367
8783
  .option("--json", "Print a JSON envelope.")
8368
8784
  .action(async (workflow, options) => {
8369
- await handleAsyncAction("workflows enable", options, () => requestOxygen("/api/cli/workflows/enable", {
8370
- method: "POST",
8371
- body: { workflow },
8372
- }));
8785
+ await handleAsyncAction("workflows enable", options, async () => {
8786
+ const data = await requestOxygen("/api/cli/workflows/enable", {
8787
+ method: "POST",
8788
+ body: { workflow },
8789
+ });
8790
+ writeAutomationProjection(data);
8791
+ return data;
8792
+ });
8373
8793
  }))
8374
8794
  .addCommand(new Command("disable")
8375
8795
  .description("Disable a workflow automation and its current trigger.")
@@ -8380,7 +8800,56 @@ export function createProgram() {
8380
8800
  method: "POST",
8381
8801
  body: { workflow },
8382
8802
  }));
8383
- }));
8803
+ }))
8804
+ .addCommand(new Command("mcp")
8805
+ .description("Publish workflows as dynamic MCP tools (oxygen_workflow_<slug>) — the 'Clay Functions' pattern.")
8806
+ .addCommand(new Command("enable")
8807
+ .description("Publish an active workflow as a callable MCP tool so any MCP client can run it.")
8808
+ .argument("<workflow>", "Workflow id, slug, or name.")
8809
+ .option("--display-name <name>", "Display name for the published MCP tool.")
8810
+ .option("--description <text>", "Description for the published MCP tool.")
8811
+ .option("--json", "Print a JSON envelope.")
8812
+ .action(async (workflow, options) => {
8813
+ await handleAsyncAction("workflows mcp enable", options, async () => {
8814
+ const data = await requestOxygen("/api/cli/workflows/mcp", {
8815
+ method: "POST",
8816
+ body: {
8817
+ workflow,
8818
+ enabled: true,
8819
+ ...(readOption(options.displayName) ? { display_name: readOption(options.displayName) } : {}),
8820
+ ...(readOption(options.description) ? { description: readOption(options.description) } : {}),
8821
+ },
8822
+ });
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.
8827
+ 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`);
8832
+ }
8833
+ }
8834
+ return data;
8835
+ });
8836
+ }))
8837
+ .addCommand(new Command("disable")
8838
+ .description("Unpublish a workflow's MCP tool. The workflow stays active and callable via `oxygen workflows call`.")
8839
+ .argument("<workflow>", "Workflow id, slug, or name.")
8840
+ .option("--json", "Print a JSON envelope.")
8841
+ .action(async (workflow, options) => {
8842
+ await handleAsyncAction("workflows mcp disable", options, () => requestOxygen("/api/cli/workflows/mcp", {
8843
+ method: "POST",
8844
+ body: { workflow, enabled: false },
8845
+ }));
8846
+ }))
8847
+ .addCommand(new Command("list")
8848
+ .description("List workflows currently published as MCP tools.")
8849
+ .option("--json", "Print a JSON envelope.")
8850
+ .action(async (options) => {
8851
+ await handleAsyncAction("workflows mcp list", options, () => requestOxygen("/api/cli/workflows/mcp-callable"));
8852
+ })));
8384
8853
  program
8385
8854
  .command("skills")
8386
8855
  .description("Agent skill discovery and installation commands.")
@@ -9555,7 +10024,7 @@ function readCompaniesEnrichBody(table, options) {
9555
10024
  return body;
9556
10025
  }
9557
10026
  function readCompaniesEnrichSelection(options) {
9558
- const explicitSelection = readSelectionJsonOption(options.selectionJson);
10027
+ const explicitSelection = readJsonObjectOption(options.selectionJson);
9559
10028
  const hasAll = Boolean(options.all);
9560
10029
  const limit = readPositiveInt(options.limit);
9561
10030
  const rowIds = readCsvOption(options.rowIds);
@@ -9586,12 +10055,6 @@ function readFilterSelectionOption(value) {
9586
10055
  const filters = readFilterJsonOption(value);
9587
10056
  return filters ? { mode: "filter", filters } : undefined;
9588
10057
  }
9589
- function readSelectionJsonOption(value) {
9590
- const raw = readOption(value);
9591
- if (!raw)
9592
- return undefined;
9593
- return parseJsonObject(raw);
9594
- }
9595
10058
  function readFilterJsonOption(value) {
9596
10059
  const raw = readOption(value);
9597
10060
  if (!raw)
@@ -9615,12 +10078,6 @@ function readFilterJsonOption(value) {
9615
10078
  }
9616
10079
  return filters;
9617
10080
  }
9618
- function readFilterTreeJsonOption(value) {
9619
- const raw = readOption(value);
9620
- if (!raw)
9621
- return undefined;
9622
- return parseJsonObject(raw);
9623
- }
9624
10081
  function readSortJsonOption(value) {
9625
10082
  const raw = readOption(value);
9626
10083
  if (!raw)
@@ -10917,12 +11374,6 @@ function chunk(values, size) {
10917
11374
  function readCount(value) {
10918
11375
  return typeof value === "number" && Number.isFinite(value) ? value : 0;
10919
11376
  }
10920
- function readRecordString(value, key) {
10921
- if (!value || typeof value !== "object" || Array.isArray(value))
10922
- return null;
10923
- const entry = value[key];
10924
- return typeof entry === "string" ? entry : null;
10925
- }
10926
11377
  function readRecord(value, key) {
10927
11378
  if (!value || typeof value !== "object" || Array.isArray(value))
10928
11379
  return null;
@@ -10957,9 +11408,6 @@ function isTerminalTableActionRunStatus(status) {
10957
11408
  || status === "failed"
10958
11409
  || status === "canceled";
10959
11410
  }
10960
- function sleep(ms) {
10961
- return new Promise((resolve) => setTimeout(resolve, ms));
10962
- }
10963
11411
  async function resolveActiveProfileWithSource() {
10964
11412
  const resolution = await resolveActiveProfile();
10965
11413
  let source;
@@ -11031,9 +11479,7 @@ async function handleWhoamiAction(options) {
11031
11479
  process.stdout.write(formatWhoami(identity, context));
11032
11480
  }
11033
11481
  catch (error) {
11034
- const failure = toFailure("whoami", error);
11035
- writeJson(failure);
11036
- process.exitCode = error instanceof OxygenError ? exitCodeForOxygenError(error) : 1;
11482
+ emitCliFailure("whoami", error);
11037
11483
  }
11038
11484
  }
11039
11485
  async function handleOnboardingStartAction(options) {
@@ -11046,9 +11492,7 @@ async function handleOnboardingStartAction(options) {
11046
11492
  process.stdout.write(formatOnboardingStart(data));
11047
11493
  }
11048
11494
  catch (error) {
11049
- const failure = toFailure("onboarding start", error);
11050
- writeJson(failure);
11051
- process.exitCode = error instanceof OxygenError ? exitCodeForOxygenError(error) : 1;
11495
+ emitCliFailure("onboarding start", error);
11052
11496
  }
11053
11497
  }
11054
11498
  async function handleOnboardingResetAction(options) {
@@ -11070,9 +11514,7 @@ async function handleOnboardingResetAction(options) {
11070
11514
  process.stdout.write(formatOnboardingReset(data));
11071
11515
  }
11072
11516
  catch (error) {
11073
- const failure = toFailure("onboarding reset", error);
11074
- writeJson(failure);
11075
- process.exitCode = error instanceof OxygenError ? exitCodeForOxygenError(error) : 1;
11517
+ emitCliFailure("onboarding reset", error);
11076
11518
  }
11077
11519
  }
11078
11520
  function formatOnboardingReset(data) {
@@ -11128,9 +11570,7 @@ async function handleLoginAction(options) {
11128
11570
  }
11129
11571
  }
11130
11572
  catch (error) {
11131
- const failure = toFailure("login", error);
11132
- writeJson(failure);
11133
- process.exitCode = error instanceof OxygenError ? exitCodeForOxygenError(error) : 1;
11573
+ emitCliFailure("login", error);
11134
11574
  }
11135
11575
  }
11136
11576
  async function handleAuthUseTokenAction(options) {
@@ -11173,9 +11613,7 @@ async function handleAuthDoctorAction(options) {
11173
11613
  process.stdout.write(formatAuthDoctor(data));
11174
11614
  }
11175
11615
  catch (error) {
11176
- const failure = toFailure("auth doctor", error);
11177
- writeJson(failure);
11178
- process.exitCode = error instanceof OxygenError ? exitCodeForOxygenError(error) : 1;
11616
+ emitCliFailure("auth doctor", error);
11179
11617
  }
11180
11618
  }
11181
11619
  async function runAuthDoctor() {
@@ -11264,9 +11702,7 @@ async function handleOrgUseAction(organization, options, command) {
11264
11702
  writeJson(result);
11265
11703
  }
11266
11704
  catch (error) {
11267
- const failure = toFailure(command, error);
11268
- writeJson(failure);
11269
- process.exitCode = error instanceof OxygenError ? exitCodeForOxygenError(error) : 1;
11705
+ emitCliFailure(command, error);
11270
11706
  }
11271
11707
  }
11272
11708
  async function handleProfilesListAction(options) {
@@ -11283,9 +11719,7 @@ async function handleProfilesListAction(options) {
11283
11719
  process.stdout.write(formatProfilesList(data));
11284
11720
  }
11285
11721
  catch (error) {
11286
- const failure = toFailure("profiles list", error);
11287
- writeJson(failure);
11288
- process.exitCode = error instanceof OxygenError ? exitCodeForOxygenError(error) : 1;
11722
+ emitCliFailure("profiles list", error);
11289
11723
  }
11290
11724
  }
11291
11725
  async function handleProfilesUseAction(profile, options) {
@@ -11304,9 +11738,7 @@ async function handleProfilesUseAction(profile, options) {
11304
11738
  process.stdout.write(formatProfileUseSuccess(data.profile, { totalProfiles }));
11305
11739
  }
11306
11740
  catch (error) {
11307
- const failure = toFailure("profiles use", error);
11308
- writeJson(failure);
11309
- process.exitCode = error instanceof OxygenError ? exitCodeForOxygenError(error) : 1;
11741
+ emitCliFailure("profiles use", error);
11310
11742
  }
11311
11743
  }
11312
11744
  async function handleProfilesEnvAction(profile, options) {
@@ -11343,9 +11775,7 @@ async function handleProfilesEnvAction(profile, options) {
11343
11775
  process.stdout.write(`export OXYGEN_API_URL=${shellQuote(match.apiUrl)}\n`);
11344
11776
  }
11345
11777
  catch (error) {
11346
- const failure = toFailure("profiles env", error);
11347
- writeJson(failure);
11348
- process.exitCode = error instanceof OxygenError ? exitCodeForOxygenError(error) : 1;
11778
+ emitCliFailure("profiles env", error);
11349
11779
  }
11350
11780
  }
11351
11781
  async function handleProfilesCurrentAction(options) {
@@ -11367,9 +11797,7 @@ async function handleProfilesCurrentAction(options) {
11367
11797
  process.stdout.write(formatProfilesCurrent(context));
11368
11798
  }
11369
11799
  catch (error) {
11370
- const failure = toFailure("profiles current", error);
11371
- writeJson(failure);
11372
- process.exitCode = error instanceof OxygenError ? exitCodeForOxygenError(error) : 1;
11800
+ emitCliFailure("profiles current", error);
11373
11801
  }
11374
11802
  }
11375
11803
  function shellQuote(value) {
@@ -11396,9 +11824,7 @@ async function handleLogoutAction(options) {
11396
11824
  process.stdout.write(formatLogoutSuccess(result));
11397
11825
  }
11398
11826
  catch (error) {
11399
- const failure = toFailure("logout", error);
11400
- writeJson(failure);
11401
- process.exitCode = error instanceof OxygenError ? exitCodeForOxygenError(error) : 1;
11827
+ emitCliFailure("logout", error);
11402
11828
  }
11403
11829
  }
11404
11830
  async function handleUpdateAction(options) {
@@ -11411,9 +11837,7 @@ async function handleUpdateAction(options) {
11411
11837
  process.stdout.write(formatUpdateSuccess(result));
11412
11838
  }
11413
11839
  catch (error) {
11414
- const failure = toFailure("update", error);
11415
- writeJson(failure);
11416
- process.exitCode = error instanceof OxygenError ? exitCodeForOxygenError(error) : 1;
11840
+ emitCliFailure("update", error);
11417
11841
  }
11418
11842
  }
11419
11843
  function buildApiKeyCreateBody(options) {
@@ -11477,8 +11901,16 @@ async function login(options) {
11477
11901
  chosenProfile = picked.name;
11478
11902
  renamed = picked.renamed;
11479
11903
  }
11480
- const profile = await saveCredentials(credentials, process.env, { profile: chosenProfile });
11904
+ const profile = await saveCredentials(credentials, process.env, {
11905
+ profile: chosenProfile,
11906
+ allowHostChange: options.force === true,
11907
+ });
11481
11908
  const skillsInstall = await runAutomaticSkillsInstall({ apiUrl: credentials.apiUrl, credentials });
11909
+ // Opt-in eager mirror of every reachable workspace, off by default so login
11910
+ // stays fast and non-surprising. Best-effort: a sync failure never fails login.
11911
+ if (process.env.OXYGEN_KNOWLEDGE_AUTOSYNC === "1") {
11912
+ await runKnowledgeMirrorSyncAll({ ifStale: true }).catch(() => undefined);
11913
+ }
11482
11914
  if (!options.json) {
11483
11915
  process.stdout.write(formatLoginSuccessForResolved(loginIdentity, profile, { renamed, skillsInstall }));
11484
11916
  const hint = await buildPostLoginHint(profile);
@@ -11521,7 +11953,10 @@ async function applyAuthToken(options) {
11521
11953
  chosenProfile = picked.name;
11522
11954
  renamed = picked.renamed;
11523
11955
  }
11524
- const profile = await saveCredentials(loginIdentity.credentials, process.env, { profile: chosenProfile });
11956
+ const profile = await saveCredentials(loginIdentity.credentials, process.env, {
11957
+ profile: chosenProfile,
11958
+ allowHostChange: options.force === true,
11959
+ });
11525
11960
  return {
11526
11961
  identity: loginIdentity.whoami,
11527
11962
  loginIdentity,
@@ -11576,18 +12011,19 @@ async function resolveLoginIdentity(token, apiUrl) {
11576
12011
  };
11577
12012
  }
11578
12013
  function pickProfileForLogin(loginIdentity) {
12014
+ const apiUrl = loginIdentity.credentials.apiUrl;
11579
12015
  if (loginIdentity.credentials.authKind === "user_session") {
11580
12016
  const seed = loginIdentity.activeOrganization?.slug
11581
12017
  ?? loginIdentity.user.email
11582
12018
  ?? loginIdentity.user.id;
11583
- return pickProfileNameForUserSession(loginIdentity.user.id, seed);
12019
+ return pickProfileNameForUserSession(loginIdentity.user.id, seed, apiUrl);
11584
12020
  }
11585
12021
  const organization = loginIdentity.activeOrganization;
11586
12022
  if (!organization) {
11587
- return pickProfileNameForUserSession(loginIdentity.user.id, loginIdentity.user.email ?? loginIdentity.user.id);
12023
+ return pickProfileNameForUserSession(loginIdentity.user.id, loginIdentity.user.email ?? loginIdentity.user.id, apiUrl);
11588
12024
  }
11589
12025
  const seed = organization.slug?.trim() || organization.id;
11590
- return pickProfileNameForIdentity(organization.id, seed);
12026
+ return pickProfileNameForIdentity(organization.id, seed, apiUrl);
11591
12027
  }
11592
12028
  function readEnvProfileName() {
11593
12029
  const value = process.env.OXYGEN_PROFILE?.trim();
@@ -11624,17 +12060,20 @@ function isUserSessionToken(token) {
11624
12060
  return token.startsWith("oxy_user_");
11625
12061
  }
11626
12062
  async function buildPostLoginHint(activeProfile) {
11627
- if (output.isTTY !== true || process.env.NO_COLOR) {
11628
- const state = await listCredentialProfiles().catch(() => null);
11629
- if (!state || state.profiles.length < 2)
11630
- return "";
11631
- return `\n Pin this terminal: eval "$(oxygen profiles env ${activeProfile})"\n\n`;
11632
- }
12063
+ const plain = output.isTTY !== true || Boolean(process.env.NO_COLOR);
12064
+ const styles = ansi(!plain);
12065
+ const label = (text) => (plain ? text : styles.dim(text));
12066
+ const command = (text) => (plain ? text : styles.bold(text));
11633
12067
  const state = await listCredentialProfiles().catch(() => null);
11634
- if (!state || state.profiles.length < 2)
11635
- return "";
11636
- const styles = ansi(true);
11637
- return `\n ${styles.dim("Pin this terminal:")} ${styles.bold(`eval "$(oxygen profiles env ${activeProfile})"`)}\n\n`;
12068
+ const lines = [];
12069
+ // The pin hint only makes sense with more than one stored profile.
12070
+ if (state && state.profiles.length >= 2) {
12071
+ lines.push(` ${label("Pin this terminal:")} ${command(`eval "$(oxygen profiles env ${activeProfile})"`)}`);
12072
+ }
12073
+ // Mirror every reachable workspace into local markdown so agents can read/grep
12074
+ // the wiki offline; --if-stale keeps a large multi-workspace loop cheap.
12075
+ lines.push(` ${label("Mirror your wikis:")} ${command("oxygen knowledge sync --all --if-stale")}`);
12076
+ return `\n${lines.join("\n")}\n\n`;
11638
12077
  }
11639
12078
  async function promptForToken(options) {
11640
12079
  const fallbackLoginUrl = createCliLoginUrl(options.apiUrl);
@@ -12018,10 +12457,7 @@ async function handleSequenceSignalAction(sequence, options) {
12018
12457
  process.stdout.write(`Recorded ${recordedSignal} on ${updated} enrollment(s).${link ? ` ${link}` : ""}\n`);
12019
12458
  }
12020
12459
  catch (error) {
12021
- const failure = toFailure("sequences signal", error);
12022
- writeJson(failure);
12023
- writeMaxCreditsHint(error);
12024
- process.exitCode = error instanceof OxygenError ? exitCodeForOxygenError(error) : 1;
12460
+ emitCliFailure("sequences signal", error);
12025
12461
  }
12026
12462
  }
12027
12463
  async function handleSequenceVariantsAction(sequence, options) {
@@ -12055,10 +12491,7 @@ async function handleSequenceVariantsAction(sequence, options) {
12055
12491
  process.stdout.write(formatSequenceVariants(data));
12056
12492
  }
12057
12493
  catch (error) {
12058
- const failure = toFailure("sequences variants", error);
12059
- writeJson(failure);
12060
- writeMaxCreditsHint(error);
12061
- process.exitCode = error instanceof OxygenError ? exitCodeForOxygenError(error) : 1;
12494
+ emitCliFailure("sequences variants", error);
12062
12495
  }
12063
12496
  }
12064
12497
  function formatVariantCell(value) {
@@ -12186,10 +12619,7 @@ async function handleTablesTidySuggestAction(table, options) {
12186
12619
  process.stdout.write(formatTidySuggestions(data));
12187
12620
  }
12188
12621
  catch (error) {
12189
- const failure = toFailure("tables tidy-suggest", error);
12190
- writeJson(failure);
12191
- writeMaxCreditsHint(error);
12192
- process.exitCode = error instanceof OxygenError ? exitCodeForOxygenError(error) : 1;
12622
+ emitCliFailure("tables tidy-suggest", error);
12193
12623
  }
12194
12624
  }
12195
12625
  function formatTidyCount(value) {
@@ -12567,9 +12997,7 @@ async function handleSupportAdminWorkflowAction(ticketId, options) {
12567
12997
  process.stdout.write(formatSupportAdminWorkflowTicket(data));
12568
12998
  }
12569
12999
  catch (error) {
12570
- const failure = toFailure("support admin workflow", error);
12571
- writeJson(failure);
12572
- process.exitCode = error instanceof OxygenError ? exitCodeForOxygenError(error) : 1;
13000
+ emitCliFailure("support admin workflow", error);
12573
13001
  }
12574
13002
  }
12575
13003
  async function handleSupportAdminUpdateAction(ticketId, options) {
@@ -12583,9 +13011,7 @@ async function handleSupportAdminUpdateAction(ticketId, options) {
12583
13011
  process.stdout.write(formatSupportAdminUpdateTicket(data));
12584
13012
  }
12585
13013
  catch (error) {
12586
- const failure = toFailure("support admin update", error);
12587
- writeJson(failure);
12588
- process.exitCode = error instanceof OxygenError ? exitCodeForOxygenError(error) : 1;
13014
+ emitCliFailure("support admin update", error);
12589
13015
  }
12590
13016
  }
12591
13017
  function formatSupportAdminWorkflowStep(styles, label, status, artifactLabel, artifact) {
@@ -12883,8 +13309,12 @@ function buildKnowledgePageListBody(options) {
12883
13309
  }
12884
13310
  function buildKnowledgeGraphBody(options) {
12885
13311
  const maxNodes = readPositiveInt(options.maxNodes);
13312
+ const center = readOption(options.center);
13313
+ const depth = readPositiveInt(options.depth);
12886
13314
  return {
12887
13315
  ...(maxNodes !== undefined ? { max_nodes: maxNodes } : {}),
13316
+ ...(center ? { center } : {}),
13317
+ ...(depth !== undefined ? { depth } : {}),
12888
13318
  };
12889
13319
  }
12890
13320
  function buildKnowledgeLogListBody(options) {
@@ -12976,10 +13406,27 @@ async function resolveKnowledgeMirrorTarget() {
12976
13406
  async function markKnowledgeMirrorStaleAfterWrite() {
12977
13407
  try {
12978
13408
  const credentials = await loadCredentials().catch(() => null);
12979
- const target = credentials ? knowledgeMirrorTargetFromCredentials(credentials) : null;
12980
- if (!target || !mirrorExists(target.dir))
13409
+ if (!credentials)
12981
13410
  return;
12982
- markMirrorStale(target.dir);
13411
+ const cached = credentials.activeOrganization ?? credentials.identity?.organization ?? null;
13412
+ const ref = process.env.OXYGEN_ORG?.trim() || cached?.id || cached?.slug;
13413
+ if (!ref)
13414
+ return;
13415
+ // Resolve the ref to an org id offline — the hook stays network-free so a page
13416
+ // write is never slowed by a whoami round-trip. A non-cached slug override
13417
+ // (`--org <slug>` for an org we haven't cached) can't be mapped without a
13418
+ // network call, so that mirror isn't stale-marked here; the 15-min --if-stale
13419
+ // TTL and `knowledge sync --all` are the safety net.
13420
+ let orgId = null;
13421
+ if (cached && (ref === cached.id || ref === cached.slug))
13422
+ orgId = cached.id;
13423
+ else if (isUuid(ref))
13424
+ orgId = ref;
13425
+ if (!orgId)
13426
+ return;
13427
+ const target = knowledgeMirrorTargetFor(credentials.apiUrl, orgId);
13428
+ if (mirrorExists(target.dir))
13429
+ markMirrorStale(target.dir);
12983
13430
  }
12984
13431
  catch {
12985
13432
  // A broken local mirror must never fail the page write itself.
@@ -13035,8 +13482,24 @@ function applyKnowledgeSyncPage(dir, state, page, counters) {
13035
13482
  };
13036
13483
  counters.synced += 1;
13037
13484
  }
13038
- async function runKnowledgeMirrorSync(options) {
13039
- const target = await resolveKnowledgeMirrorTarget();
13485
+ // `orgOverride` targets a specific org (used by `sync --all`): it names the mirror
13486
+ // directory explicitly and pins X-Oxygen-Organization on every request in the
13487
+ // loop, so each workspace clones into its own mirror regardless of the cached
13488
+ // active org. Absent (the default single-org path), behavior is unchanged.
13489
+ async function runKnowledgeMirrorSync(// skipcq: JS-R1005 -- one linear sync pipeline: staleness gate, lock, delta loop, generated files.
13490
+ options, orgOverride) {
13491
+ const requestOrg = orgOverride ? { selectedOrganization: orgOverride.selector } : {};
13492
+ let target;
13493
+ if (orgOverride) {
13494
+ const credentials = await loadCredentials();
13495
+ if (!credentials) {
13496
+ throw new OxygenError("not_logged_in", "Run `oxygen login` before using CLI commands.", { exitCode: 1 });
13497
+ }
13498
+ target = knowledgeMirrorTargetFor(credentials.apiUrl, orgOverride.orgId);
13499
+ }
13500
+ else {
13501
+ target = await resolveKnowledgeMirrorTarget();
13502
+ }
13040
13503
  const ttlMinutes = readPositiveInt(options.ttl) ?? KNOWLEDGE_SYNC_DEFAULT_TTL_MINUTES;
13041
13504
  if (options.ifStale && !options.full) {
13042
13505
  const state = readMirrorState(target.dir);
@@ -13073,6 +13536,7 @@ async function runKnowledgeMirrorSync(options) {
13073
13536
  const delta = await requestOxygen("/api/cli/knowledge/sync/delta", {
13074
13537
  method: "POST",
13075
13538
  body: cursor ? { cursor } : {},
13539
+ ...requestOrg,
13076
13540
  });
13077
13541
  if (delta.web_url)
13078
13542
  webUrl = delta.web_url;
@@ -13109,10 +13573,11 @@ async function runKnowledgeMirrorSync(options) {
13109
13573
  }
13110
13574
  // Generated summaries come from the server's own projections, not a client-side
13111
13575
  // recomputation. A real wiki page may own the `index`/`log` slug — page bytes win.
13112
- const index = await requestOxygen("/api/cli/knowledge/index");
13576
+ const index = await requestOxygen("/api/cli/knowledge/index", requestOrg);
13113
13577
  const logPage = await requestOxygen("/api/cli/knowledge/log", {
13114
13578
  method: "POST",
13115
13579
  body: {},
13580
+ ...requestOrg,
13116
13581
  });
13117
13582
  if (!state.pages.index)
13118
13583
  writeGeneratedIndexFile(target.dir, index.index);
@@ -13136,6 +13601,50 @@ async function runKnowledgeMirrorSync(options) {
13136
13601
  releaseMirrorLock(target.dir);
13137
13602
  }
13138
13603
  }
13604
+ // `knowledge sync --all` — mirror every workspace the credential can reach, each
13605
+ // into its own org-id-keyed mirror directory. Loops the same `/api/cli/orgs` list
13606
+ // the `orgs` command reads and syncs each org via `runKnowledgeMirrorSync` with an
13607
+ // explicit org override. One org's failure (including a revoked membership, which
13608
+ // purges just that org's mirror) is recorded and never aborts the rest. Pair with
13609
+ // `--if-stale` so a large fleet loop stays cheap (each org honors its own TTL).
13610
+ async function runKnowledgeMirrorSyncAll(options) {
13611
+ const credentials = await loadCredentials();
13612
+ if (!credentials) {
13613
+ throw new OxygenError("not_logged_in", "Run `oxygen login` before using CLI commands.", { exitCode: 1 });
13614
+ }
13615
+ const orgs = await requestOxygen("/api/cli/orgs", { credentials });
13616
+ const results = [];
13617
+ for (const org of orgs.organizations) {
13618
+ const selector = org.slug ?? org.id;
13619
+ try {
13620
+ const summary = await runKnowledgeMirrorSync({ ...options, all: false }, { orgId: org.id, selector });
13621
+ results.push({
13622
+ org_id: org.id,
13623
+ slug: org.slug ?? null,
13624
+ status: summary.skipped ? "skipped" : "synced",
13625
+ ...summary,
13626
+ });
13627
+ }
13628
+ catch (error) {
13629
+ results.push({
13630
+ org_id: org.id,
13631
+ slug: org.slug ?? null,
13632
+ status: "failed",
13633
+ error: error instanceof OxygenError
13634
+ ? { code: error.code, message: error.message }
13635
+ : { code: "unknown", message: error instanceof Error ? error.message : "Unknown error." },
13636
+ });
13637
+ }
13638
+ }
13639
+ return {
13640
+ orgs: results,
13641
+ count: results.length,
13642
+ synced: results.filter((result) => result.status === "synced").length,
13643
+ skipped: results.filter((result) => result.status === "skipped").length,
13644
+ failed: results.filter((result) => result.status === "failed").length,
13645
+ web_url: `${credentials.apiUrl.replace(/\/$/, "")}/knowledge`,
13646
+ };
13647
+ }
13139
13648
  // One page's push: parse the local file, guard identity, upsert with the manifest
13140
13649
  // revision as the optimistic-concurrency base, and translate the two expected
13141
13650
  // gate outcomes (approval proposal, revision conflict) into statuses.
@@ -13299,6 +13808,45 @@ async function runKnowledgeMirrorStatus(options) {
13299
13808
  web_url: target.webUrl,
13300
13809
  };
13301
13810
  }
13811
+ // `knowledge status --all` — freshness report across every locally mirrored
13812
+ // workspace for the active API host. Pure filesystem read (no network): enumerate
13813
+ // the org-id mirror dirs, read each `MirrorState`, and flag any whose last sync is
13814
+ // older than the default TTL. Slugs are cross-referenced from the cached active
13815
+ // org only (offline); other orgs report `org_id` alone.
13816
+ async function runKnowledgeMirrorStatusAll() {
13817
+ const credentials = await loadCredentials();
13818
+ if (!credentials) {
13819
+ throw new OxygenError("not_logged_in", "Run `oxygen login` before using CLI commands.", { exitCode: 1 });
13820
+ }
13821
+ const apiHost = new URL(credentials.apiUrl).host;
13822
+ const orgIds = listLocalMirrors({ configDir: resolveDefaultConfigDir(), apiHost });
13823
+ const cached = credentials.activeOrganization ?? credentials.identity?.organization ?? null;
13824
+ const ttlMs = KNOWLEDGE_SYNC_DEFAULT_TTL_MINUTES * 60_000;
13825
+ const mirrors = orgIds.map((orgId) => {
13826
+ const target = knowledgeMirrorTargetFor(credentials.apiUrl, orgId);
13827
+ const state = readMirrorState(target.dir);
13828
+ const conflicts = listConflictFiles(target.dir);
13829
+ const lastSyncMs = state?.last_sync_at ? Date.parse(state.last_sync_at) : Number.NaN;
13830
+ const stale = !Number.isFinite(lastSyncMs) || Date.now() - lastSyncMs >= ttlMs;
13831
+ return {
13832
+ org_id: orgId,
13833
+ ...(cached && cached.id === orgId ? { slug: cached.slug ?? null } : {}),
13834
+ mirror_path: target.dir,
13835
+ exists: mirrorExists(target.dir),
13836
+ pages_total: state ? Object.keys(state.pages).length : 0,
13837
+ last_sync_at: state?.last_sync_at ?? null,
13838
+ stale,
13839
+ conflict_count: conflicts.length,
13840
+ web_url: target.webUrl,
13841
+ };
13842
+ });
13843
+ return {
13844
+ api_host: apiHost,
13845
+ count: mirrors.length,
13846
+ mirrors,
13847
+ web_url: `${credentials.apiUrl.replace(/\/$/, "")}/knowledge`,
13848
+ };
13849
+ }
13302
13850
  async function runKnowledgeMirrorPurge() {
13303
13851
  const target = await resolveKnowledgeMirrorTarget();
13304
13852
  const existed = existsSync(target.dir);
@@ -13313,9 +13861,7 @@ async function handleKnowledgeMirrorPrintPathAction() {
13313
13861
  process.stdout.write(`${target.dir}\n`);
13314
13862
  }
13315
13863
  catch (error) {
13316
- const failure = toFailure("knowledge status", error);
13317
- writeJson(failure);
13318
- process.exitCode = error instanceof OxygenError ? exitCodeForOxygenError(error) : 1;
13864
+ emitCliFailure("knowledge status", error);
13319
13865
  }
13320
13866
  }
13321
13867
  // Knowledge page routes accept either a UUID (`id`) or a slug (`slug`); split
@@ -13330,7 +13876,7 @@ function buildEnrichColumnBody(// skipcq: JS-R1005 -- CLI body builder maps enri
13330
13876
  table, options) {
13331
13877
  const limit = readPositiveInt(options.limit);
13332
13878
  const filterSelection = readFilterSelectionOption(options.filterJson);
13333
- const explicitSelection = readSelectionJsonOption(options.selectionJson);
13879
+ const explicitSelection = readJsonObjectOption(options.selectionJson);
13334
13880
  const selectedModes = [
13335
13881
  Boolean(options.all),
13336
13882
  limit !== undefined,
@@ -13444,19 +13990,6 @@ options) {
13444
13990
  ...(hasRandomize ? { randomize_daily_caps: options.randomizeCaps } : {}),
13445
13991
  };
13446
13992
  }
13447
- function readPositiveInt(value) {
13448
- const trimmed = value?.trim();
13449
- if (!trimmed)
13450
- return undefined;
13451
- const parsed = Number(trimmed);
13452
- if (!Number.isInteger(parsed) || parsed < 1) {
13453
- throw new OxygenError("invalid_number", "Expected a positive integer.", {
13454
- details: { value },
13455
- exitCode: 1,
13456
- });
13457
- }
13458
- return parsed;
13459
- }
13460
13993
  function readPositiveNumber(value) {
13461
13994
  const trimmed = value?.trim();
13462
13995
  if (!trimmed)
@@ -13527,6 +14060,19 @@ function readSequenceSettings(options) {
13527
14060
  const espMatching = readOption(options.espMatching);
13528
14061
  if (espMatching)
13529
14062
  settings.esp_matching = espMatching;
14063
+ const senderFailover = readOption(options.senderFailover);
14064
+ if (senderFailover)
14065
+ settings.sender_failover = senderFailover;
14066
+ // email_min_gap_minutes accepts 0 ("no extra humanization gap"), which
14067
+ // readPositiveInt rejects (it requires >= 1); treat a literal 0 as valid and
14068
+ // defer any other value to readPositiveInt's positive-integer validation. The
14069
+ // server enforces the 0-720 range (validateSequenceSettings).
14070
+ const emailMinGapRaw = readOption(options.emailMinGapMinutes);
14071
+ if (emailMinGapRaw !== null) {
14072
+ const emailMinGap = Number(emailMinGapRaw) === 0 ? 0 : readPositiveInt(emailMinGapRaw);
14073
+ if (emailMinGap !== undefined)
14074
+ settings.email_min_gap_minutes = emailMinGap;
14075
+ }
13530
14076
  return Object.keys(settings).length > 0 ? settings : undefined;
13531
14077
  }
13532
14078
  function muteTokenEcho() {