@oxygen-agent/cli 1.164.30 → 1.177.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/README.md +1 -1
  2. package/dist/index.js +1195 -507
  3. package/dist/run-wait.d.ts +23 -0
  4. package/dist/run-wait.js +57 -0
  5. package/node_modules/@oxygen/shared/dist/cell-format.d.ts +2 -14
  6. package/node_modules/@oxygen/shared/dist/cell-format.js +3 -10
  7. package/node_modules/@oxygen/shared/dist/cli-envelope.d.ts +1 -1
  8. package/node_modules/@oxygen/shared/dist/cli-result.d.ts +39 -0
  9. package/node_modules/@oxygen/shared/dist/cli-result.js +52 -0
  10. package/node_modules/@oxygen/shared/dist/credit-guidance.d.ts +0 -1
  11. package/node_modules/@oxygen/shared/dist/credit-guidance.js +1 -1
  12. package/node_modules/@oxygen/shared/dist/file-import.js +1 -1
  13. package/node_modules/@oxygen/shared/dist/index.d.ts +2 -39
  14. package/node_modules/@oxygen/shared/dist/index.js +2 -44
  15. package/node_modules/@oxygen/shared/dist/log.d.ts +0 -1
  16. package/node_modules/@oxygen/shared/dist/log.js +8 -3
  17. package/node_modules/@oxygen/shared/dist/object-storage.d.ts +0 -3
  18. package/node_modules/@oxygen/shared/dist/object-storage.js +1 -24
  19. package/node_modules/@oxygen/shared/dist/search-vocab.d.ts +18 -0
  20. package/node_modules/@oxygen/shared/dist/search-vocab.js +151 -0
  21. package/node_modules/@oxygen/shared/dist/select-options.d.ts +18 -0
  22. package/node_modules/@oxygen/shared/dist/select-options.js +121 -0
  23. package/node_modules/@oxygen/shared/dist/sequences.js +1 -1
  24. package/node_modules/@oxygen/shared/dist/sql-error.d.ts +0 -6
  25. package/node_modules/@oxygen/shared/dist/sql-error.js +67 -58
  26. package/node_modules/@oxygen/shared/dist/telemetry.d.ts +0 -1
  27. package/node_modules/@oxygen/shared/dist/telemetry.js +23 -18
  28. package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
  29. package/node_modules/@oxygen/shared/dist/version.js +1 -1
  30. package/node_modules/@oxygen/shared/dist/worker-failures-queue.d.ts +22 -0
  31. package/node_modules/@oxygen/shared/dist/worker-failures-queue.js +56 -0
  32. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -16,12 +16,49 @@ 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 { waitForCliRun } from "./run-wait.js";
19
20
  import { runLocalCustomHttpColumn } from "./local-custom-http-column.js";
20
21
  import { captureCurrentTranscript, collectFeedbackEnvironment, TranscriptCaptureError, } from "./transcript.js";
21
22
  import { addSessionOutput, addSessionStatus, getSessionUsage, startSession, updateSessionStep, } from "./session.js";
22
23
  import { doctorAgentSkills, installAgentSkills, listAgentSkills, runAutomaticSkillsInstall, } from "./skills.js";
23
24
  import { resolveCliBinaryName } from "./runtime.js";
24
25
  import { updateCli } from "./update.js";
26
+ function buildFindBody(capability, options) {
27
+ const body = { capability };
28
+ const set = (key, value) => {
29
+ if (value)
30
+ body[key] = value;
31
+ };
32
+ if (capability === "company") {
33
+ set("domain", options.domain);
34
+ set("name", options.name);
35
+ set("linkedin_url", options.linkedinUrl);
36
+ if (options.fields) {
37
+ const fields = options.fields.split(",").map((field) => field.trim()).filter(Boolean);
38
+ if (fields.length > 0)
39
+ body.fields = fields;
40
+ }
41
+ }
42
+ else {
43
+ set("linkedin_url", options.linkedinUrl);
44
+ set("full_name", options.fullName);
45
+ set("first_name", options.firstName);
46
+ set("last_name", options.lastName);
47
+ set("email", options.email);
48
+ set("company_domain", options.companyDomain);
49
+ set("company_name", options.companyName);
50
+ set("company_linkedin_url", options.companyLinkedinUrl);
51
+ }
52
+ if (options.mode)
53
+ body.mode = options.mode;
54
+ const maxCredits = readPositiveNumber(options.maxCredits);
55
+ if (maxCredits)
56
+ body.max_credits = maxCredits;
57
+ // Phone-only opt-in; the route ignores it for other capabilities.
58
+ if (options.verify)
59
+ body.verify = true;
60
+ return body;
61
+ }
25
62
  const BROWSER_LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
26
63
  const OXYGEN_SPINNER_INTERVAL_MS = 90;
27
64
  const INITIAL_OXYGEN_PROFILE_ENV = process.env.OXYGEN_PROFILE?.trim() || null;
@@ -54,6 +91,14 @@ const TABLE_INGESTION_WAIT_DEFAULT_TIMEOUT_SECONDS = 600;
54
91
  const TABLE_INGESTION_WAIT_DEFAULT_INTERVAL_SECONDS = 5;
55
92
  const WORKFLOW_TAIL_DEFAULT_TIMEOUT_SECONDS = 600;
56
93
  const WORKFLOW_TAIL_DEFAULT_INTERVAL_SECONDS = 2;
94
+ // Cloudflare domain sync returns partial pages under the request budget; the
95
+ // CLI auto-continues up to this many follow-up POSTs before handing back a
96
+ // still-partial summary.
97
+ const DOMAINS_SYNC_MAX_CONTINUATIONS = 20;
98
+ // Cloudflare's domain-check endpoint accepts at most 20 domains per call.
99
+ const DOMAINS_CHECK_MAX_DOMAINS = 20;
100
+ const DOMAIN_REGISTRATION_WAIT_TIMEOUT_SECONDS = 600;
101
+ const DOMAIN_REGISTRATION_WAIT_INTERVAL_SECONDS = 10;
57
102
  const CLI_MODULE_DIR = dirname(fileURLToPath(import.meta.url));
58
103
  const RECIPE_ESBUILD_NODE_PATHS = [
59
104
  resolve("node_modules"),
@@ -162,6 +207,33 @@ function parseJsonArray(value) {
162
207
  }
163
208
  return parsed;
164
209
  }
210
+ /**
211
+ * Parse a JSON command-input value, mapping `JSON.parse` syntax failures to a
212
+ * typed `invalid_json` envelope. A raw `JSON.parse` throw is an ordinary `Error`,
213
+ * which `@oxygen/shared`'s `toFailure` flattens to a machine-hostile
214
+ * `unexpected_error`; routing through here keeps malformed user JSON actionable.
215
+ * Shape validation, where a caller needs it, stays with the caller. `inputName`
216
+ * is the user-facing flag or source label, e.g. "--steps-file".
217
+ */
218
+ function parseJsonValue(value, inputName) {
219
+ try {
220
+ return JSON.parse(value);
221
+ }
222
+ catch (error) {
223
+ throw new OxygenError("invalid_json", `${inputName} must be valid JSON.`, {
224
+ details: { reason: error instanceof Error ? error.message : "unknown" },
225
+ exitCode: 1,
226
+ });
227
+ }
228
+ }
229
+ /**
230
+ * Read a local file and parse it as JSON command input, surfacing syntax
231
+ * failures as a typed `invalid_json` envelope tagged with `inputName`. File-read
232
+ * errors (missing/unreadable path) propagate unchanged — they are not JSON.
233
+ */
234
+ function readJsonFileValue(path, inputName) {
235
+ return parseJsonValue(readFileSync(path, "utf8"), inputName);
236
+ }
165
237
  async function readDeleteRowIdsOption(options) {
166
238
  const rowIdsJson = readOption(options.rowIdsJson);
167
239
  const rowIdsFile = readOption(options.rowIdsFile);
@@ -284,6 +356,54 @@ function resolveComposioRunMode(options) {
284
356
  }
285
357
  return "dry_run";
286
358
  }
359
+ const DEFAULT_CRM_SETUP_OBJECTS = ["companies", "people"];
360
+ function buildCrmSetupBody(options) {
361
+ return {
362
+ objects: readCrmSetupObjects(options.objects),
363
+ mode: resolveCrmSetupMode(options),
364
+ ...(readOption(options.project) ? { project: readOption(options.project) } : {}),
365
+ };
366
+ }
367
+ function readCrmSetupObjects(value) {
368
+ const objects = readCsvOption(value);
369
+ return objects.length > 0 ? [...new Set(objects)] : DEFAULT_CRM_SETUP_OBJECTS;
370
+ }
371
+ function resolveCrmSetupMode(options) {
372
+ if (options.live === true && options.dryRun === true) {
373
+ throw new OxygenError("conflicting_flags", "Pass either --live or --dry-run, not both.", { exitCode: 1 });
374
+ }
375
+ return options.live === true ? "live" : "dry_run";
376
+ }
377
+ function buildCrmAssertBody(object, options) {
378
+ return {
379
+ object,
380
+ identity: parseCrmIdentityOption(options.identity),
381
+ values: options.valuesJson ? parseJsonObject(options.valuesJson) : {},
382
+ mode: resolveCrmSetupMode(options),
383
+ };
384
+ }
385
+ function buildCrmRelationshipUpsertBody(object, rowId, options) {
386
+ return {
387
+ object,
388
+ row_id: rowId,
389
+ relationship: options.relationship,
390
+ target: {
391
+ ...(readOption(options.targetObject) ? { object: readOption(options.targetObject) } : {}),
392
+ row_id: options.targetRowId,
393
+ },
394
+ mode: resolveCrmSetupMode(options),
395
+ };
396
+ }
397
+ function parseCrmIdentityOption(value) {
398
+ const separator = value.indexOf("=");
399
+ if (separator <= 0 || separator === value.length - 1) {
400
+ throw new OxygenError("invalid_identity", "--identity must be formatted as key=value, for example domain=acme.com or email=ceo@acme.com.", { exitCode: 1 });
401
+ }
402
+ return {
403
+ key: value.slice(0, separator).trim(),
404
+ value: value.slice(separator + 1).trim(),
405
+ };
406
+ }
287
407
  function readSpecFileBody(path) {
288
408
  const text = readFileSync(resolve(path), "utf8");
289
409
  try {
@@ -296,6 +416,88 @@ function readSpecFileBody(path) {
296
416
  throw error;
297
417
  }
298
418
  }
419
+ // Builds the `prompts` command tree (list/get/upsert/archive). The
420
+ // deprecated `templates` alias is the identical tree — same subcommands,
421
+ // options, request bodies, and routes — differing only in the parent command
422
+ // name, its description, and the `handleAsyncAction` operation labels, so both
423
+ // surfaces are generated here to stop them drifting (the alias previously
424
+ // duplicated every subcommand). All subcommands hit the shared
425
+ // /api/cli/templates/* routes.
426
+ function buildPromptTemplatesCommand(surface, description) {
427
+ return new Command(surface)
428
+ .description(description)
429
+ .addCommand(new Command("list")
430
+ .description("List prompt templates in the workspace.")
431
+ .option("--kind <kind>", "Filter by ai_column_system, scoring_rubric, or other.")
432
+ .option("--include-archived", "Include archived templates.")
433
+ .option("--json", "Print a JSON envelope.")
434
+ .action(async (options) => {
435
+ await handleAsyncAction(`${surface} list`, options, () => {
436
+ const params = new URLSearchParams();
437
+ const kind = readOption(options.kind);
438
+ if (kind)
439
+ params.set("kind", kind);
440
+ if (options.includeArchived)
441
+ params.set("include_archived", "true");
442
+ const qs = params.toString() ? `?${params.toString()}` : "";
443
+ return requestOxygen(`/api/cli/templates${qs}`);
444
+ });
445
+ }))
446
+ .addCommand(new Command("get")
447
+ .description("Read one prompt template by id or slug.")
448
+ .argument("<id_or_slug>", "Template UUID or slug.")
449
+ .option("--json", "Print a JSON envelope.")
450
+ .action(async (idOrSlug, options) => {
451
+ await handleAsyncAction(`${surface} get`, options, () => requestOxygen("/api/cli/templates/get", {
452
+ method: "POST",
453
+ body: idOrSlug.includes("-") && idOrSlug.length >= 32
454
+ ? { id: idOrSlug }
455
+ : { slug: idOrSlug },
456
+ }));
457
+ }))
458
+ .addCommand(new Command("upsert")
459
+ .description("Create or update a prompt template.")
460
+ .option("--id <id>", "Existing template UUID to update. Omit to create.")
461
+ .option("--slug <slug>", "Stable slug (kebab-case).")
462
+ .option("--name <name>", "Human-readable name.")
463
+ .option("--description <text>", "Short description.")
464
+ .option("--kind <kind>", "Template kind: ai_column_system, scoring_rubric, or other.")
465
+ .option("--body <text>", "Prompt body.")
466
+ .option("--body-file <path>", "Path to a file containing the prompt body.")
467
+ .option("--json", "Print a JSON envelope.")
468
+ .action(async (options) => {
469
+ await handleAsyncAction(`${surface} upsert`, options, async () => {
470
+ const body = {};
471
+ if (readOption(options.id))
472
+ body.id = readOption(options.id);
473
+ if (readOption(options.slug))
474
+ body.slug = readOption(options.slug);
475
+ if (readOption(options.name))
476
+ body.name = readOption(options.name);
477
+ if (readOption(options.description) !== undefined)
478
+ body.description = readOption(options.description);
479
+ if (readOption(options.kind))
480
+ body.kind = readOption(options.kind);
481
+ if (readOption(options.body))
482
+ body.body = readOption(options.body);
483
+ else {
484
+ const path = readOption(options.bodyFile);
485
+ if (path) {
486
+ const fs = await import("node:fs/promises");
487
+ body.body = await fs.readFile(path, "utf8");
488
+ }
489
+ }
490
+ return requestOxygen("/api/cli/templates/upsert", { method: "POST", body });
491
+ });
492
+ }))
493
+ .addCommand(new Command("archive")
494
+ .description("Archive a prompt template. Seeded templates cannot be archived.")
495
+ .argument("<id>", "Template UUID.")
496
+ .option("--json", "Print a JSON envelope.")
497
+ .action(async (id, options) => {
498
+ await handleAsyncAction(`${surface} archive`, options, () => requestOxygen("/api/cli/templates/archive", { method: "POST", body: { id } }));
499
+ }));
500
+ }
299
501
  export function createProgram() {
300
502
  const program = new Command();
301
503
  const binaryName = resolveCliBinaryName();
@@ -396,8 +598,8 @@ export function createProgram() {
396
598
  .option("--package <npm_spec>", "Override the npm package spec.")
397
599
  .option("--dry-run", "Print the update command without running it.")
398
600
  .option("--json", "Print a JSON envelope.")
399
- .action(async (options) => {
400
- await handleUpdateAction(options);
601
+ .action((options) => {
602
+ handleUpdateAction(options);
401
603
  });
402
604
  program
403
605
  .command("api-keys")
@@ -406,7 +608,7 @@ export function createProgram() {
406
608
  .description("List active CLI API keys for the current user and organization.")
407
609
  .option("--json", "Print a JSON envelope.")
408
610
  .action(async (options) => {
409
- await handleAsyncAction("api-keys list", options, async () => requestOxygen("/api/cli/api-keys"));
611
+ await handleAsyncAction("api-keys list", options, () => requestOxygen("/api/cli/api-keys"));
410
612
  }))
411
613
  .addCommand(new Command("create")
412
614
  .description("Create a CLI API key. The token is shown once.")
@@ -415,7 +617,7 @@ export function createProgram() {
415
617
  .option("--expires-in-days <days>", "Expire the key after this many days.")
416
618
  .option("--json", "Print a JSON envelope.")
417
619
  .action(async (options) => {
418
- await handleAsyncAction("api-keys create", options, async () => requestOxygen("/api/cli/api-keys", {
620
+ await handleAsyncAction("api-keys create", options, () => requestOxygen("/api/cli/api-keys", {
419
621
  method: "POST",
420
622
  body: buildApiKeyCreateBody(options),
421
623
  }));
@@ -425,7 +627,7 @@ export function createProgram() {
425
627
  .argument("<key-id>", "CLI API key id.")
426
628
  .option("--json", "Print a JSON envelope.")
427
629
  .action(async (keyId, options) => {
428
- await handleAsyncAction("api-keys revoke", options, async () => requestOxygen(`/api/cli/api-keys/${encodeURIComponent(keyId)}`, {
630
+ await handleAsyncAction("api-keys revoke", options, () => requestOxygen(`/api/cli/api-keys/${encodeURIComponent(keyId)}`, {
429
631
  method: "DELETE",
430
632
  }));
431
633
  }));
@@ -489,7 +691,7 @@ export function createProgram() {
489
691
  .description("List organizations available to the current CLI identity.")
490
692
  .option("--json", "Print a JSON envelope.")
491
693
  .action(async (options) => {
492
- await handleAsyncAction("orgs list", options, async () => requestOxygen("/api/cli/orgs"));
694
+ await handleAsyncAction("orgs list", options, () => requestOxygen("/api/cli/orgs"));
493
695
  }))
494
696
  .addCommand(new Command("use")
495
697
  .description("Select the active organization for this CLI profile.")
@@ -642,20 +844,20 @@ export function createProgram() {
642
844
  .description("Show the current organization's tenant database status. Staff can pass global --org to inspect another org.")
643
845
  .option("--json", "Print a JSON envelope.")
644
846
  .action(async (options) => {
645
- await handleAsyncAction("db status", options, async () => requestOxygen("/api/cli/db/status"));
847
+ await handleAsyncAction("db status", options, () => requestOxygen("/api/cli/db/status"));
646
848
  }))
647
849
  .addCommand(new Command("provision")
648
850
  .description("Provision a managed Neon tenant database for the current organization. Staff can pass global --org to repair another org.")
649
851
  .option("--json", "Print a JSON envelope.")
650
852
  .action(async (options) => {
651
- await handleAsyncAction("db provision", options, async () => requestOxygen("/api/cli/db/provision", { method: "POST", body: {} }));
853
+ await handleAsyncAction("db provision", options, () => requestOxygen("/api/cli/db/provision", { method: "POST", body: {} }));
652
854
  }))
653
855
  .addCommand(new Command("migrate")
654
856
  .description("Apply pending tenant database migrations for the current organization.")
655
857
  .option("--rotate-credentials", "Rotate the tenant runtime/read DB passwords and rewrite stored credentials. Use only to repair a tenant whose stored credentials are out of sync; routine migrations do not need it.")
656
858
  .option("--json", "Print a JSON envelope.")
657
859
  .action(async (options) => {
658
- await handleAsyncAction("db migrate", options, async () => requestOxygen("/api/cli/db/migrate", {
860
+ await handleAsyncAction("db migrate", options, () => requestOxygen("/api/cli/db/migrate", {
659
861
  method: "POST",
660
862
  body: options.rotateCredentials ? { rotate_credentials: true } : {},
661
863
  }));
@@ -666,7 +868,7 @@ export function createProgram() {
666
868
  .option("--dry-run", "List pending tenants without applying migrations.")
667
869
  .option("--json", "Print a JSON envelope.")
668
870
  .action(async (options) => {
669
- await handleAsyncAction("db migrate-all", options, async () => {
871
+ await handleAsyncAction("db migrate-all", options, () => {
670
872
  const limit = readPositiveInt(options.limit);
671
873
  return requestOxygen("/api/cli/db/migrate-all", {
672
874
  method: "POST",
@@ -742,14 +944,14 @@ export function createProgram() {
742
944
  .description("Show tenant database cost controls and reconciliation status.")
743
945
  .option("--json", "Print a JSON envelope.")
744
946
  .action(async (options) => {
745
- await handleAsyncAction("db cost-policy", options, async () => requestOxygen("/api/cli/db/cost-policy"));
947
+ await handleAsyncAction("db cost-policy", options, () => requestOxygen("/api/cli/db/cost-policy"));
746
948
  }))
747
949
  .addCommand(new Command("reconcile-cost-policy")
748
950
  .description("Apply tenant database cost controls to the managed Neon endpoint.")
749
951
  .option("--suspend-idle", "Request immediate Neon compute suspension when the tenant is idle.")
750
952
  .option("--json", "Print a JSON envelope.")
751
953
  .action(async (options) => {
752
- await handleAsyncAction("db reconcile-cost-policy", options, async () => requestOxygen("/api/cli/db/reconcile-cost-policy", {
954
+ await handleAsyncAction("db reconcile-cost-policy", options, () => requestOxygen("/api/cli/db/reconcile-cost-policy", {
753
955
  method: "POST",
754
956
  body: {
755
957
  ...(options.suspendIdle ? { suspend_idle: true } : {}),
@@ -761,7 +963,7 @@ export function createProgram() {
761
963
  .requiredOption("--database-url <url>", "Owner connection string for the tenant Postgres database.")
762
964
  .option("--json", "Print a JSON envelope.")
763
965
  .action(async (options) => {
764
- await handleAsyncAction("db attach", options, async () => requestOxygen("/api/cli/db/attach", {
966
+ await handleAsyncAction("db attach", options, () => requestOxygen("/api/cli/db/attach", {
765
967
  method: "POST",
766
968
  body: { database_url: options.databaseUrl },
767
969
  }));
@@ -772,7 +974,7 @@ export function createProgram() {
772
974
  .option("--max-rows <n>", "Maximum rows to return. Defaults to 100; hard cap is 1000.")
773
975
  .option("--json", "Print a JSON envelope.")
774
976
  .action(async (options) => {
775
- await handleAsyncAction("db query", options, async () => {
977
+ await handleAsyncAction("db query", options, () => {
776
978
  const maxRows = readPositiveInt(options.maxRows);
777
979
  return requestOxygen("/api/cli/db/query", {
778
980
  method: "POST",
@@ -790,18 +992,87 @@ export function createProgram() {
790
992
  .description("List table projects in the current tenant database.")
791
993
  .option("--json", "Print a JSON envelope.")
792
994
  .action(async (options) => {
793
- await handleAsyncAction("projects list", options, async () => requestOxygen("/api/cli/projects"));
995
+ await handleAsyncAction("projects list", options, () => requestOxygen("/api/cli/projects"));
794
996
  }))
795
997
  .addCommand(new Command("create")
796
998
  .description("Create a schema-backed table project.")
797
999
  .argument("<name>", "Display name for the project.")
798
1000
  .option("--json", "Print a JSON envelope.")
799
1001
  .action(async (name, options) => {
800
- await handleAsyncAction("projects create", options, async () => requestOxygen("/api/cli/projects", {
1002
+ await handleAsyncAction("projects create", options, () => requestOxygen("/api/cli/projects", {
801
1003
  method: "POST",
802
1004
  body: { name },
803
1005
  }));
804
1006
  }));
1007
+ program
1008
+ .command("crm")
1009
+ .description("Agent-native CRM object setup and metadata commands.")
1010
+ .addCommand(new Command("setup")
1011
+ .description("Create or repair standard CRM object-backed tables. Defaults to dry-run.")
1012
+ .option("--objects <objects>", "Comma-separated standard CRM objects to set up. Defaults to companies,people.")
1013
+ .option("--project <project>", "Project id or slug for created CRM tables.")
1014
+ .option("--dry-run", "Preview CRM setup without creating or repairing tables.")
1015
+ .option("--live", "Apply CRM setup changes. Default is dry-run.")
1016
+ .option("--json", "Print a JSON envelope.")
1017
+ .action(async (options) => {
1018
+ await handleAsyncAction("crm setup", options, () => requestOxygen("/api/cli/crm/setup", {
1019
+ method: "POST",
1020
+ body: buildCrmSetupBody(options),
1021
+ }));
1022
+ }))
1023
+ .addCommand(new Command("objects")
1024
+ .description("List configured CRM objects in the current tenant database.")
1025
+ .option("--json", "Print a JSON envelope.")
1026
+ .action(async (options) => {
1027
+ await handleAsyncAction("crm objects", options, () => requestOxygen("/api/cli/crm/objects"));
1028
+ }))
1029
+ .addCommand(new Command("assert")
1030
+ .description("Create or update one CRM record by object identity. Defaults to dry-run.")
1031
+ .argument("<object>", "CRM object slug, such as companies or people.")
1032
+ .requiredOption("--identity <key=value>", "Identity key/value, for example domain=acme.com or email=ceo@acme.com.")
1033
+ .option("--values-json <json>", "JSON object of CRM attribute values keyed by column key.")
1034
+ .option("--dry-run", "Preview the assert without writing a row.")
1035
+ .option("--live", "Apply the assert. Default is dry-run.")
1036
+ .option("--json", "Print a JSON envelope.")
1037
+ .action(async (object, options) => {
1038
+ await handleAsyncAction("crm assert", options, () => requestOxygen("/api/cli/crm/assert", {
1039
+ method: "POST",
1040
+ body: buildCrmAssertBody(object, options),
1041
+ }));
1042
+ }))
1043
+ .addCommand(new Command("get")
1044
+ .description("Get one CRM record aggregate with row values, identities, and relationships.")
1045
+ .argument("<object>", "CRM object slug, such as companies or people.")
1046
+ .argument("<row_id>", "CRM record row id.")
1047
+ .option("--json", "Print a JSON envelope.")
1048
+ .action(async (object, rowId, options) => {
1049
+ await handleAsyncAction("crm get", options, () => requestOxygen(`/api/cli/crm/objects/${encodeURIComponent(object)}/records/${encodeURIComponent(rowId)}`));
1050
+ }))
1051
+ .addCommand(new Command("relationships")
1052
+ .description("Manage CRM record relationships.")
1053
+ .addCommand(new Command("upsert")
1054
+ .description("Create or replace a CRM relationship edge. Defaults to dry-run.")
1055
+ .argument("<object>", "Source CRM object slug, such as companies or people.")
1056
+ .argument("<row_id>", "Source CRM record row id.")
1057
+ .requiredOption("--relationship <slug>", "Relationship slug from the source object, such as team or company.")
1058
+ .option("--target-object <object>", "Target CRM object slug. Optional when the relationship has one target.")
1059
+ .requiredOption("--target-row-id <row_id>", "Target CRM record row id.")
1060
+ .option("--dry-run", "Preview the relationship write without changing edges.")
1061
+ .option("--live", "Apply the relationship write. Default is dry-run.")
1062
+ .option("--json", "Print a JSON envelope.")
1063
+ .action(async (object, rowId, options) => {
1064
+ await handleAsyncAction("crm relationships upsert", options, () => requestOxygen("/api/cli/crm/relationships/upsert", {
1065
+ method: "POST",
1066
+ body: buildCrmRelationshipUpsertBody(object, rowId, options),
1067
+ }));
1068
+ })))
1069
+ .addCommand(new Command("describe")
1070
+ .description("Describe one configured CRM object.")
1071
+ .argument("<object>", "CRM object slug, such as companies or people.")
1072
+ .option("--json", "Print a JSON envelope.")
1073
+ .action(async (object, options) => {
1074
+ await handleAsyncAction("crm describe", options, () => requestOxygen(`/api/cli/crm/objects/${encodeURIComponent(object)}`));
1075
+ }));
805
1076
  const tablesCommand = program
806
1077
  .command("tables")
807
1078
  .description("Tenant workspace table commands.")
@@ -812,7 +1083,7 @@ export function createProgram() {
812
1083
  .option("--project <project>", "Project id or slug. Defaults to General.")
813
1084
  .option("--json", "Print a JSON envelope.")
814
1085
  .action(async (name, options) => {
815
- await handleAsyncAction("tables create", options, async () => requestOxygen("/api/cli/tables", {
1086
+ await handleAsyncAction("tables create", options, () => requestOxygen("/api/cli/tables", {
816
1087
  method: "POST",
817
1088
  body: {
818
1089
  name,
@@ -828,7 +1099,7 @@ export function createProgram() {
828
1099
  .option("--schema-only", "Duplicate columns and definitions without copying row values.")
829
1100
  .option("--json", "Print a JSON envelope.")
830
1101
  .action(async (table, options) => {
831
- await handleAsyncAction("tables duplicate", options, async () => requestOxygen("/api/cli/tables/duplicate", {
1102
+ await handleAsyncAction("tables duplicate", options, () => requestOxygen("/api/cli/tables/duplicate", {
832
1103
  method: "POST",
833
1104
  body: {
834
1105
  table,
@@ -843,7 +1114,7 @@ export function createProgram() {
843
1114
  .requiredOption("--rows-json <json>", "JSON array of row objects keyed by column key.")
844
1115
  .option("--json", "Print a JSON envelope.")
845
1116
  .action(async (table, options) => {
846
- await handleAsyncAction("tables insert", options, async () => requestOxygen("/api/cli/tables/rows", {
1117
+ await handleAsyncAction("tables insert", options, () => requestOxygen("/api/cli/tables/rows", {
847
1118
  method: "POST",
848
1119
  body: {
849
1120
  table,
@@ -858,7 +1129,7 @@ export function createProgram() {
858
1129
  .requiredOption("--values-json <json>", "JSON object of values keyed by column key.")
859
1130
  .option("--json", "Print a JSON envelope.")
860
1131
  .action(async (table, rowId, options) => {
861
- await handleAsyncAction("tables update", options, async () => requestOxygen("/api/cli/tables/rows/update", {
1132
+ await handleAsyncAction("tables update", options, () => requestOxygen("/api/cli/tables/rows/update", {
862
1133
  method: "POST",
863
1134
  body: {
864
1135
  table,
@@ -873,7 +1144,7 @@ export function createProgram() {
873
1144
  .argument("<row_id>", "Workspace row UUID.")
874
1145
  .option("--json", "Print a JSON envelope.")
875
1146
  .action(async (table, rowId, options) => {
876
- await handleAsyncAction("tables delete-row", options, async () => requestOxygen("/api/cli/tables/rows/delete", {
1147
+ await handleAsyncAction("tables delete-row", options, () => requestOxygen("/api/cli/tables/rows/delete", {
877
1148
  method: "POST",
878
1149
  body: {
879
1150
  table,
@@ -911,7 +1182,7 @@ export function createProgram() {
911
1182
  .option("--dry-run", "Preview inserts, updates, duplicate keys, and field conflicts without writing rows.")
912
1183
  .option("--json", "Print a JSON envelope.")
913
1184
  .action(async (table, options) => {
914
- await handleAsyncAction("tables upsert", options, async () => requestOxygen("/api/cli/tables/rows/upsert", {
1185
+ await handleAsyncAction("tables upsert", options, () => requestOxygen("/api/cli/tables/rows/upsert", {
915
1186
  method: "POST",
916
1187
  body: {
917
1188
  table,
@@ -937,7 +1208,7 @@ export function createProgram() {
937
1208
  .option("--max-concurrency <n>", "Maximum concurrent import chunks for background mode. Defaults to 5.")
938
1209
  .option("--json", "Print a JSON envelope.")
939
1210
  .action(async (table, options) => {
940
- await handleAsyncAction("tables import", options, async () => importRows(table, options));
1211
+ await handleAsyncAction("tables import", options, () => importRows(table, options));
941
1212
  }))
942
1213
  .addCommand(new Command("export")
943
1214
  .description("Export workspace table rows as JSON, JSONL, CSV, or a human-readable table.")
@@ -947,7 +1218,7 @@ export function createProgram() {
947
1218
  .option("--limit <n>", "Maximum rows to export. Defaults to 100; hard cap is 1000.")
948
1219
  .option("--json", "Print a JSON envelope.")
949
1220
  .action(async (table, options) => {
950
- await handleAsyncAction("tables export", options, async () => exportRows(table, options));
1221
+ await handleAsyncAction("tables export", options, () => exportRows(table, options));
951
1222
  }))
952
1223
  .addCommand(new Command("export-bundle")
953
1224
  .description("Export a workspace table as a portable bundle (schema + every row) for cross-environment / cross-org copies.")
@@ -956,7 +1227,7 @@ export function createProgram() {
956
1227
  .option("--page-size <n>", "Rows per cursor-paginated request. Defaults to 500; hard cap is 1000.")
957
1228
  .option("--json", "Print a JSON envelope (omit row payload — use --output to keep the rows).")
958
1229
  .action(async (table, options) => {
959
- await handleAsyncAction("tables export-bundle", options, async () => exportTableBundle(table, options));
1230
+ await handleAsyncAction("tables export-bundle", options, () => exportTableBundle(table, options));
960
1231
  }))
961
1232
  .addCommand(new Command("import-bundle")
962
1233
  .description("Recreate a workspace table from an export-bundle file in this org. Restores columns (incl. enrichment/tool definitions) and inserts every row. Pass --key to make the import idempotent (re-runnable), and --into to resume a failed import into the table it already created.")
@@ -968,14 +1239,14 @@ export function createProgram() {
968
1239
  .option("--batch-size <n>", "Rows per write request. Defaults to 500; paid orgs may use up to 5000.")
969
1240
  .option("--json", "Print a JSON envelope.")
970
1241
  .action(async (options) => {
971
- await handleAsyncAction("tables import-bundle", options, async () => importTableBundle(options));
1242
+ await handleAsyncAction("tables import-bundle", options, () => importTableBundle(options));
972
1243
  }))
973
1244
  .addCommand(new Command("list")
974
1245
  .description("List workspace tables in the current tenant database.")
975
1246
  .option("--project <project>", "Project id or slug to filter by.")
976
1247
  .option("--json", "Print a JSON envelope.")
977
1248
  .action(async (options) => {
978
- await handleAsyncAction("tables list", options, async () => requestOxygen(readOption(options.project)
1249
+ await handleAsyncAction("tables list", options, () => requestOxygen(readOption(options.project)
979
1250
  ? `/api/cli/tables?project=${encodeURIComponent(readOption(options.project))}`
980
1251
  : "/api/cli/tables"));
981
1252
  }))
@@ -985,12 +1256,20 @@ export function createProgram() {
985
1256
  .option("--limit <n>", "Maximum rows to return. Defaults to 100; hard cap is 1000.")
986
1257
  .option("--cursor <cursor>", "Pagination cursor returned by a previous query.")
987
1258
  .option("--fields <columns>", "Comma-separated column keys or ids to include.")
988
- .option("--filter-json <json>", "Filter object or array, e.g. '{\"column\":\"mobile_phone_e164\",\"op\":\"is_null\"}'.")
1259
+ .option("--filter-json <json>", "Legacy filter object or array, e.g. '{\"column\":\"mobile_phone_e164\",\"op\":\"is_null\"}'. Mutually exclusive with --filter-tree-json/--sort-json.")
1260
+ .option("--filter-tree-json <json>", "Airtable-style filter group, e.g. '{\"type\":\"group\",\"conjunction\":\"and\",\"children\":[{\"type\":\"leaf\",\"columnKey\":\"stage\",\"operator\":\"is\",\"value\":\"won\"}]}'. Mutually exclusive with --filter-json.")
1261
+ .option("--sort-json <json>", "Ordered sort rules, e.g. '[{\"columnKey\":\"created_at\",\"direction\":\"desc\"}]'. Earlier rules dominate. Mutually exclusive with --filter-json.")
1262
+ .option("--no-system-fields", "Omit _row_id, _created_at, and _updated_at from returned rows (included by default).")
989
1263
  .option("--json", "Print a JSON envelope.")
990
1264
  .action(async (table, options) => {
991
- await handleAsyncAction("tables query", options, async () => {
1265
+ await handleAsyncAction("tables query", options, () => {
992
1266
  const limit = readPositiveInt(options.limit);
993
1267
  const filters = readFilterJsonOption(options.filterJson);
1268
+ const filterTree = readFilterTreeJsonOption(options.filterTreeJson);
1269
+ const sorts = readSortJsonOption(options.sortJson);
1270
+ if (filters && (filterTree || sorts)) {
1271
+ throw new OxygenError("invalid_filter", "Pass either --filter-json (legacy) or --filter-tree-json/--sort-json, not both.", { exitCode: 1 });
1272
+ }
994
1273
  return requestOxygen("/api/cli/tables/query", {
995
1274
  method: "POST",
996
1275
  body: {
@@ -999,6 +1278,9 @@ export function createProgram() {
999
1278
  ...(readOption(options.cursor) ? { cursor: readOption(options.cursor) } : {}),
1000
1279
  ...(readOption(options.fields) ? { fields: readCsvOption(options.fields) } : {}),
1001
1280
  ...(filters ? { filters } : {}),
1281
+ ...(filterTree ? { filterTree } : {}),
1282
+ ...(sorts ? { sorts } : {}),
1283
+ ...(options.systemFields === false ? { include_system_fields: false } : {}),
1002
1284
  },
1003
1285
  });
1004
1286
  });
@@ -1034,13 +1316,27 @@ export function createProgram() {
1034
1316
  .option("--include-archived", "Include archived columns.")
1035
1317
  .option("--json", "Print a JSON envelope.")
1036
1318
  .action(async (table, options) => {
1037
- await handleAsyncAction("tables describe", options, async () => requestOxygen("/api/cli/tables/describe", {
1319
+ await handleAsyncAction("tables describe", options, () => requestOxygen("/api/cli/tables/describe", {
1038
1320
  method: "POST",
1039
1321
  body: {
1040
1322
  table,
1041
1323
  ...(options.includeArchived ? { include_archived: true } : {}),
1042
1324
  },
1043
1325
  }));
1326
+ }))
1327
+ .addCommand(new Command("activity")
1328
+ .description("Show background runs active on a workspace table right now, with a worker-queue health rollup.")
1329
+ .argument("<table>", "Table id or slug.")
1330
+ .option("--limit <n>", "Maximum active runs to return (1-100). Defaults to 20.")
1331
+ .option("--json", "Print a JSON envelope.")
1332
+ .action(async (table, options) => {
1333
+ await handleAsyncAction("tables activity", options, () => {
1334
+ const params = new URLSearchParams({ table });
1335
+ const limit = readPositiveInt(options.limit);
1336
+ if (limit)
1337
+ params.set("limit", String(limit));
1338
+ return requestOxygen(`/api/cli/tables/activity?${params.toString()}`);
1339
+ });
1044
1340
  }))
1045
1341
  .addCommand(new Command("rename")
1046
1342
  .description("Rename a workspace table display name and slug.")
@@ -1048,7 +1344,7 @@ export function createProgram() {
1048
1344
  .requiredOption("--name <name>", "New table display name.")
1049
1345
  .option("--json", "Print a JSON envelope.")
1050
1346
  .action(async (table, options) => {
1051
- await handleAsyncAction("tables rename", options, async () => requestOxygen("/api/cli/tables/rename", {
1347
+ await handleAsyncAction("tables rename", options, () => requestOxygen("/api/cli/tables/rename", {
1052
1348
  method: "POST",
1053
1349
  body: { table, name: options.name },
1054
1350
  }));
@@ -1058,7 +1354,7 @@ export function createProgram() {
1058
1354
  .argument("<table>", "Table id or slug.")
1059
1355
  .option("--json", "Print a JSON envelope.")
1060
1356
  .action(async (table, options) => {
1061
- await handleAsyncAction("tables archive", options, async () => requestOxygen("/api/cli/tables/archive", {
1357
+ await handleAsyncAction("tables archive", options, () => requestOxygen("/api/cli/tables/archive", {
1062
1358
  method: "POST",
1063
1359
  body: { table },
1064
1360
  }));
@@ -1069,7 +1365,7 @@ export function createProgram() {
1069
1365
  .requiredOption("--project <project>", "Destination project id or slug.")
1070
1366
  .option("--json", "Print a JSON envelope.")
1071
1367
  .action(async (table, options) => {
1072
- await handleAsyncAction("tables move", options, async () => requestOxygen("/api/cli/tables/move", {
1368
+ await handleAsyncAction("tables move", options, () => requestOxygen("/api/cli/tables/move", {
1073
1369
  method: "POST",
1074
1370
  body: { table, project: options.project },
1075
1371
  }));
@@ -1080,7 +1376,7 @@ export function createProgram() {
1080
1376
  .option("--max-items <n>", "Max pending cache rows to poll in this run. Defaults to 1000.")
1081
1377
  .option("--json", "Print a JSON envelope.")
1082
1378
  .action(async (table, options) => {
1083
- await handleAsyncAction("tables recover-pending", options, async () => {
1379
+ await handleAsyncAction("tables recover-pending", options, () => {
1084
1380
  const maxItems = options.maxItems ? Number.parseInt(options.maxItems, 10) : undefined;
1085
1381
  return requestOxygen("/api/cli/tables/recover-pending", {
1086
1382
  method: "POST",
@@ -1196,6 +1492,69 @@ export function createProgram() {
1196
1492
  autoRunStatus: options.autoRunStatus,
1197
1493
  limit: options.limit,
1198
1494
  }))))));
1495
+ tablesCommand.addCommand(new Command("views")
1496
+ .description("Create and manage saved table views (grid / kanban configurations).")
1497
+ .addCommand(new Command("list")
1498
+ .description("List a table's saved views.")
1499
+ .argument("<table>", "Table id or slug.")
1500
+ .option("--json", "Print a JSON envelope.")
1501
+ .action((table, options) => handleAsyncAction("tables views list", options, () => requestOxygen(`/api/cli/tables/views?table=${encodeURIComponent(table)}`))))
1502
+ .addCommand(new Command("get")
1503
+ .description("Get one saved view by id.")
1504
+ .argument("<table>", "Table id or slug.")
1505
+ .argument("<view>", "View id.")
1506
+ .option("--json", "Print a JSON envelope.")
1507
+ .action((table, view, options) => handleAsyncAction("tables views get", options, () => requestOxygen(`/api/cli/tables/views?table=${encodeURIComponent(table)}&view=${encodeURIComponent(view)}`))))
1508
+ .addCommand(new Command("create")
1509
+ .description("Create a saved view.")
1510
+ .argument("<table>", "Table id or slug.")
1511
+ .requiredOption("--name <name>", "View name.")
1512
+ .option("--view-type <type>", "table or kanban. Defaults to table.")
1513
+ .option("--config <json>", "Advanced: raw JSON view config (filters, sorts, columns, group). Usually written by the app.")
1514
+ .option("--default", "Make this the table's default view.")
1515
+ .option("--json", "Print a JSON envelope.")
1516
+ .action((table, options) => handleAsyncAction("tables views create", options, () => requestOxygen("/api/cli/tables/views", {
1517
+ method: "POST",
1518
+ body: {
1519
+ table,
1520
+ name: readOption(options.name),
1521
+ ...(readOption(options.viewType) ? { view_type: readOption(options.viewType) } : {}),
1522
+ ...(readOption(options.config)
1523
+ ? { config: parseJsonValue(readOption(options.config) ?? "", "--config") }
1524
+ : {}),
1525
+ ...(options.default ? { is_default: true } : {}),
1526
+ },
1527
+ }))))
1528
+ .addCommand(new Command("update")
1529
+ .description("Update a saved view.")
1530
+ .argument("<table>", "Table id or slug.")
1531
+ .argument("<view>", "View id.")
1532
+ .option("--name <name>", "Rename the view.")
1533
+ .option("--view-type <type>", "table or kanban.")
1534
+ .option("--config <json>", "Advanced: raw JSON view config. Replaces the stored config.")
1535
+ .option("--default", "Make this the table's default view.")
1536
+ .option("--position <n>", "0-based order among the table's views.")
1537
+ .option("--json", "Print a JSON envelope.")
1538
+ .action((table, view, options) => handleAsyncAction("tables views update", options, () => requestOxygen("/api/cli/tables/views", {
1539
+ method: "PATCH",
1540
+ body: {
1541
+ table,
1542
+ view,
1543
+ ...(readOption(options.name) ? { name: readOption(options.name) } : {}),
1544
+ ...(readOption(options.viewType) ? { view_type: readOption(options.viewType) } : {}),
1545
+ ...(readOption(options.config)
1546
+ ? { config: parseJsonValue(readOption(options.config) ?? "", "--config") }
1547
+ : {}),
1548
+ ...(options.default ? { is_default: true } : {}),
1549
+ ...(readOption(options.position) ? { position: Number(readOption(options.position)) } : {}),
1550
+ },
1551
+ }))))
1552
+ .addCommand(new Command("delete")
1553
+ .description("Delete a saved view by id.")
1554
+ .argument("<table>", "Table id or slug.")
1555
+ .argument("<view>", "View id.")
1556
+ .option("--json", "Print a JSON envelope.")
1557
+ .action((table, view, options) => handleAsyncAction("tables views delete", options, () => requestOxygen(`/api/cli/tables/views?table=${encodeURIComponent(table)}&view=${encodeURIComponent(view)}`, { method: "DELETE" })))));
1199
1558
  program
1200
1559
  .command("context")
1201
1560
  .description("Workspace-level GTM context commands.")
@@ -1210,7 +1569,7 @@ export function createProgram() {
1210
1569
  .option("--require-ready", "Exit with a conflict error when required context sections are missing.")
1211
1570
  .option("--json", "Print a JSON envelope.")
1212
1571
  .action(async (options) => {
1213
- await handleAsyncAction("context resolve", options, async () => requestOxygen("/api/cli/context/resolve", {
1572
+ await handleAsyncAction("context resolve", options, () => requestOxygen("/api/cli/context/resolve", {
1214
1573
  method: "POST",
1215
1574
  body: buildContextResolveBody(options),
1216
1575
  }));
@@ -1221,7 +1580,7 @@ export function createProgram() {
1221
1580
  .description("Read the current workspace company profile.")
1222
1581
  .option("--json", "Print a JSON envelope.")
1223
1582
  .action(async (options) => {
1224
- await handleAsyncAction("context profile get", options, async () => requestOxygen("/api/cli/context/profile"));
1583
+ await handleAsyncAction("context profile get", options, () => requestOxygen("/api/cli/context/profile"));
1225
1584
  }))
1226
1585
  .addCommand(new Command("update")
1227
1586
  .description("Merge one or more profile sections into workspace GTM memory.")
@@ -1229,7 +1588,7 @@ export function createProgram() {
1229
1588
  .option("--summary <text>", "Optional concise summary for the profile.")
1230
1589
  .option("--json", "Print a JSON envelope.")
1231
1590
  .action(async (options) => {
1232
- await handleAsyncAction("context profile update", options, async () => requestOxygen("/api/cli/context/profile/update", {
1591
+ await handleAsyncAction("context profile update", options, () => requestOxygen("/api/cli/context/profile/update", {
1233
1592
  method: "POST",
1234
1593
  body: {
1235
1594
  data: parseJsonObject(options.dataJson ?? "{}"),
@@ -1247,14 +1606,14 @@ export function createProgram() {
1247
1606
  .option("--include-archived", "Include archived assets when no status filter is set.")
1248
1607
  .option("--json", "Print a JSON envelope.")
1249
1608
  .action(async (options) => {
1250
- await handleAsyncAction("context assets list", options, async () => requestOxygen(`/api/cli/context/assets${contextAssetsQuery(options)}`));
1609
+ await handleAsyncAction("context assets list", options, () => requestOxygen(`/api/cli/context/assets${contextAssetsQuery(options)}`));
1251
1610
  }))
1252
1611
  .addCommand(new Command("get")
1253
1612
  .description("Read one context asset.")
1254
1613
  .argument("<asset_id>", "Context asset UUID.")
1255
1614
  .option("--json", "Print a JSON envelope.")
1256
1615
  .action(async (assetId, options) => {
1257
- await handleAsyncAction("context asset get", options, async () => requestOxygen("/api/cli/context/assets/get", {
1616
+ await handleAsyncAction("context asset get", options, () => requestOxygen("/api/cli/context/assets/get", {
1258
1617
  method: "POST",
1259
1618
  body: { id: assetId },
1260
1619
  }));
@@ -1272,7 +1631,7 @@ export function createProgram() {
1272
1631
  .option("--asset-json <json>", "Full asset JSON object. CLI flags override matching fields.")
1273
1632
  .option("--json", "Print a JSON envelope.")
1274
1633
  .action(async (options) => {
1275
- await handleAsyncAction("context asset upsert", options, async () => requestOxygen("/api/cli/context/assets/upsert", {
1634
+ await handleAsyncAction("context asset upsert", options, () => requestOxygen("/api/cli/context/assets/upsert", {
1276
1635
  method: "POST",
1277
1636
  body: buildContextAssetUpsertBody(options),
1278
1637
  }));
@@ -1282,7 +1641,7 @@ export function createProgram() {
1282
1641
  .argument("<asset_id>", "Context asset UUID.")
1283
1642
  .option("--json", "Print a JSON envelope.")
1284
1643
  .action(async (assetId, options) => {
1285
- await handleAsyncAction("context asset archive", options, async () => requestOxygen("/api/cli/context/assets/archive", {
1644
+ await handleAsyncAction("context asset archive", options, () => requestOxygen("/api/cli/context/assets/archive", {
1286
1645
  method: "POST",
1287
1646
  body: { id: assetId },
1288
1647
  }));
@@ -1336,10 +1695,10 @@ export function createProgram() {
1336
1695
  const body = { workflow_id: options.workflow };
1337
1696
  const tables = readOption(options.tables);
1338
1697
  if (tables)
1339
- body.table_ids = splitCsv(tables);
1698
+ body.table_ids = readCsvOption(tables);
1340
1699
  const prompts = readOption(options.prompts);
1341
1700
  if (prompts)
1342
- body.prompt_slugs = splitCsv(prompts);
1701
+ body.prompt_slugs = readCsvOption(prompts);
1343
1702
  const blueprintId = readOption(options.blueprintId);
1344
1703
  if (blueprintId)
1345
1704
  body.blueprint_id = blueprintId;
@@ -1351,7 +1710,7 @@ export function createProgram() {
1351
1710
  body.blueprint_summary = blueprintSummary;
1352
1711
  const blueprintTags = readOption(options.blueprintTags);
1353
1712
  if (blueprintTags)
1354
- body.blueprint_tags = splitCsv(blueprintTags);
1713
+ body.blueprint_tags = readCsvOption(blueprintTags);
1355
1714
  const result = await requestOxygen("/api/cli/blueprints/export", {
1356
1715
  method: "POST",
1357
1716
  body,
@@ -1415,7 +1774,7 @@ export function createProgram() {
1415
1774
  throw new Error("--file is required for blueprints save");
1416
1775
  const fs = await import("node:fs/promises");
1417
1776
  const raw = await fs.readFile(filePath, "utf8");
1418
- const envelope = JSON.parse(raw);
1777
+ const envelope = parseJsonValue(raw, "--file");
1419
1778
  const body = { envelope };
1420
1779
  const slug = readOption(options.slug);
1421
1780
  if (slug)
@@ -1518,154 +1877,8 @@ export function createProgram() {
1518
1877
  return requestOxygen(`/api/blueprints/marketplace${qs}`, { requireAuth: false });
1519
1878
  });
1520
1879
  }));
1521
- program
1522
- .command("prompts")
1523
- .description("Reusable prompt templates layered into AI columns at run time.")
1524
- .addCommand(new Command("list")
1525
- .description("List prompt templates in the workspace.")
1526
- .option("--kind <kind>", "Filter by ai_column_system, scoring_rubric, or other.")
1527
- .option("--include-archived", "Include archived templates.")
1528
- .option("--json", "Print a JSON envelope.")
1529
- .action(async (options) => {
1530
- await handleAsyncAction("prompts list", options, () => {
1531
- const params = new URLSearchParams();
1532
- const kind = readOption(options.kind);
1533
- if (kind)
1534
- params.set("kind", kind);
1535
- if (options.includeArchived)
1536
- params.set("include_archived", "true");
1537
- const qs = params.toString() ? `?${params.toString()}` : "";
1538
- return requestOxygen(`/api/cli/templates${qs}`);
1539
- });
1540
- }))
1541
- .addCommand(new Command("get")
1542
- .description("Read one prompt template by id or slug.")
1543
- .argument("<id_or_slug>", "Template UUID or slug.")
1544
- .option("--json", "Print a JSON envelope.")
1545
- .action(async (idOrSlug, options) => {
1546
- await handleAsyncAction("prompts get", options, () => requestOxygen("/api/cli/templates/get", {
1547
- method: "POST",
1548
- body: idOrSlug.includes("-") && idOrSlug.length >= 32
1549
- ? { id: idOrSlug }
1550
- : { slug: idOrSlug },
1551
- }));
1552
- }))
1553
- .addCommand(new Command("upsert")
1554
- .description("Create or update a prompt template.")
1555
- .option("--id <id>", "Existing template UUID to update. Omit to create.")
1556
- .option("--slug <slug>", "Stable slug (kebab-case).")
1557
- .option("--name <name>", "Human-readable name.")
1558
- .option("--description <text>", "Short description.")
1559
- .option("--kind <kind>", "Template kind: ai_column_system, scoring_rubric, or other.")
1560
- .option("--body <text>", "Prompt body.")
1561
- .option("--body-file <path>", "Path to a file containing the prompt body.")
1562
- .option("--json", "Print a JSON envelope.")
1563
- .action(async (options) => {
1564
- await handleAsyncAction("prompts upsert", options, async () => {
1565
- const body = {};
1566
- if (readOption(options.id))
1567
- body.id = readOption(options.id);
1568
- if (readOption(options.slug))
1569
- body.slug = readOption(options.slug);
1570
- if (readOption(options.name))
1571
- body.name = readOption(options.name);
1572
- if (readOption(options.description) !== undefined)
1573
- body.description = readOption(options.description);
1574
- if (readOption(options.kind))
1575
- body.kind = readOption(options.kind);
1576
- if (readOption(options.body))
1577
- body.body = readOption(options.body);
1578
- else {
1579
- const path = readOption(options.bodyFile);
1580
- if (path) {
1581
- const fs = await import("node:fs/promises");
1582
- body.body = await fs.readFile(path, "utf8");
1583
- }
1584
- }
1585
- return requestOxygen("/api/cli/templates/upsert", { method: "POST", body });
1586
- });
1587
- }))
1588
- .addCommand(new Command("archive")
1589
- .description("Archive a prompt template. Seeded templates cannot be archived.")
1590
- .argument("<id>", "Template UUID.")
1591
- .option("--json", "Print a JSON envelope.")
1592
- .action(async (id, options) => {
1593
- await handleAsyncAction("prompts archive", options, () => requestOxygen("/api/cli/templates/archive", { method: "POST", body: { id } }));
1594
- }));
1595
- program
1596
- .command("templates")
1597
- .description("Deprecated alias for 'oxygen prompts'. Will be removed in a future release.")
1598
- .addCommand(new Command("list")
1599
- .description("List prompt templates in the workspace.")
1600
- .option("--kind <kind>", "Filter by ai_column_system, scoring_rubric, or other.")
1601
- .option("--include-archived", "Include archived templates.")
1602
- .option("--json", "Print a JSON envelope.")
1603
- .action(async (options) => {
1604
- await handleAsyncAction("templates list", options, () => {
1605
- const params = new URLSearchParams();
1606
- const kind = readOption(options.kind);
1607
- if (kind)
1608
- params.set("kind", kind);
1609
- if (options.includeArchived)
1610
- params.set("include_archived", "true");
1611
- const qs = params.toString() ? `?${params.toString()}` : "";
1612
- return requestOxygen(`/api/cli/templates${qs}`);
1613
- });
1614
- }))
1615
- .addCommand(new Command("get")
1616
- .description("Read one prompt template by id or slug.")
1617
- .argument("<id_or_slug>", "Template UUID or slug.")
1618
- .option("--json", "Print a JSON envelope.")
1619
- .action(async (idOrSlug, options) => {
1620
- await handleAsyncAction("templates get", options, async () => requestOxygen("/api/cli/templates/get", {
1621
- method: "POST",
1622
- body: idOrSlug.includes("-") && idOrSlug.length >= 32
1623
- ? { id: idOrSlug }
1624
- : { slug: idOrSlug },
1625
- }));
1626
- }))
1627
- .addCommand(new Command("upsert")
1628
- .description("Create or update a prompt template.")
1629
- .option("--id <id>", "Existing template UUID to update. Omit to create.")
1630
- .option("--slug <slug>", "Stable slug (kebab-case).")
1631
- .option("--name <name>", "Human-readable name.")
1632
- .option("--description <text>", "Short description.")
1633
- .option("--kind <kind>", "Template kind: ai_column_system, scoring_rubric, or other.")
1634
- .option("--body <text>", "Prompt body.")
1635
- .option("--body-file <path>", "Path to a file containing the prompt body.")
1636
- .option("--json", "Print a JSON envelope.")
1637
- .action(async (options) => {
1638
- await handleAsyncAction("templates upsert", options, async () => {
1639
- const body = {};
1640
- if (readOption(options.id))
1641
- body.id = readOption(options.id);
1642
- if (readOption(options.slug))
1643
- body.slug = readOption(options.slug);
1644
- if (readOption(options.name))
1645
- body.name = readOption(options.name);
1646
- if (readOption(options.description) !== undefined)
1647
- body.description = readOption(options.description);
1648
- if (readOption(options.kind))
1649
- body.kind = readOption(options.kind);
1650
- if (readOption(options.body))
1651
- body.body = readOption(options.body);
1652
- else {
1653
- const path = readOption(options.bodyFile);
1654
- if (path) {
1655
- const fs = await import("node:fs/promises");
1656
- body.body = await fs.readFile(path, "utf8");
1657
- }
1658
- }
1659
- return requestOxygen("/api/cli/templates/upsert", { method: "POST", body });
1660
- });
1661
- }))
1662
- .addCommand(new Command("archive")
1663
- .description("Archive a prompt template. Seeded templates cannot be archived.")
1664
- .argument("<id>", "Template UUID.")
1665
- .option("--json", "Print a JSON envelope.")
1666
- .action(async (id, options) => {
1667
- await handleAsyncAction("templates archive", options, async () => requestOxygen("/api/cli/templates/archive", { method: "POST", body: { id } }));
1668
- }));
1880
+ program.addCommand(buildPromptTemplatesCommand("prompts", "Reusable prompt templates layered into AI columns at run time."));
1881
+ program.addCommand(buildPromptTemplatesCommand("templates", "Deprecated alias for 'oxygen prompts'. Will be removed in a future release."));
1669
1882
  program
1670
1883
  .command("reviews")
1671
1884
  .description("Human-in-the-loop reviews for AI-generated outreach messages.")
@@ -1710,7 +1923,7 @@ export function createProgram() {
1710
1923
  .argument("<review_id>", "Message review UUID.")
1711
1924
  .option("--json", "Print a JSON envelope.")
1712
1925
  .action(async (reviewId, options) => {
1713
- await handleAsyncAction("reviews accept", options, async () => requestOxygen("/api/cli/message-reviews/decide", {
1926
+ await handleAsyncAction("reviews accept", options, () => requestOxygen("/api/cli/message-reviews/decide", {
1714
1927
  method: "POST",
1715
1928
  body: { id: reviewId, decision: "accept" },
1716
1929
  }));
@@ -1726,7 +1939,7 @@ export function createProgram() {
1726
1939
  const body = { id: reviewId, decision: "reject" };
1727
1940
  const highlightsJson = readOption(options.highlightsJson);
1728
1941
  if (highlightsJson) {
1729
- body.highlights = JSON.parse(highlightsJson);
1942
+ body.highlights = parseJsonValue(highlightsJson, "--highlights-json");
1730
1943
  }
1731
1944
  if (options.autoRerun)
1732
1945
  body.auto_rerun = true;
@@ -1747,7 +1960,14 @@ export function createProgram() {
1747
1960
  .option("--definition-json <json>", "Optional JSON object with column definition metadata.")
1748
1961
  .option("--prompt-key <key>", "OXYGEN prompt-library key (e.g. email_draft_v1). Materializes prompt + output_schema and forces kind=ai.")
1749
1962
  .option("--input-mapping <json>", "Required with --prompt-key. JSON object mapping prompt input names to column or literal refs.")
1750
- .option("--json", "Print a JSON envelope.")
1963
+ .option("--model <id>", "AI column model id (e.g. claude-sonnet-4-5). Explicit models require credentialMode byok unless allow-listed managed.")
1964
+ .option("--reasoning-level <level>", "AI column reasoning level: low, medium, or high.")
1965
+ .option("--run-condition <formula>", "Formula expression gating whether the AI column runs per row.")
1966
+ .option("--run-condition-columns <csv>", "Comma-separated column keys referenced by --run-condition.")
1967
+ .option("--output-schema-json <json>", "AI column output JSON schema as inline JSON.")
1968
+ .option("--output-schema-file <path>", "AI column output JSON schema read from a file path.")
1969
+ .option("--json", "Print a JSON envelope.")
1970
+ // skipcq: JS-R1005 — intentional per-option branching to assemble the columns-add request body
1751
1971
  .action(async (table, options) => {
1752
1972
  if (!options.promptKey && !options.label) {
1753
1973
  throw new OxygenError("invalid_request", "--label is required.", { exitCode: 1 });
@@ -1768,12 +1988,20 @@ export function createProgram() {
1768
1988
  column.semantic_type = options.semanticType;
1769
1989
  if (options.definitionJson)
1770
1990
  column.definition = parseJsonObject(options.definitionJson);
1991
+ if (readOption(options.model) ||
1992
+ readOption(options.reasoningLevel) ||
1993
+ readOption(options.runCondition) ||
1994
+ readOption(options.outputSchemaJson) ||
1995
+ readOption(options.outputSchemaFile)) {
1996
+ const definition = isRecord(column.definition) ? column.definition : {};
1997
+ column.definition = applyAiColumnConfig(definition, options);
1998
+ }
1771
1999
  const body = { table, column };
1772
2000
  if (options.promptKey)
1773
2001
  body.prompt_key = options.promptKey;
1774
2002
  if (options.inputMapping)
1775
2003
  body.input_mapping = parseJsonObject(options.inputMapping);
1776
- await handleAsyncAction("columns add", options, async () => requestOxygen("/api/cli/tables/columns", {
2004
+ await handleAsyncAction("columns add", options, () => requestOxygen("/api/cli/tables/columns", {
1777
2005
  method: "POST",
1778
2006
  body,
1779
2007
  }));
@@ -1793,6 +2021,7 @@ export function createProgram() {
1793
2021
  .option("--max-concurrency <n>", "Maximum concurrent row items for a background run. Defaults to 250 for AI columns and 50 otherwise.")
1794
2022
  .option("--local", "Run a custom HTTP column in this CLI process so env-var secrets stay local.")
1795
2023
  .option("--local-concurrency <n>", "Maximum concurrent custom HTTP requests for --local. Defaults to 3.")
2024
+ .option("--dry-run", "Preview resolved model, credit estimate, and run-condition posture without spending any credits.")
1796
2025
  .option("--json", "Print a JSON envelope.")
1797
2026
  .action(async (table, column, options) => {
1798
2027
  const limit = readPositiveInt(options.limit);
@@ -1816,7 +2045,8 @@ export function createProgram() {
1816
2045
  exitCode: 1,
1817
2046
  });
1818
2047
  }
1819
- await handleAsyncAction("columns run", options, async () => {
2048
+ // skipcq: JS-R1005 intentional branching for local/background/filter column-run modes
2049
+ await handleAsyncAction("columns run", options, () => {
1820
2050
  if (options.local) {
1821
2051
  if (options.background) {
1822
2052
  throw new OxygenError("invalid_column_run", "Pass either --local or --background, not both.", {
@@ -1847,6 +2077,7 @@ export function createProgram() {
1847
2077
  ...(options.background ? { background: true } : {}),
1848
2078
  ...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
1849
2079
  ...(maxConcurrency ? { max_concurrency: maxConcurrency } : {}),
2080
+ ...(options.dryRun ? { dry_run: true } : {}),
1850
2081
  };
1851
2082
  return requestColumnsRun(body, table, {
1852
2083
  background: Boolean(options.background),
@@ -1859,15 +2090,17 @@ export function createProgram() {
1859
2090
  .requiredOption("--column <column>", "Column id or key.")
1860
2091
  .requiredOption("--row <row_id>", "Row UUID.")
1861
2092
  .option("--from-review-id <review_id>", "Prior message review whose feedback to thread into the regeneration prompt.")
2093
+ .option("--dry-run", "Preview the single-row credit estimate and posture without spending credits or writing a review.")
1862
2094
  .option("--json", "Print a JSON envelope.")
1863
2095
  .action(async (options) => {
1864
- await handleAsyncAction("columns rerun", options, async () => requestOxygen("/api/cli/columns/rerun", {
2096
+ await handleAsyncAction("columns rerun", options, () => requestOxygen("/api/cli/columns/rerun", {
1865
2097
  method: "POST",
1866
2098
  body: {
1867
2099
  table: options.table,
1868
2100
  column: options.column,
1869
2101
  row_id: options.row,
1870
2102
  ...(readOption(options.fromReviewId) ? { from_review_id: readOption(options.fromReviewId) } : {}),
2103
+ ...(options.dryRun ? { dry_run: true } : {}),
1871
2104
  },
1872
2105
  }));
1873
2106
  }))
@@ -1882,7 +2115,7 @@ export function createProgram() {
1882
2115
  .action(async (table, sourceColumn, options) => {
1883
2116
  const mappings = options.mappingsJson ? parseJsonArray(options.mappingsJson) : undefined;
1884
2117
  const preset = readOption(options.preset);
1885
- await handleAsyncAction("columns materialize", options, async () => requestOxygen("/api/cli/tables/columns/materialize", {
2118
+ await handleAsyncAction("columns materialize", options, () => requestOxygen("/api/cli/tables/columns/materialize", {
1886
2119
  method: "POST",
1887
2120
  body: {
1888
2121
  table,
@@ -1901,7 +2134,7 @@ export function createProgram() {
1901
2134
  .option("--label <label>", "Optional new display label.")
1902
2135
  .option("--json", "Print a JSON envelope.")
1903
2136
  .action(async (table, column, options) => {
1904
- await handleAsyncAction("columns rename", options, async () => requestOxygen("/api/cli/tables/columns/rename", {
2137
+ await handleAsyncAction("columns rename", options, () => requestOxygen("/api/cli/tables/columns/rename", {
1905
2138
  method: "POST",
1906
2139
  body: {
1907
2140
  table,
@@ -1918,9 +2151,26 @@ export function createProgram() {
1918
2151
  .option("--label <label>", "New display label.")
1919
2152
  .option("--semantic-type <type>", "New semantic type.")
1920
2153
  .option("--definition-json <json>", "Definition metadata to shallow-merge into the column; arrays are replaced wholesale and missing keys leave existing fields unchanged.")
2154
+ .option("--model <id>", "AI column model id (e.g. claude-sonnet-4-5). Explicit models require credentialMode byok unless allow-listed managed.")
2155
+ .option("--reasoning-level <level>", "AI column reasoning level: low, medium, or high.")
2156
+ .option("--run-condition <formula>", "Formula expression gating whether the AI column runs per row.")
2157
+ .option("--run-condition-columns <csv>", "Comma-separated column keys referenced by --run-condition.")
2158
+ .option("--output-schema-json <json>", "AI column output JSON schema as inline JSON.")
2159
+ .option("--output-schema-file <path>", "AI column output JSON schema read from a file path.")
1921
2160
  .option("--dry-run", "Return the would-be merged definition without writing.")
1922
2161
  .option("--json", "Print a JSON envelope.")
1923
2162
  .action(async (table, column, options) => {
2163
+ const hasAiConfig = readOption(options.model) ||
2164
+ readOption(options.reasoningLevel) ||
2165
+ readOption(options.runCondition) ||
2166
+ readOption(options.outputSchemaJson) ||
2167
+ readOption(options.outputSchemaFile);
2168
+ let definition = options.definitionJson
2169
+ ? parseJsonObject(options.definitionJson)
2170
+ : undefined;
2171
+ if (hasAiConfig) {
2172
+ definition = applyAiColumnConfig(definition ?? {}, options);
2173
+ }
1924
2174
  await handleAsyncAction("columns update", options, () => requestOxygen("/api/cli/tables/columns/update", {
1925
2175
  method: "POST",
1926
2176
  body: {
@@ -1928,7 +2178,7 @@ export function createProgram() {
1928
2178
  column,
1929
2179
  ...(readOption(options.label) ? { label: readOption(options.label) } : {}),
1930
2180
  ...(readOption(options.semanticType) ? { semantic_type: readOption(options.semanticType) } : {}),
1931
- ...(options.definitionJson ? { definition: parseJsonObject(options.definitionJson) } : {}),
2181
+ ...(definition ? { definition } : {}),
1932
2182
  ...(options.dryRun ? { dry_run: true } : {}),
1933
2183
  },
1934
2184
  }));
@@ -1941,7 +2191,7 @@ export function createProgram() {
1941
2191
  .option("--dry-run", "Preview the conversion (row counts and samples) without writing.")
1942
2192
  .option("--json", "Print a JSON envelope.")
1943
2193
  .action(async (table, column, options) => {
1944
- await handleAsyncAction("columns retype", options, async () => requestOxygen("/api/cli/tables/columns/retype", {
2194
+ await handleAsyncAction("columns retype", options, () => requestOxygen("/api/cli/tables/columns/retype", {
1945
2195
  method: "POST",
1946
2196
  body: {
1947
2197
  table,
@@ -1957,7 +2207,7 @@ export function createProgram() {
1957
2207
  .argument("<column>", "Column id or key.")
1958
2208
  .option("--json", "Print a JSON envelope.")
1959
2209
  .action(async (table, column, options) => {
1960
- await handleAsyncAction("columns archive", options, async () => requestOxygen("/api/cli/tables/columns/archive", {
2210
+ await handleAsyncAction("columns archive", options, () => requestOxygen("/api/cli/tables/columns/archive", {
1961
2211
  method: "POST",
1962
2212
  body: { table, column },
1963
2213
  }));
@@ -1976,7 +2226,7 @@ export function createProgram() {
1976
2226
  .option("--dry-run", "Preview the provider action column without creating it.")
1977
2227
  .option("--json", "Print a JSON envelope.")
1978
2228
  .action(async (table, options) => {
1979
- await handleAsyncAction("action-column add-provider", options, async () => {
2229
+ await handleAsyncAction("action-column add-provider", options, () => {
1980
2230
  const tableId = readOption(table) ?? readOption(options.table);
1981
2231
  if (!tableId) {
1982
2232
  throw new OxygenError("invalid_request", "Pass a table argument or --table <table>.", {
@@ -2021,7 +2271,7 @@ export function createProgram() {
2021
2271
  const maxCredits = readPositiveNumber(options.maxCredits);
2022
2272
  const maxConcurrency = readPositiveInt(options.maxConcurrency);
2023
2273
  const chainSteps = readChainStepsOption(options.thenJson);
2024
- await handleAsyncAction("table-runs create", options, async () => requestOxygen("/api/cli/table-action-runs", {
2274
+ await handleAsyncAction("table-runs create", options, () => requestOxygen("/api/cli/table-action-runs", {
2025
2275
  method: "POST",
2026
2276
  body: {
2027
2277
  table,
@@ -2050,7 +2300,7 @@ export function createProgram() {
2050
2300
  .argument("<run_id>", "Table action run UUID or parent workspace run UUID.")
2051
2301
  .option("--json", "Print a JSON envelope.")
2052
2302
  .action(async (runId, options) => {
2053
- await handleAsyncAction("table-runs get", options, async () => requestOxygen(`/api/cli/table-action-runs/${encodeURIComponent(runId)}`));
2303
+ await handleAsyncAction("table-runs get", options, () => requestOxygen(`/api/cli/table-action-runs/${encodeURIComponent(runId)}`));
2054
2304
  }))
2055
2305
  .addCommand(new Command("items")
2056
2306
  .description("List row items for a durable table action run.")
@@ -2059,7 +2309,7 @@ export function createProgram() {
2059
2309
  .option("--limit <n>", "Maximum items to return. Defaults to 100.")
2060
2310
  .option("--json", "Print a JSON envelope.")
2061
2311
  .action(async (runId, options) => {
2062
- await handleAsyncAction("table-runs items", options, async () => {
2312
+ await handleAsyncAction("table-runs items", options, () => {
2063
2313
  const query = new URLSearchParams();
2064
2314
  if (readOption(options.status))
2065
2315
  query.set("status", readOption(options.status) ?? "");
@@ -2077,21 +2327,21 @@ export function createProgram() {
2077
2327
  .option("--interval-seconds <n>", "Polling interval. Defaults to 5.")
2078
2328
  .option("--json", "Print a JSON envelope.")
2079
2329
  .action(async (runId, options) => {
2080
- await handleAsyncAction("table-runs wait", options, async () => waitForTableActionRun(runId, options));
2330
+ await handleAsyncAction("table-runs wait", options, () => waitForTableActionRun(runId, options));
2081
2331
  }))
2082
2332
  .addCommand(new Command("provider-summary")
2083
2333
  .description("Summarize provider attempts, upstream request events, and credit capture/release for a table action run.")
2084
2334
  .argument("<run_id>", "Table action run UUID.")
2085
2335
  .option("--json", "Print a JSON envelope.")
2086
2336
  .action(async (runId, options) => {
2087
- await handleAsyncAction("table-runs provider-summary", options, async () => requestOxygen(`/api/cli/table-action-runs/${encodeURIComponent(runId)}/provider-summary`));
2337
+ await handleAsyncAction("table-runs provider-summary", options, () => requestOxygen(`/api/cli/table-action-runs/${encodeURIComponent(runId)}/provider-summary`));
2088
2338
  }))
2089
2339
  .addCommand(new Command("cancel")
2090
2340
  .description("Request cancellation for a durable table action run.")
2091
2341
  .argument("<run_id>", "Table action run UUID or parent workspace run UUID.")
2092
2342
  .option("--json", "Print a JSON envelope.")
2093
2343
  .action(async (runId, options) => {
2094
- await handleAsyncAction("table-runs cancel", options, async () => requestOxygen(`/api/cli/table-action-runs/${encodeURIComponent(runId)}/cancel`, {
2344
+ await handleAsyncAction("table-runs cancel", options, () => requestOxygen(`/api/cli/table-action-runs/${encodeURIComponent(runId)}/cancel`, {
2095
2345
  method: "POST",
2096
2346
  body: {},
2097
2347
  }));
@@ -2101,7 +2351,7 @@ export function createProgram() {
2101
2351
  .argument("<run_id>", "Table action run UUID.")
2102
2352
  .option("--json", "Print a JSON envelope.")
2103
2353
  .action(async (runId, options) => {
2104
- await handleAsyncAction("table-runs pause", options, async () => requestOxygen(`/api/cli/table-action-runs/${encodeURIComponent(runId)}/pause`, {
2354
+ await handleAsyncAction("table-runs pause", options, () => requestOxygen(`/api/cli/table-action-runs/${encodeURIComponent(runId)}/pause`, {
2105
2355
  method: "POST",
2106
2356
  body: {},
2107
2357
  }));
@@ -2111,7 +2361,7 @@ export function createProgram() {
2111
2361
  .argument("<run_id>", "Table action run UUID.")
2112
2362
  .option("--json", "Print a JSON envelope.")
2113
2363
  .action(async (runId, options) => {
2114
- await handleAsyncAction("table-runs resume", options, async () => requestOxygen(`/api/cli/table-action-runs/${encodeURIComponent(runId)}/resume`, {
2364
+ await handleAsyncAction("table-runs resume", options, () => requestOxygen(`/api/cli/table-action-runs/${encodeURIComponent(runId)}/resume`, {
2115
2365
  method: "POST",
2116
2366
  body: {},
2117
2367
  }));
@@ -2121,7 +2371,7 @@ export function createProgram() {
2121
2371
  .argument("<run_id>", "Table action run UUID.")
2122
2372
  .option("--json", "Print a JSON envelope.")
2123
2373
  .action(async (runId, options) => {
2124
- await handleAsyncAction("table-runs retry-failed", options, async () => requestOxygen(`/api/cli/table-action-runs/${encodeURIComponent(runId)}/retry-failed`, {
2374
+ await handleAsyncAction("table-runs retry-failed", options, () => requestOxygen(`/api/cli/table-action-runs/${encodeURIComponent(runId)}/retry-failed`, {
2125
2375
  method: "POST",
2126
2376
  body: {},
2127
2377
  }));
@@ -2146,7 +2396,7 @@ export function createProgram() {
2146
2396
  .option("--metadata-json <json>", "Optional metadata object to attach to the run.")
2147
2397
  .option("--json", "Print a JSON envelope.")
2148
2398
  .action(async (table, options) => {
2149
- await handleAsyncAction("table-ingestions create-tool-page", options, async () => {
2399
+ await handleAsyncAction("table-ingestions create-tool-page", options, () => {
2150
2400
  const toolId = readOption(options.tool) ?? readOption(options.toolId);
2151
2401
  if (!toolId) {
2152
2402
  throw new OxygenError("invalid_table_ingestion", "Pass --tool or --tool-id.", {
@@ -2193,7 +2443,7 @@ export function createProgram() {
2193
2443
  .argument("<run_id>", "Table ingestion run UUID.")
2194
2444
  .option("--json", "Print a JSON envelope.")
2195
2445
  .action(async (runId, options) => {
2196
- await handleAsyncAction("table-ingestions get", options, async () => requestOxygen(`/api/cli/table-ingestion-runs/${encodeURIComponent(runId)}`));
2446
+ await handleAsyncAction("table-ingestions get", options, () => requestOxygen(`/api/cli/table-ingestion-runs/${encodeURIComponent(runId)}`));
2197
2447
  }))
2198
2448
  .addCommand(new Command("items")
2199
2449
  .description("List items for a durable table ingestion run.")
@@ -2202,7 +2452,7 @@ export function createProgram() {
2202
2452
  .option("--limit <n>", "Maximum items to return. Defaults to 100.")
2203
2453
  .option("--json", "Print a JSON envelope.")
2204
2454
  .action(async (runId, options) => {
2205
- await handleAsyncAction("table-ingestions items", options, async () => {
2455
+ await handleAsyncAction("table-ingestions items", options, () => {
2206
2456
  const query = new URLSearchParams();
2207
2457
  if (readOption(options.status))
2208
2458
  query.set("status", readOption(options.status) ?? "");
@@ -2220,14 +2470,14 @@ export function createProgram() {
2220
2470
  .option("--interval-seconds <n>", "Polling interval. Defaults to 5.")
2221
2471
  .option("--json", "Print a JSON envelope.")
2222
2472
  .action(async (runId, options) => {
2223
- await handleAsyncAction("table-ingestions wait", options, async () => waitForTableIngestionRun(runId, options));
2473
+ await handleAsyncAction("table-ingestions wait", options, () => waitForTableIngestionRun(runId, options));
2224
2474
  }))
2225
2475
  .addCommand(new Command("cancel")
2226
2476
  .description("Request cancellation for a durable table ingestion run.")
2227
2477
  .argument("<run_id>", "Table ingestion run UUID.")
2228
2478
  .option("--json", "Print a JSON envelope.")
2229
2479
  .action(async (runId, options) => {
2230
- await handleAsyncAction("table-ingestions cancel", options, async () => requestOxygen(`/api/cli/table-ingestion-runs/${encodeURIComponent(runId)}/cancel`, {
2480
+ await handleAsyncAction("table-ingestions cancel", options, () => requestOxygen(`/api/cli/table-ingestion-runs/${encodeURIComponent(runId)}/cancel`, {
2231
2481
  method: "POST",
2232
2482
  body: {},
2233
2483
  }));
@@ -2237,7 +2487,7 @@ export function createProgram() {
2237
2487
  .argument("<run_id>", "Table ingestion run UUID.")
2238
2488
  .option("--json", "Print a JSON envelope.")
2239
2489
  .action(async (runId, options) => {
2240
- await handleAsyncAction("table-ingestions retry-failed", options, async () => requestOxygen(`/api/cli/table-ingestion-runs/${encodeURIComponent(runId)}/retry-failed`, {
2490
+ await handleAsyncAction("table-ingestions retry-failed", options, () => requestOxygen(`/api/cli/table-ingestion-runs/${encodeURIComponent(runId)}/retry-failed`, {
2241
2491
  method: "POST",
2242
2492
  body: {},
2243
2493
  }));
@@ -2272,7 +2522,7 @@ export function createProgram() {
2272
2522
  .option("--materialize-preview", "Create a preview table for the plan. Defaults to no table side effect.")
2273
2523
  .option("--json", "Print a JSON envelope.")
2274
2524
  .action(async (options) => {
2275
- await handleAsyncAction("lead-sourcing plan", options, async () => requestOxygen("/api/cli/lead-sourcing/plan", {
2525
+ await handleAsyncAction("lead-sourcing plan", options, () => requestOxygen("/api/cli/lead-sourcing/plan", {
2276
2526
  method: "POST",
2277
2527
  body: {
2278
2528
  prompt: readFileIfPresent(options.prompt),
@@ -2286,7 +2536,7 @@ export function createProgram() {
2286
2536
  .requiredOption("--spec <file>", "JSON LeadSourcingSpec file, or a text prompt file to compile into a spec.")
2287
2537
  .option("--json", "Print a JSON envelope.")
2288
2538
  .action(async (table, options) => {
2289
- await handleAsyncAction("lead-sourcing audit", options, async () => requestOxygen("/api/cli/lead-sourcing/audit", {
2539
+ await handleAsyncAction("lead-sourcing audit", options, () => requestOxygen("/api/cli/lead-sourcing/audit", {
2290
2540
  method: "POST",
2291
2541
  body: {
2292
2542
  table,
@@ -2300,7 +2550,7 @@ export function createProgram() {
2300
2550
  .addCommand(new Command("plan")
2301
2551
  .description("Plan an Oxygen search or scrape route before running provider jobs.")
2302
2552
  .requiredOption("--goal <file|text>", "Search/scrape goal text, or a local file path containing the goal.")
2303
- .option("--kind <kind>", "Route kind: people_search, signal_search, web_search, web_scrape, local_business_search, or source_specific_scrape. For company search use 'oxygen companies search plan'.")
2553
+ .option("--kind <kind>", "Route kind: signal_search, web_search, web_scrape, local_business_search, or source_specific_scrape. For people search use 'oxygen people search plan'; for company search use 'oxygen companies search plan'.")
2304
2554
  .option("--target-count <n>", "Optional target row count.")
2305
2555
  .option("--geography <text>", "Optional geography hint.")
2306
2556
  .option("--known-urls <urls>", "Comma-separated known URLs for scrape routes.")
@@ -2309,6 +2559,7 @@ export function createProgram() {
2309
2559
  .action(async (options) => {
2310
2560
  await handleAsyncAction("search plan", options, () => {
2311
2561
  assertNotCompanySearchKind(options.kind);
2562
+ assertNotPeopleSearchKind(options.kind);
2312
2563
  return requestOxygen("/api/cli/search/plan", {
2313
2564
  method: "POST",
2314
2565
  body: {
@@ -2423,7 +2674,7 @@ export function createProgram() {
2423
2674
  .description("Compile a company-search prompt into ordered provider routes without provider calls.")
2424
2675
  .requiredOption("--prompt <text-or-file>", "Company-search prompt, or a path to a prompt file.")
2425
2676
  .option("--target-count <n>", "Desired company count for routing and estimates.")
2426
- .option("--source-intent <intent>", "Override detected intent: sizing, structured, technology, hiring, local, known_source, concept, web, url, or fallback.")
2677
+ .option("--source-intent <intent>", "Override detected intent: sizing, structured, lookalike, technology, hiring, local, known_source, concept, web, url, or fallback.")
2427
2678
  .option("--filters-json <json-or-file>", "CompanySearchFilters JSON inline or a @file/path; wins over individual flags per top-level filter path.")
2428
2679
  .option("--industries <csv>", "Comma-separated industries to include.")
2429
2680
  .option("--exclude-industries <csv>", "Comma-separated industries to exclude.")
@@ -2436,7 +2687,7 @@ export function createProgram() {
2436
2687
  .option("--revenue <range>", "Annual revenue (USD) range: 1000000-20000000, 1000000+, or -5000000.")
2437
2688
  .option("--founded <range>", "Founded year range: 2015-2024, 2020+, or -2010.")
2438
2689
  .option("--lookalike <csv>", "Comma-separated lookalike company domains.")
2439
- .option("--estimate", "Run a free count probe for an estimated match count.")
2690
+ .option("--estimate", "Run a free server-side preflight pass: resolves provider enums and a free count probe for an estimated match count (zero credits).")
2440
2691
  .option("--materialize-preview", "Create a preview table with route rows.")
2441
2692
  .option("--json", "Print a JSON envelope.")
2442
2693
  .action(async (options) => {
@@ -2470,7 +2721,7 @@ export function createProgram() {
2470
2721
  .option("--revenue <range>", "Annual revenue (USD) range when planning from --prompt.")
2471
2722
  .option("--founded <range>", "Founded year range when planning from --prompt.")
2472
2723
  .option("--lookalike <csv>", "Comma-separated lookalike company domains when planning from --prompt.")
2473
- .option("--estimate", "Run a free count probe when planning from --prompt.")
2724
+ .option("--estimate", "Run a free server-side preflight pass when planning from --prompt: resolves provider enums and a free count probe (zero credits).")
2474
2725
  .option("--approved", "Required for live runs after inspecting dry-run output.")
2475
2726
  .option("--json", "Print a JSON envelope.")
2476
2727
  .action(async (options) => {
@@ -2493,7 +2744,7 @@ export function createProgram() {
2493
2744
  .option("--selection-json <json>", "Raw table action selection JSON.")
2494
2745
  .option("--json", "Print a JSON envelope.")
2495
2746
  .action(async (table, options) => {
2496
- await handleAsyncAction("companies enrich preview", options, async () => requestOxygen("/api/cli/company-enrichment/preview", {
2747
+ await handleAsyncAction("companies enrich preview", options, () => requestOxygen("/api/cli/company-enrichment/preview", {
2497
2748
  method: "POST",
2498
2749
  body: readCompaniesEnrichBody(table, options),
2499
2750
  }));
@@ -2513,7 +2764,7 @@ export function createProgram() {
2513
2764
  .option("--force", "Re-run the waterfall audit column even when it already has a value.")
2514
2765
  .option("--json", "Print a JSON envelope.")
2515
2766
  .action(async (table, options) => {
2516
- await handleAsyncAction("companies enrich run", options, async () => requestOxygen("/api/cli/company-enrichment/run", {
2767
+ await handleAsyncAction("companies enrich run", options, () => requestOxygen("/api/cli/company-enrichment/run", {
2517
2768
  method: "POST",
2518
2769
  body: readCompaniesEnrichBody(table, options),
2519
2770
  }));
@@ -2542,7 +2793,8 @@ export function createProgram() {
2542
2793
  .option("--require-email", "Only return people with a work email available (provider-dependent).")
2543
2794
  .option("--require-phone", "Only return people with a phone/mobile available (provider-dependent).")
2544
2795
  .option("--max-per-company <n>", "Cap on contacts per company for account-anchored searches.")
2545
- .option("--estimate", "Run a free count probe for an estimated match count.")
2796
+ .option("--estimate", "Run a free count probe for an estimated match count (on by default).")
2797
+ .option("--no-estimate", "Skip the default free count probe (people-search sizing is on by default).")
2546
2798
  .option("--materialize-preview", "Create a preview table with route rows.")
2547
2799
  .option("--json", "Print a JSON envelope.")
2548
2800
  .action(async (options) => {
@@ -2578,7 +2830,8 @@ export function createProgram() {
2578
2830
  .option("--require-email", "Only return people with a work email available when planning from --prompt.")
2579
2831
  .option("--require-phone", "Only return people with a phone available when planning from --prompt.")
2580
2832
  .option("--max-per-company <n>", "Cap on contacts per company when planning from --prompt.")
2581
- .option("--estimate", "Run a free count probe when planning from --prompt.")
2833
+ .option("--estimate", "Run a free count probe when planning from --prompt (on by default).")
2834
+ .option("--no-estimate", "Skip the default free count probe when planning from --prompt.")
2582
2835
  .option("--approved", "Required for live runs after inspecting dry-run output.")
2583
2836
  .option("--json", "Print a JSON envelope.")
2584
2837
  .action(async (options) => {
@@ -2594,15 +2847,15 @@ export function createProgram() {
2594
2847
  .description("Show background action and ingestion queue health.")
2595
2848
  .option("--json", "Print a JSON envelope.")
2596
2849
  .action(async (options) => {
2597
- await handleAsyncAction("worker queue-stats", options, async () => requestOxygen("/api/cli/worker/queue-stats"));
2850
+ await handleAsyncAction("worker queue-stats", options, () => requestOxygen("/api/cli/worker/queue-stats"));
2598
2851
  }))
2599
2852
  .addCommand(new Command("failures")
2600
2853
  .description("List failed background action and ingestion items.")
2601
- .option("--queue <queue>", "all, actions, ingestions, or postgres_jobs. Defaults to all. Legacy aliases: bullmq, redis, jobs.")
2854
+ .option("--queue <queue>", "all, actions, ingestions, or postgres_queue. Defaults to all. Legacy aliases: postgres_jobs, bullmq, redis, jobs.")
2602
2855
  .option("--limit <n>", "Maximum failed items per queue. Defaults to 25; server cap is 100.")
2603
2856
  .option("--json", "Print a JSON envelope.")
2604
2857
  .action(async (options) => {
2605
- await handleAsyncAction("worker failures", options, async () => {
2858
+ await handleAsyncAction("worker failures", options, () => {
2606
2859
  const query = new URLSearchParams();
2607
2860
  if (readOption(options.queue))
2608
2861
  query.set("queue", readOption(options.queue) ?? "");
@@ -2617,7 +2870,7 @@ export function createProgram() {
2617
2870
  .description("Repair stale background action, ingestion, and workflow queue state.")
2618
2871
  .option("--json", "Print a JSON envelope.")
2619
2872
  .action(async (options) => {
2620
- await handleAsyncAction("worker repair", options, async () => requestOxygen("/api/cli/worker/repair", {
2873
+ await handleAsyncAction("worker repair", options, () => requestOxygen("/api/cli/worker/repair", {
2621
2874
  method: "POST",
2622
2875
  body: {},
2623
2876
  }));
@@ -2632,22 +2885,13 @@ export function createProgram() {
2632
2885
  .option("--recipe-timeout-ms <n>", "Compatibility option; ignored by the Postgres worker.")
2633
2886
  .option("--json", "Print a JSON envelope.")
2634
2887
  .action(async (options) => {
2635
- const claimLimit = readPositiveInt(options.claimLimit);
2636
- const concurrency = readPositiveInt(options.concurrency);
2637
- const enrichmentConcurrency = readPositiveInt(options.enrichmentConcurrency);
2638
- const leaseSeconds = readPositiveInt(options.leaseSeconds);
2639
- const providerTimeoutMs = readPositiveInt(options.providerTimeoutMs);
2640
- const recipeTimeoutMs = readPositiveInt(options.recipeTimeoutMs);
2641
- await handleAsyncAction("worker run-once", options, async () => requestOxygen("/api/cli/worker/run-once", {
2888
+ // The legacy tuning options are accepted for backward compatibility but
2889
+ // intentionally ignored: the Postgres worker has no BullMQ knobs, and the
2890
+ // route runs repairTenantWorkspaceQueues regardless of the request body.
2891
+ // Send an empty body so we stop modeling ignored knobs as meaningful fields.
2892
+ await handleAsyncAction("worker run-once", options, () => requestOxygen("/api/cli/worker/run-once", {
2642
2893
  method: "POST",
2643
- body: {
2644
- ...(claimLimit ? { claim_limit: claimLimit } : {}),
2645
- ...(concurrency ? { concurrency } : {}),
2646
- ...(enrichmentConcurrency ? { enrichment_concurrency: enrichmentConcurrency } : {}),
2647
- ...(leaseSeconds ? { lease_seconds: leaseSeconds } : {}),
2648
- ...(providerTimeoutMs ? { provider_timeout_ms: providerTimeoutMs } : {}),
2649
- ...(recipeTimeoutMs ? { recipe_timeout_ms: recipeTimeoutMs } : {}),
2650
- },
2894
+ body: {},
2651
2895
  }));
2652
2896
  }));
2653
2897
  program
@@ -2657,7 +2901,7 @@ export function createProgram() {
2657
2901
  .description("Show the current plan and managed credit balance.")
2658
2902
  .option("--json", "Print a JSON envelope.")
2659
2903
  .action(async (options) => {
2660
- await handleAsyncAction("billing balance", options, async () => requestOxygen("/api/cli/billing/balance"));
2904
+ await handleAsyncAction("billing balance", options, () => requestOxygen("/api/cli/billing/balance"));
2661
2905
  }))
2662
2906
  .addCommand(new Command("usage")
2663
2907
  .description("Show credit ledger events.")
@@ -2675,7 +2919,7 @@ export function createProgram() {
2675
2919
  .option("--nonzero", "Only include events that changed available or reserved credits.")
2676
2920
  .option("--json", "Print a JSON envelope.")
2677
2921
  .action(async (options) => {
2678
- await handleAsyncAction("billing usage", options, async () => {
2922
+ await handleAsyncAction("billing usage", options, () => {
2679
2923
  const params = buildBillingLedgerParams(options);
2680
2924
  const suffix = params.toString() ? `?${params.toString()}` : "";
2681
2925
  return requestOxygen(`/api/cli/billing/usage${suffix}`);
@@ -2697,7 +2941,7 @@ export function createProgram() {
2697
2941
  .option("--nonzero", "Only include events that changed available or reserved credits. Default for audit except BYOK filters.")
2698
2942
  .option("--json", "Print a JSON envelope.")
2699
2943
  .action(async (options) => {
2700
- await handleAsyncAction("billing audit", options, async () => {
2944
+ await handleAsyncAction("billing audit", options, () => {
2701
2945
  const params = buildBillingLedgerParams(options);
2702
2946
  if (readOption(options.groupBy))
2703
2947
  params.set("group_by", readOption(options.groupBy));
@@ -2714,7 +2958,7 @@ export function createProgram() {
2714
2958
  .option("--description <text>", "Ledger description for the admin grant.")
2715
2959
  .option("--json", "Print a JSON envelope.")
2716
2960
  .action(async (options) => {
2717
- await handleAsyncAction("billing grant", options, async () => requestOxygen("/api/cli/billing/grant", {
2961
+ await handleAsyncAction("billing grant", options, () => requestOxygen("/api/cli/billing/grant", {
2718
2962
  method: "POST",
2719
2963
  body: {
2720
2964
  credits: readPositiveNumber(options.credits),
@@ -2726,6 +2970,54 @@ export function createProgram() {
2726
2970
  },
2727
2971
  }));
2728
2972
  }));
2973
+ program
2974
+ .command("budget")
2975
+ .description("Standing credit caps (org/table daily/monthly hard-blocks) beyond per-run max_credits.")
2976
+ .addCommand(new Command("list")
2977
+ .description("List the organization's standing budget policies.")
2978
+ .option("--scope <scope>", "Filter by scope: org, table, api_key, workflow_trigger, monitor.")
2979
+ .option("--status <status>", "Filter by status. Defaults to all.")
2980
+ .option("--json", "Print a JSON envelope.")
2981
+ .action(async (options) => {
2982
+ await handleAsyncAction("budget list", options, () => {
2983
+ const params = new URLSearchParams();
2984
+ if (readOption(options.scope))
2985
+ params.set("scope", readOption(options.scope));
2986
+ if (readOption(options.status))
2987
+ params.set("status", readOption(options.status));
2988
+ const suffix = params.toString() ? `?${params.toString()}` : "";
2989
+ return requestOxygen(`/api/cli/budget${suffix}`);
2990
+ });
2991
+ }))
2992
+ .addCommand(new Command("set")
2993
+ .description("Set, raise, or lower a standing credit cap (idempotent per scope+window).")
2994
+ .requiredOption("--scope <scope>", "org, table, api_key, workflow_trigger, or monitor.")
2995
+ .requiredOption("--window <window>", "per_run, daily, or monthly.")
2996
+ .requiredOption("--max-credits <credits>", "Cap in Oxygen credits.")
2997
+ .option("--scope-id <id>", "Table id/slug for --scope table (or other scope id). Omit for a scope-wide cap.")
2998
+ .option("--action <action>", "warn or hard_block. Defaults to hard_block.")
2999
+ .option("--json", "Print a JSON envelope.")
3000
+ .action(async (options) => {
3001
+ await handleAsyncAction("budget set", options, () => requestOxygen("/api/cli/budget", {
3002
+ method: "POST",
3003
+ body: {
3004
+ scope: readOption(options.scope),
3005
+ window: readOption(options.window),
3006
+ max_credits: readPositiveNumber(options.maxCredits),
3007
+ ...(readOption(options.scopeId) ? { scope_id: readOption(options.scopeId) } : {}),
3008
+ ...(readOption(options.action) ? { action: readOption(options.action) } : {}),
3009
+ },
3010
+ }));
3011
+ }))
3012
+ .addCommand(new Command("delete")
3013
+ .description("Delete a standing budget policy by id.")
3014
+ .requiredOption("--id <id>", "Budget policy id.")
3015
+ .option("--json", "Print a JSON envelope.")
3016
+ .action(async (options) => {
3017
+ await handleAsyncAction("budget delete", options, () => requestOxygen(`/api/cli/budget?id=${encodeURIComponent(readOption(options.id))}`, {
3018
+ method: "DELETE",
3019
+ }));
3020
+ }));
2729
3021
  program
2730
3022
  .command("admin")
2731
3023
  .description("Staff-only commands.")
@@ -2735,7 +3027,7 @@ export function createProgram() {
2735
3027
  .option("--credential-mode <mode>", "managed (default; real COGS), byok (customer-supplied credentials), or all.", "managed")
2736
3028
  .option("--json", "Print a JSON envelope.")
2737
3029
  .action(async (options) => {
2738
- await handleAsyncAction("admin costs", options, async () => {
3030
+ await handleAsyncAction("admin costs", options, () => {
2739
3031
  const params = new URLSearchParams();
2740
3032
  const top = readPositiveInt(options.top);
2741
3033
  if (top)
@@ -2803,7 +3095,7 @@ export function createProgram() {
2803
3095
  .option("--limit <n>", "Maximum events to return. Defaults to 50.")
2804
3096
  .option("--json", "Print a JSON envelope.")
2805
3097
  .action(async (options) => {
2806
- await handleAsyncAction("observability events", options, async () => {
3098
+ await handleAsyncAction("observability events", options, () => {
2807
3099
  const params = new URLSearchParams();
2808
3100
  const status = readOption(options.status);
2809
3101
  const traceId = readOption(options.traceId);
@@ -2829,7 +3121,7 @@ export function createProgram() {
2829
3121
  .option("--limit <n>", "Maximum runs to return. Defaults to 50.")
2830
3122
  .option("--json", "Print a JSON envelope.")
2831
3123
  .action(async (options) => {
2832
- await handleAsyncAction("runs list", options, async () => {
3124
+ await handleAsyncAction("runs list", options, () => {
2833
3125
  const limit = readPositiveInt(options.limit);
2834
3126
  return requestOxygen(`/api/cli/runs${limit ? `?limit=${limit}` : ""}`);
2835
3127
  });
@@ -2839,7 +3131,7 @@ export function createProgram() {
2839
3131
  .argument("<run_id>", "Run UUID.")
2840
3132
  .option("--json", "Print a JSON envelope.")
2841
3133
  .action(async (runId, options) => {
2842
- await handleAsyncAction("runs get", options, async () => requestOxygen("/api/cli/runs/get", {
3134
+ await handleAsyncAction("runs get", options, () => requestOxygen("/api/cli/runs/get", {
2843
3135
  method: "POST",
2844
3136
  body: { run_id: runId },
2845
3137
  }));
@@ -2854,7 +3146,7 @@ export function createProgram() {
2854
3146
  .option("--limit <n>", "Maximum changes to return. Defaults to 50.")
2855
3147
  .option("--json", "Print a JSON envelope.")
2856
3148
  .action(async (table, rowId, options) => {
2857
- await handleAsyncAction("rows history", options, async () => {
3149
+ await handleAsyncAction("rows history", options, () => {
2858
3150
  const limit = readPositiveInt(options.limit);
2859
3151
  return requestOxygen("/api/cli/tables/rows/history", {
2860
3152
  method: "POST",
@@ -2877,7 +3169,7 @@ export function createProgram() {
2877
3169
  .option("--history-limit <n>", "Maximum recent cell changes to include. Defaults to 10; cap 50.")
2878
3170
  .option("--json", "Print a JSON envelope.")
2879
3171
  .action(async (table, rowId, column, options) => {
2880
- await handleAsyncAction("cells inspect", options, async () => {
3172
+ await handleAsyncAction("cells inspect", options, () => {
2881
3173
  const historyLimit = readPositiveInt(options.historyLimit);
2882
3174
  return requestOxygen("/api/cli/tables/cells/inspect", {
2883
3175
  method: "POST",
@@ -2898,7 +3190,7 @@ export function createProgram() {
2898
3190
  .option("--limit <n>", "Maximum changes to return. Defaults to 50.")
2899
3191
  .option("--json", "Print a JSON envelope.")
2900
3192
  .action(async (table, rowId, column, options) => {
2901
- await handleAsyncAction("cells history", options, async () => {
3193
+ await handleAsyncAction("cells history", options, () => {
2902
3194
  const limit = readPositiveInt(options.limit);
2903
3195
  return requestOxygen("/api/cli/tables/cells/history", {
2904
3196
  method: "POST",
@@ -2923,6 +3215,7 @@ export function createProgram() {
2923
3215
  .option("--session-id <id>", "Session id to update. Defaults to the current session.")
2924
3216
  .option("--json", "Print a JSON envelope.")
2925
3217
  .action(async (options) => {
3218
+ // skipcq: JS-0116 — async Promise-wraps the synchronous session helpers to satisfy handleAsyncAction's Promise<unknown> action
2926
3219
  await handleAsyncAction("session start", options, async () => {
2927
3220
  const updateIndex = readNonNegativeInt(options.update);
2928
3221
  if (updateIndex !== undefined) {
@@ -2950,6 +3243,7 @@ export function createProgram() {
2950
3243
  .option("--session-id <id>", "Session id. Defaults to the current session.")
2951
3244
  .option("--json", "Print a JSON envelope.")
2952
3245
  .action(async (options) => {
3246
+ // skipcq: JS-0116 — async Promise-wraps the synchronous addSessionStatus helper to satisfy handleAsyncAction's Promise<unknown> action
2953
3247
  await handleAsyncAction("session status", options, async () => addSessionStatus({
2954
3248
  sessionId: readOption(options.sessionId),
2955
3249
  message: options.message,
@@ -2966,6 +3260,7 @@ export function createProgram() {
2966
3260
  .option("--session-id <id>", "Session id. Defaults to the current session.")
2967
3261
  .option("--json", "Print a JSON envelope.")
2968
3262
  .action(async (options) => {
3263
+ // skipcq: JS-0116 — async Promise-wraps the synchronous addSessionOutput helper to satisfy handleAsyncAction's Promise<unknown> action
2969
3264
  await handleAsyncAction("session output", options, async () => addSessionOutput({
2970
3265
  sessionId: readOption(options.sessionId),
2971
3266
  csv: readOption(options.csv),
@@ -2980,6 +3275,7 @@ export function createProgram() {
2980
3275
  .option("--session-id <id>", "Session id. Defaults to the current session.")
2981
3276
  .option("--json", "Print a JSON envelope.")
2982
3277
  .action(async (options) => {
3278
+ // skipcq: JS-0116 — async Promise-wraps the synchronous getSessionUsage helper to satisfy handleAsyncAction's Promise<unknown> action
2983
3279
  await handleAsyncAction("session usage", options, async () => getSessionUsage({ sessionId: readOption(options.sessionId) }));
2984
3280
  }));
2985
3281
  program
@@ -3049,7 +3345,7 @@ export function createProgram() {
3049
3345
  .argument("<tool_id>", "Tool id.")
3050
3346
  .option("--json", "Print a JSON envelope.")
3051
3347
  .action(async (toolId, options) => {
3052
- await handleAsyncAction("tools get", options, async () => requestOxygen(`/api/cli/tools/${encodeURIComponent(toolId)}`));
3348
+ await handleAsyncAction("tools get", options, () => requestOxygen(`/api/cli/tools/${encodeURIComponent(toolId)}`));
3053
3349
  }))
3054
3350
  .addCommand(new Command("enums")
3055
3351
  .description("Provider enum catalogs for fields that accept normalized values.")
@@ -3147,6 +3443,7 @@ export function createProgram() {
3147
3443
  .option("--email-pattern-validation <mode>", "Work-email pattern pre-step: leadmagic_valid_only or disabled.")
3148
3444
  .option("--phone-waterfall-profile <profile>", "Phone waterfall profile: auto (input-aware), linkedin_url, email, or name_domain. Auto picks the cheapest cost-ordered provider set for each row's inputs (mobile match rate ~30-60%).")
3149
3445
  .option("--verify-phone", "Validate found phone numbers with ClearoutPhone (adds phone_line_type + phone_carrier; filter phone_line_type=mobile for mobile-only).")
3446
+ .option("--allow-premium-lanes", "Opt in to premium high-cost lanes (e.g. >25cr/row phone reveal). Off by default: premium lanes are skipped so an unattended run can't bill-shock.")
3150
3447
  .option("--phone-verification-credential-mode <mode>", "ClearoutPhone credential mode for phone verification: managed or user_api_key.")
3151
3448
  .option("--limit <n>", "Rows to estimate. Defaults to 10.")
3152
3449
  .option("--all", "Estimate all rows.")
@@ -3155,7 +3452,7 @@ export function createProgram() {
3155
3452
  .option("--only-missing", "Estimate rows missing the capability's normalized output when no explicit selection is passed.")
3156
3453
  .option("--json", "Print a JSON envelope.")
3157
3454
  .action(async (table, options) => {
3158
- await handleAsyncAction("enrich-column preview", options, async () => requestOxygen("/api/cli/enrich-column/preview", {
3455
+ await handleAsyncAction("enrich-column preview", options, () => requestOxygen("/api/cli/enrich-column/preview", {
3159
3456
  method: "POST",
3160
3457
  body: buildEnrichColumnBody(table, options),
3161
3458
  }));
@@ -3181,6 +3478,7 @@ export function createProgram() {
3181
3478
  .option("--email-pattern-validation <mode>", "Work-email pattern pre-step: leadmagic_valid_only or disabled.")
3182
3479
  .option("--phone-waterfall-profile <profile>", "Phone waterfall profile: auto (input-aware), linkedin_url, email, or name_domain. Auto picks the cheapest cost-ordered provider set for each row's inputs (mobile match rate ~30-60%).")
3183
3480
  .option("--verify-phone", "Validate found phone numbers with ClearoutPhone (adds phone_line_type + phone_carrier; filter phone_line_type=mobile for mobile-only).")
3481
+ .option("--allow-premium-lanes", "Opt in to premium high-cost lanes (e.g. >25cr/row phone reveal). Off by default: premium lanes are skipped so an unattended run can't bill-shock.")
3184
3482
  .option("--phone-verification-credential-mode <mode>", "ClearoutPhone credential mode for phone verification: managed or user_api_key.")
3185
3483
  .option("--limit <n>", "Rows to queue.")
3186
3484
  .option("--all", "Queue all rows.")
@@ -3192,7 +3490,7 @@ export function createProgram() {
3192
3490
  .option("--json", "Print a JSON envelope.")
3193
3491
  .action(async (table, options) => {
3194
3492
  const maxConcurrency = readPositiveInt(options.maxConcurrency);
3195
- await handleAsyncAction("enrich-column run", options, async () => requestOxygen("/api/cli/enrich-column/run", {
3493
+ await handleAsyncAction("enrich-column run", options, () => requestOxygen("/api/cli/enrich-column/run", {
3196
3494
  method: "POST",
3197
3495
  body: {
3198
3496
  ...buildEnrichColumnBody(table, options),
@@ -3202,6 +3500,62 @@ export function createProgram() {
3202
3500
  },
3203
3501
  }));
3204
3502
  }));
3503
+ program
3504
+ .command("find")
3505
+ .description("One-shot contact and company lookups over enrichment waterfalls (no table). dry_run previews for free; live spends and needs --max-credits.")
3506
+ .addCommand(new Command("email")
3507
+ .description("Find a person's work email via an input-aware multi-provider waterfall: a LinkedIn URL, name+domain, or first/last+domain each select the best provider profile, with an email-pattern pre-step and verification. dry_run shows the resolved profile + chain for free.")
3508
+ .option("--linkedin-url <url>", "Person LinkedIn profile URL (strongest signal).")
3509
+ .option("--full-name <name>", "Person full name.")
3510
+ .option("--first-name <name>", "Person first name.")
3511
+ .option("--last-name <name>", "Person last name.")
3512
+ .option("--company-domain <domain>", "Company apex domain, e.g. acme.com.")
3513
+ .option("--company-name <name>", "Company name.")
3514
+ .option("--mode <mode>", "dry_run (default) or live. live spends credits.")
3515
+ .option("--max-credits <credits>", "Spend ceiling. Required for --mode live.")
3516
+ .option("--json", "Print a JSON envelope.")
3517
+ .action(async (options) => {
3518
+ await handleAsyncAction("find email", options, () => requestOxygen("/api/cli/find/run", { method: "POST", body: buildFindBody("email", options) }));
3519
+ }))
3520
+ .addCommand(new Command("phone")
3521
+ .description("Find a person's mobile phone via a multi-provider waterfall. The provider chain is input-aware: a LinkedIn URL unlocks the full cost-ordered chain (Blitz first), email/name use the providers that accept them.")
3522
+ .option("--linkedin-url <url>", "Person LinkedIn profile URL (strongest signal for phone).")
3523
+ .option("--email <email>", "Known email as an identity fallback.")
3524
+ .option("--full-name <name>", "Person full name.")
3525
+ .option("--company-domain <domain>", "Company apex domain.")
3526
+ .option("--company-name <name>", "Company name.")
3527
+ .option("--verify", "Verify the found number with ClearoutPhone — adds line_type/carrier and keeps the number on a non-verdict (only a genuine 'not valid' is discarded).")
3528
+ .option("--mode <mode>", "dry_run (default) or live. live spends credits.")
3529
+ .option("--max-credits <credits>", "Spend ceiling. Required for --mode live.")
3530
+ .option("--json", "Print a JSON envelope.")
3531
+ .action(async (options) => {
3532
+ await handleAsyncAction("find phone", options, () => requestOxygen("/api/cli/find/run", { method: "POST", body: buildFindBody("phone", options) }));
3533
+ }))
3534
+ .addCommand(new Command("linkedin")
3535
+ .description("Resolve a person's LinkedIn URL from name + company (or email), with identity validation.")
3536
+ .option("--full-name <name>", "Person full name.")
3537
+ .option("--email <email>", "Known email.")
3538
+ .option("--company-domain <domain>", "Company apex domain.")
3539
+ .option("--company-name <name>", "Company name.")
3540
+ .option("--company-linkedin-url <url>", "Company LinkedIn URL.")
3541
+ .option("--mode <mode>", "dry_run (default) or live. live spends credits.")
3542
+ .option("--max-credits <credits>", "Spend ceiling. Required for --mode live.")
3543
+ .option("--json", "Print a JSON envelope.")
3544
+ .action(async (options) => {
3545
+ await handleAsyncAction("find linkedin", options, () => requestOxygen("/api/cli/find/run", { method: "POST", body: buildFindBody("linkedin", options) }));
3546
+ }))
3547
+ .addCommand(new Command("company")
3548
+ .description("Enrich a company (domain/linkedin/headcount/industry/funding/tech/hiring/profile) via a waterfall.")
3549
+ .option("--domain <domain>", "Company apex domain.")
3550
+ .option("--name <name>", "Company name.")
3551
+ .option("--linkedin-url <url>", "Company LinkedIn URL.")
3552
+ .option("--fields <fields>", "Comma-separated company fields. Defaults to domain,linkedin_url,headcount,industry.")
3553
+ .option("--mode <mode>", "dry_run (default) or live. live spends credits.")
3554
+ .option("--max-credits <credits>", "Spend ceiling. Required for --mode live.")
3555
+ .option("--json", "Print a JSON envelope.")
3556
+ .action(async (options) => {
3557
+ await handleAsyncAction("find company", options, () => requestOxygen("/api/cli/find/run", { method: "POST", body: buildFindBody("company", options) }));
3558
+ }));
3205
3559
  program
3206
3560
  .command("enrichment")
3207
3561
  .description("High-level enrichment column definition helpers.")
@@ -3233,7 +3587,7 @@ export function createProgram() {
3233
3587
  .option("--toolkit <id>", "Filter by toolkit / integration id, such as gmail.")
3234
3588
  .option("--json", "Print a JSON envelope.")
3235
3589
  .action(async (options) => {
3236
- await handleAsyncAction("integrations events list", options, async () => {
3590
+ await handleAsyncAction("integrations events list", options, () => {
3237
3591
  const query = new URLSearchParams();
3238
3592
  if (readOption(options.source))
3239
3593
  query.set("source", readOption(options.source) ?? "");
@@ -3253,22 +3607,17 @@ export function createProgram() {
3253
3607
  .option("--trigger-config <json>", "JSON object passed to the provider when registering the trigger (Composio triggers only).")
3254
3608
  .option("--json", "Print a JSON envelope.")
3255
3609
  .action(async (options) => {
3256
- await handleAsyncAction("integrations events enable", options, async () => {
3610
+ await handleAsyncAction("integrations events enable", options, () => {
3257
3611
  const triggerConfigRaw = readOption(options.triggerConfig);
3258
3612
  let triggerConfig;
3259
3613
  if (triggerConfigRaw) {
3260
- try {
3261
- const parsed = JSON.parse(triggerConfigRaw);
3262
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
3263
- throw new Error("--trigger-config must be a JSON object.");
3264
- }
3265
- triggerConfig = parsed;
3266
- }
3267
- catch (error) {
3268
- throw new Error(error instanceof Error
3269
- ? `Invalid --trigger-config: ${error.message}`
3270
- : "Invalid --trigger-config");
3614
+ const parsed = parseJsonValue(triggerConfigRaw, "--trigger-config");
3615
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
3616
+ throw new OxygenError("invalid_request", "--trigger-config must be a JSON object.", {
3617
+ exitCode: 1,
3618
+ });
3271
3619
  }
3620
+ triggerConfig = parsed;
3272
3621
  }
3273
3622
  return requestOxygen("/api/cli/integrations/events/enable", {
3274
3623
  method: "POST",
@@ -3288,7 +3637,7 @@ export function createProgram() {
3288
3637
  .option("--connection-id <connection_id>", "Specific integration connection id. Defaults to the active default connection.")
3289
3638
  .option("--json", "Print a JSON envelope.")
3290
3639
  .action(async (options) => {
3291
- await handleAsyncAction("integrations events disable", options, async () => requestOxygen("/api/cli/integrations/events/disable", {
3640
+ await handleAsyncAction("integrations events disable", options, () => requestOxygen("/api/cli/integrations/events/disable", {
3292
3641
  method: "POST",
3293
3642
  body: {
3294
3643
  source: readOption(options.source),
@@ -3328,21 +3677,24 @@ export function createProgram() {
3328
3677
  .description("List supported integrations and this org's connections.")
3329
3678
  .option("--json", "Print a JSON envelope.")
3330
3679
  .action(async (options) => {
3331
- await handleAsyncAction("integrations list", options, async () => requestOxygen("/api/cli/integrations/composio/list"));
3680
+ await handleAsyncAction("integrations list", options, () => requestOxygen("/api/cli/integrations/composio/list"));
3332
3681
  }))
3333
3682
  .addCommand(new Command("connect")
3334
3683
  .description("Connect an integration. OAuth toolkits return a redirect URL; API-key integrations accept --api-key.")
3335
3684
  .argument("<integration_id>", "Integration id, such as 'slack' or 'serpapi'.")
3336
3685
  .option("--api-key <value>", "API key for Composio API-key toolkits (e.g. SerpAPI, Resend).")
3686
+ .option("--account-id <id>", "Provider account id for integrations that span multiple accounts (e.g. Cloudflare when the token can access more than one account).")
3337
3687
  .option("--json", "Print a JSON envelope.")
3338
3688
  .action(async (integrationId, options) => {
3339
- await handleAsyncAction("integrations connect", options, async () => {
3689
+ await handleAsyncAction("integrations connect", options, () => {
3340
3690
  const apiKey = readOption(options.apiKey)?.trim();
3691
+ const accountId = readOption(options.accountId);
3341
3692
  return requestOxygen("/api/cli/integrations/connect", {
3342
3693
  method: "POST",
3343
3694
  body: {
3344
3695
  integration_id: integrationId,
3345
3696
  ...(apiKey ? { api_key: apiKey } : {}),
3697
+ ...(accountId ? { account_id: accountId } : {}),
3346
3698
  },
3347
3699
  });
3348
3700
  });
@@ -3367,7 +3719,7 @@ export function createProgram() {
3367
3719
  .argument("<integration_id>", "Integration id, such as 'slack'.")
3368
3720
  .option("--json", "Print a JSON envelope.")
3369
3721
  .action(async (integrationId, options) => {
3370
- await handleAsyncAction("integrations actions", options, async () => {
3722
+ await handleAsyncAction("integrations actions", options, () => {
3371
3723
  const params = new URLSearchParams({ integration_id: integrationId });
3372
3724
  return requestOxygen(`/api/cli/integrations/composio/actions?${params.toString()}`);
3373
3725
  });
@@ -3382,7 +3734,7 @@ export function createProgram() {
3382
3734
  .option("--mode <mode>", "'live' or 'dry_run'. Overridden by --live/--dry-run if provided.")
3383
3735
  .option("--json", "Print a JSON envelope.")
3384
3736
  .action(async (integrationId, actionSlug, options) => {
3385
- await handleAsyncAction("integrations run", options, async () => {
3737
+ await handleAsyncAction("integrations run", options, () => {
3386
3738
  const args = readOption(options.input)
3387
3739
  ? parseJsonObject(readOption(options.input))
3388
3740
  : {};
@@ -3437,7 +3789,7 @@ export function createProgram() {
3437
3789
  .argument("<id>", "Sender account id, connection id, or Unipile account id.")
3438
3790
  .option("--json", "Print a JSON envelope.")
3439
3791
  .action(async (id, options) => {
3440
- await handleAsyncAction("senders get", options, async () => requestOxygen(`/api/cli/senders/${encodeURIComponent(id)}`));
3792
+ await handleAsyncAction("senders get", options, () => requestOxygen(`/api/cli/senders/${encodeURIComponent(id)}`));
3441
3793
  }))
3442
3794
  .addCommand(new Command("sync")
3443
3795
  .description("Refresh LinkedIn account state (status, profile) from Unipile. Pass --connection-id to sync one account, or omit it to sync all.")
@@ -3459,7 +3811,7 @@ export function createProgram() {
3459
3811
  .argument("<id>", "Sender account id, connection id, or Unipile account id.")
3460
3812
  .option("--json", "Print a JSON envelope.")
3461
3813
  .action(async (id, options) => {
3462
- await handleAsyncAction("senders disconnect", options, async () => requestOxygen(`/api/cli/senders/${encodeURIComponent(id)}/disconnect`, {
3814
+ await handleAsyncAction("senders disconnect", options, () => requestOxygen(`/api/cli/senders/${encodeURIComponent(id)}/disconnect`, {
3463
3815
  method: "POST",
3464
3816
  }));
3465
3817
  }))
@@ -3471,7 +3823,7 @@ export function createProgram() {
3471
3823
  .argument("<id>", "Sender account id, connection id, or Unipile account id.")
3472
3824
  .option("--json", "Print a JSON envelope.")
3473
3825
  .action(async (id, options) => {
3474
- await handleAsyncAction("senders limits get", options, async () => requestOxygen(`/api/cli/senders/${encodeURIComponent(id)}/limits`));
3826
+ await handleAsyncAction("senders limits get", options, () => requestOxygen(`/api/cli/senders/${encodeURIComponent(id)}/limits`));
3475
3827
  }))
3476
3828
  .addCommand(new Command("set")
3477
3829
  .description("Adjust per-account daily action limits and the daily-reset timezone. Values are clamped to safe maximums (e.g. max 80 invites/day). Send windows (time of day) are set per sequence in the campaign schedule, not per account. <id> accepts a sender account id, connection id, or Unipile account id.")
@@ -3556,19 +3908,24 @@ export function createProgram() {
3556
3908
  });
3557
3909
  }))
3558
3910
  .addCommand(new Command("send")
3559
- .description("Reply into a LinkedIn conversation. Sends a real LinkedIn message — requires --approved. Without it, returns a preview.")
3560
- .argument("<conversation>", "Conversation id or Unipile chat id.")
3911
+ .description("Reply into a conversation. --channel email replies from the conversation's own mailbox (Zapbox/native Gmail/Graph, threaded); default replies into the LinkedIn conversation. Sends a real message — requires --approved. Without it, returns a preview.")
3912
+ .argument("<conversation>", "Conversation id, Unipile chat id, or (email) Zapbox thread id.")
3561
3913
  .requiredOption("--text <message>", "Reply text to send.")
3914
+ .option("--channel <channel>", "Inbox channel: linkedin (default) or email.")
3562
3915
  .option("--approved", "Approve and send the message. Without this flag, returns a preview only.")
3563
3916
  .option("--json", "Print a JSON envelope.")
3564
3917
  .action(async (conversation, options) => {
3565
- await handleAsyncAction("inbox send", options, () => requestOxygen(`/api/cli/inbox/${encodeURIComponent(conversation)}/send`, {
3566
- method: "POST",
3567
- body: {
3568
- text: readOption(options.text),
3569
- ...(options.approved ? { approved: true } : {}),
3570
- },
3571
- }));
3918
+ await handleAsyncAction("inbox send", options, () => {
3919
+ const channel = readOption(options.channel);
3920
+ return requestOxygen(`/api/cli/inbox/${encodeURIComponent(conversation)}/send`, {
3921
+ method: "POST",
3922
+ body: {
3923
+ text: readOption(options.text),
3924
+ ...(channel ? { channel } : {}),
3925
+ ...(options.approved ? { approved: true } : {}),
3926
+ },
3927
+ });
3928
+ });
3572
3929
  }))
3573
3930
  .addCommand(new Command("mark-read")
3574
3931
  .description("Mark a conversation and all its messages as read.")
@@ -3578,6 +3935,27 @@ export function createProgram() {
3578
3935
  await handleAsyncAction("inbox mark-read", options, () => requestOxygen(`/api/cli/inbox/${encodeURIComponent(conversation)}/read`, {
3579
3936
  method: "POST",
3580
3937
  }));
3938
+ }))
3939
+ .addCommand(new Command("analyze")
3940
+ .description("Run the default analysis on an email conversation now: sentiment + interest category + a drafted reply (Oxygen's default model). The worker also does this automatically on inbound mail.")
3941
+ .argument("<conversation>", "Conversation id or Zapbox thread id.")
3942
+ .option("--json", "Print a JSON envelope.")
3943
+ .action(async (conversation, options) => {
3944
+ await handleAsyncAction("inbox analyze", options, () => requestOxygen(`/api/cli/inbox/${encodeURIComponent(conversation)}/analyze`, {
3945
+ method: "POST",
3946
+ body: { channel: "email" },
3947
+ }));
3948
+ }))
3949
+ .addCommand(new Command("archive")
3950
+ .description("Archive an email conversation (triage it out of the inbox). --unarchive restores it.")
3951
+ .argument("<conversation>", "Conversation id or Zapbox thread id.")
3952
+ .option("--unarchive", "Restore the conversation instead of archiving it.")
3953
+ .option("--json", "Print a JSON envelope.")
3954
+ .action(async (conversation, options) => {
3955
+ await handleAsyncAction("inbox archive", options, () => requestOxygen(`/api/cli/inbox/${encodeURIComponent(conversation)}/archive`, {
3956
+ method: "POST",
3957
+ body: { channel: "email", archived: !options.unarchive },
3958
+ }));
3581
3959
  }))
3582
3960
  .addCommand(new Command("sync")
3583
3961
  .description("Force a backstop inbox sync from Unipile for all active sender accounts (pulls recent chats + messages into the unibox).")
@@ -3661,8 +4039,7 @@ export function createProgram() {
3661
4039
  const stepsPath = readOption(options.stepsFile);
3662
4040
  if (!stepsPath)
3663
4041
  throw new Error("--steps-file is required.");
3664
- const raw = readFileSync(resolve(stepsPath), "utf8");
3665
- const definition = JSON.parse(raw);
4042
+ const definition = readJsonFileValue(resolve(stepsPath), "--steps-file");
3666
4043
  const channels = readCsvOption(options.channels);
3667
4044
  const senders = readCsvOption(options.senders);
3668
4045
  const email = readCampaignEmailBinding(options);
@@ -3704,7 +4081,7 @@ export function createProgram() {
3704
4081
  body.name = name;
3705
4082
  const stepsPath = readOption(options.stepsFile);
3706
4083
  if (stepsPath) {
3707
- body.definition = JSON.parse(readFileSync(resolve(stepsPath), "utf8"));
4084
+ body.definition = readJsonFileValue(resolve(stepsPath), "--steps-file");
3708
4085
  }
3709
4086
  const channels = readCsvOption(options.channels);
3710
4087
  if (channels.length > 0)
@@ -3749,7 +4126,7 @@ export function createProgram() {
3749
4126
  const leadsPath = readOption(options.leadsFile);
3750
4127
  if (!leadsPath)
3751
4128
  throw new Error("--leads-file is required.");
3752
- const parsed = JSON.parse(readFileSync(resolve(leadsPath), "utf8"));
4129
+ const parsed = readJsonFileValue(resolve(leadsPath), "--leads-file");
3753
4130
  return requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/enroll`, {
3754
4131
  method: "POST",
3755
4132
  body: { leads: parsed.leads ?? [] },
@@ -3901,7 +4278,7 @@ export function createProgram() {
3901
4278
  throw new Error("--provider only applies with --from zapmail (inline files carry a per-mailbox provider).");
3902
4279
  if (!filePath)
3903
4280
  throw new Error("Provide --file <path> (a { \"mailboxes\": [...] } JSON file) or --from zapmail.");
3904
- const parsed = JSON.parse(readFileSync(resolve(filePath), "utf8"));
4281
+ const parsed = readJsonFileValue(resolve(filePath), "--file");
3905
4282
  return requestOxygen("/api/cli/mailboxes", {
3906
4283
  method: "POST",
3907
4284
  body: { mailboxes: parsed.mailboxes ?? [] },
@@ -4000,68 +4377,177 @@ export function createProgram() {
4000
4377
  return requestOxygen(`/api/cli/mailboxes/warmup/status${suffix ? `?${suffix}` : ""}`);
4001
4378
  });
4002
4379
  }))));
4380
+ program.addCommand(new Command("domains")
4381
+ .description("Cold-email domain management on the org's own Cloudflare account (BYOK): sync zones, inspect age/warmup/DNS health, check availability and pricing, and buy domains. Purchases bill your Cloudflare payment method, never Oxygen credits.")
4382
+ .addCommand(new Command("list")
4383
+ .description("List the org's cached Cloudflare domains with cold-email metadata (age, mailboxes, warmup, sending volume, DNS health). Reads the cache only — run `domains sync` to refresh.")
4384
+ .option("--status <status>", "Filter by zone status: unknown, initializing, pending, active, moved, or deleted.")
4385
+ .option("--registrar <registrar>", "Filter by registrar name.")
4386
+ .option("--q <text>", "Filter by domain substring.")
4387
+ .option("--json", "Print a JSON envelope.")
4388
+ .action(async (options) => {
4389
+ await handleAsyncAction("domains list", options, () => {
4390
+ const params = new URLSearchParams();
4391
+ const status = readOption(options.status);
4392
+ if (status)
4393
+ params.set("status", status);
4394
+ const registrar = readOption(options.registrar);
4395
+ if (registrar)
4396
+ params.set("registrar", registrar);
4397
+ const queryText = readOption(options.q);
4398
+ if (queryText)
4399
+ params.set("q", queryText);
4400
+ const suffix = params.toString();
4401
+ return requestOxygen(`/api/cli/domains${suffix ? `?${suffix}` : ""}`);
4402
+ });
4403
+ }))
4404
+ .addCommand(new Command("sync")
4405
+ .description("Refresh the domain cache from Cloudflare: zone pages, registrar metadata, RDAP age backfill, and DNS health. Partial (paginated) syncs auto-continue until complete.")
4406
+ .option("--full", "Restart the sweep from the first zone page instead of resuming the cursor.")
4407
+ .option("--json", "Print a JSON envelope.")
4408
+ .action(async (options) => {
4409
+ await handleAsyncAction("domains sync", options, () => runDomainsSync(options));
4410
+ }))
4411
+ .addCommand(new Command("get")
4412
+ .description("Get one domain's full detail: zone state, age with provenance, mailboxes, sending volume, and the last DNS health check.")
4413
+ .argument("<domain>", "Domain name, such as acme.com.")
4414
+ .option("--json", "Print a JSON envelope.")
4415
+ .action(async (domain, options) => {
4416
+ await handleAsyncAction("domains get", options, () => requestOxygen(`/api/cli/domains/${encodeURIComponent(domain)}`));
4417
+ }))
4418
+ .addCommand(new Command("dns")
4419
+ .description("Run a live read-only DNS health check (SPF, DKIM, DMARC, MX) against the domain's Cloudflare zone and persist the result.")
4420
+ .argument("<domain>", "Domain name, such as acme.com.")
4421
+ .option("--json", "Print a JSON envelope.")
4422
+ .action(async (domain, options) => {
4423
+ await handleAsyncAction("domains dns", options, () => requestOxygen(`/api/cli/domains/${encodeURIComponent(domain)}/dns`));
4424
+ }))
4425
+ .addCommand(new Command("search")
4426
+ .description("Search Cloudflare Registrar for available domains with registration/renewal pricing. Free — nothing is purchased.")
4427
+ .argument("<query>", "Search text, such as a brand or keyword.")
4428
+ .option("--json", "Print a JSON envelope.")
4429
+ .action(async (query, options) => {
4430
+ await handleAsyncAction("domains search", options, () => {
4431
+ const params = new URLSearchParams({ q: query });
4432
+ return requestOxygen(`/api/cli/domains/search?${params.toString()}`);
4433
+ });
4434
+ }))
4435
+ .addCommand(new Command("check")
4436
+ .description("Check availability and pricing for up to 20 specific domains via Cloudflare Registrar. Free — nothing is purchased.")
4437
+ .argument("<domains...>", "Domain names to check (max 20).")
4438
+ .option("--json", "Print a JSON envelope.")
4439
+ .action(async (domains, options) => {
4440
+ await handleAsyncAction("domains check", options, () => {
4441
+ if (domains.length > DOMAINS_CHECK_MAX_DOMAINS) {
4442
+ throw new Error(`Cloudflare checks at most ${DOMAINS_CHECK_MAX_DOMAINS} domains per call (got ${domains.length}). Split the list and re-run.`);
4443
+ }
4444
+ return requestOxygen("/api/cli/domains/check", {
4445
+ method: "POST",
4446
+ body: { domains },
4447
+ });
4448
+ });
4449
+ }))
4450
+ .addCommand(new Command("buy")
4451
+ .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.")
4452
+ .argument("<domains...>", "Domain names to buy.")
4453
+ .option("--approved", "Execute the purchase. Without this flag, returns a preview only.")
4454
+ .option("--quote <id>", "Quote id from the preview (required with --approved).")
4455
+ .option("--accept-premium", "Acknowledge premium-tier pricing for premium domains.")
4456
+ .option("--auto-renew", "Enable auto-renew on the new registrations.")
4457
+ .option("--no-privacy", "Disable WHOIS privacy/redaction on the new registrations.")
4458
+ .option("--wait", "After an approved purchase, poll each pending registration to a terminal state (up to 10 minutes per domain).")
4459
+ .option("--json", "Print a JSON envelope.")
4460
+ .action(async (domains, options) => {
4461
+ await handleAsyncAction("domains buy", options, () => runDomainsBuy(domains, options));
4462
+ }))
4463
+ .addCommand(new Command("registrations")
4464
+ .description("List the org's domain purchase ledger: every registration attempt with status and price snapshot.")
4465
+ .option("--status <status>", "Filter by ledger status: submitting, in_progress, succeeded, failed, action_required, or blocked.")
4466
+ .option("--json", "Print a JSON envelope.")
4467
+ .action(async (options) => {
4468
+ await handleAsyncAction("domains registrations", options, () => {
4469
+ const params = new URLSearchParams();
4470
+ const status = readOption(options.status);
4471
+ if (status)
4472
+ params.set("status", status);
4473
+ const suffix = params.toString();
4474
+ return requestOxygen(`/api/cli/domains/registrations${suffix ? `?${suffix}` : ""}`);
4475
+ });
4476
+ }))
4477
+ .addCommand(new Command("registration-status")
4478
+ .description("Get one domain registration's ledger state (the server performs a gated live poll when due — safe to call repeatedly). Pass --wait to poll until it settles.")
4479
+ .argument("<domain>", "Domain name from a previous `domains buy`.")
4480
+ .option("--wait", "Poll every 10s (up to 10 minutes) until the registration reaches a terminal state.")
4481
+ .option("--json", "Print a JSON envelope.")
4482
+ .action(async (domain, options) => {
4483
+ await handleAsyncAction("domains registration-status", options, () => {
4484
+ if (options.wait)
4485
+ return waitForDomainRegistration(domain);
4486
+ return requestOxygen(`/api/cli/domains/registrations/${encodeURIComponent(domain)}`);
4487
+ });
4488
+ })));
4003
4489
  program
4004
4490
  .command("workflows")
4005
4491
  .description("Durable workflow automation commands.")
4006
4492
  .addCommand(new Command("templates")
4007
- .description("Discover and run reusable Oxygen workflow templates.")
4493
+ .description("Deprecated aliases for `oxygen blueprints` these route through the canonical blueprint surface (built-in seed templates plus this workspace's saved blueprints).")
4008
4494
  .addCommand(new Command("search")
4009
- .description("Search reusable workflow templates.")
4495
+ .description("Deprecated alias for `oxygen blueprints list`. Searches Oxygen blueprints (seed templates plus saved blueprints).")
4010
4496
  .argument("[query]", "Search text.")
4011
- .option("--tag <tag>", "Filter by template tag.")
4497
+ .option("--tag <tag>", "Filter by blueprint tag.")
4012
4498
  .option("--json", "Print a JSON envelope.")
4013
4499
  .action(async (query, options) => {
4014
- await handleAsyncAction("workflows templates search", options, async () => {
4500
+ await handleAsyncAction("workflows templates search", options, () => {
4015
4501
  const params = new URLSearchParams();
4016
4502
  params.set("query", query ?? "");
4017
4503
  if (readOption(options.tag))
4018
4504
  params.set("tag", readOption(options.tag) ?? "");
4019
- return requestOxygen(`/api/cli/workflows/templates?${params.toString()}`);
4505
+ return requestOxygen(`/api/cli/blueprints?${params.toString()}`);
4020
4506
  });
4021
4507
  }))
4022
4508
  .addCommand(new Command("describe")
4023
- .description("Describe one workflow template.")
4024
- .argument("<template_id>", "Workflow template id.")
4509
+ .description("Deprecated alias for `oxygen blueprints describe`.")
4510
+ .argument("<template_id>", "Blueprint slug (formerly workflow template id).")
4025
4511
  .option("--json", "Print a JSON envelope.")
4026
4512
  .action(async (templateId, options) => {
4027
- await handleAsyncAction("workflows templates describe", options, async () => requestOxygen("/api/cli/workflows/templates/describe", {
4513
+ await handleAsyncAction("workflows templates describe", options, () => requestOxygen("/api/cli/blueprints/get", {
4028
4514
  method: "POST",
4029
- body: { template_id: templateId },
4515
+ body: { slug: templateId },
4030
4516
  }));
4031
4517
  }))
4032
4518
  .addCommand(new Command("preflight")
4033
- .description("Validate workflow template inputs and preview effects without running it.")
4034
- .argument("<template_id>", "Workflow template id.")
4035
- .requiredOption("--input-json <json>", "Template input as a JSON object.")
4036
- .option("--mode <mode>", "Execution mode: smoke_test, dry_run, or live.")
4037
- .option("--max-credits <credits>", "Credit ceiling for paid live work.")
4519
+ .description("Deprecated alias for `oxygen blueprints preflight`. Validates inputs and previews effects without running.")
4520
+ .argument("<template_id>", "Blueprint slug (formerly workflow template id).")
4521
+ .requiredOption("--input-json <json>", "Blueprint input as a JSON object.")
4522
+ .option("--mode <mode>", "Deprecated and ignored: blueprints validate without a run mode.")
4523
+ .option("--max-credits <credits>", "Credit ceiling; folded into inputs.max_credits.")
4038
4524
  .option("--json", "Print a JSON envelope.")
4039
4525
  .action(async (templateId, options) => {
4040
- await handleAsyncAction("workflows templates preflight", options, async () => requestOxygen("/api/cli/workflows/templates/preflight", {
4526
+ await handleAsyncAction("workflows templates preflight", options, () => requestOxygen("/api/cli/blueprints/preflight", {
4041
4527
  method: "POST",
4042
- body: workflowTemplateActionBody(templateId, options),
4528
+ body: workflowTemplateBlueprintBody(templateId, options),
4043
4529
  }));
4044
4530
  }))
4045
4531
  .addCommand(new Command("apply")
4046
- .description("Create or update a disabled workflow from a template.")
4047
- .argument("<template_id>", "Workflow template id.")
4048
- .requiredOption("--input-json <json>", "Template input as a JSON object.")
4049
- .option("--workflow-id <workflow_id>", "Workflow id to create or update.")
4050
- .option("--workflow-name <workflow_name>", "Workflow display name.")
4051
- .option("--mode <mode>", "Mode used for preflight validation.")
4052
- .option("--max-credits <credits>", "Credit ceiling to store in template defaults.")
4532
+ .description("Deprecated alias for `oxygen blueprints apply`. Creates a disabled workflow (plus its tables, columns, and prompts) from a blueprint.")
4533
+ .argument("<template_id>", "Blueprint slug (formerly workflow template id).")
4534
+ .requiredOption("--input-json <json>", "Blueprint input as a JSON object.")
4535
+ .option("--workflow-id <workflow_id>", "Override the resulting workflow id.")
4536
+ .option("--workflow-name <workflow_name>", "Override the resulting workflow name.")
4537
+ .option("--mode <mode>", "Deprecated and ignored: the workflow is created disabled.")
4538
+ .option("--max-credits <credits>", "Credit ceiling; folded into inputs.max_credits.")
4053
4539
  .option("--include-bundle", "Include durable recipe bundles in JSON output.")
4054
4540
  .option("--json", "Print a JSON envelope.")
4055
4541
  .action(async (templateId, options) => {
4056
- await handleAsyncAction("workflows templates apply", options, async () => prepareWorkflowCliOutput(await requestOxygen("/api/cli/workflows/templates/apply", {
4542
+ await handleAsyncAction("workflows templates apply", options, async () => prepareWorkflowCliOutput(await requestOxygen("/api/cli/blueprints/apply", {
4057
4543
  method: "POST",
4058
- body: workflowTemplateActionBody(templateId, options),
4544
+ body: workflowTemplateBlueprintBody(templateId, options, true),
4059
4545
  }), options));
4060
4546
  }))
4061
4547
  .addCommand(new Command("run")
4062
- .description("Apply a template as an active workflow and enqueue one run.")
4063
- .argument("<template_id>", "Workflow template id.")
4064
- .requiredOption("--input-json <json>", "Template input as a JSON object.")
4548
+ .description("Removed: returns a typed 410 deprecation. Use `oxygen blueprints apply <slug>` then `oxygen workflows call <workflow>`.")
4549
+ .argument("<template_id>", "Blueprint slug (formerly workflow template id).")
4550
+ .requiredOption("--input-json <json>", "Blueprint input as a JSON object.")
4065
4551
  .requiredOption("--mode <mode>", "Execution mode: smoke_test, dry_run, or live.")
4066
4552
  .option("--workflow-id <workflow_id>", "Workflow id to create or update.")
4067
4553
  .option("--workflow-name <workflow_name>", "Workflow display name.")
@@ -4080,7 +4566,7 @@ export function createProgram() {
4080
4566
  .option("--subject <subject>", "Schema subject: all, apply, call, event, trigger, or manifest.")
4081
4567
  .option("--json", "Print a JSON envelope.")
4082
4568
  .action(async (options) => {
4083
- await handleAsyncAction("workflows schema", options, async () => requestOxygen(`/api/cli/workflows/schema?subject=${encodeURIComponent(options.subject ?? "all")}`));
4569
+ await handleAsyncAction("workflows schema", options, () => requestOxygen(`/api/cli/workflows/schema?subject=${encodeURIComponent(options.subject ?? "all")}`));
4084
4570
  }))
4085
4571
  .addCommand(new Command("lint")
4086
4572
  .description("Compile and lint a workflow file without saving it.")
@@ -4113,7 +4599,7 @@ export function createProgram() {
4113
4599
  .description("List workflow automations.")
4114
4600
  .option("--json", "Print a JSON envelope.")
4115
4601
  .action(async (options) => {
4116
- await handleAsyncAction("workflows list", options, async () => requestOxygen("/api/cli/workflows"));
4602
+ await handleAsyncAction("workflows list", options, () => requestOxygen("/api/cli/workflows"));
4117
4603
  }))
4118
4604
  .addCommand(new Command("get")
4119
4605
  .description("Get one workflow automation.")
@@ -4268,14 +4754,14 @@ export function createProgram() {
4268
4754
  .option("--include-bundle", "Include durable recipe bundles in JSON output.")
4269
4755
  .option("--json", "Print a JSON envelope.")
4270
4756
  .action(async (runId, options) => {
4271
- await handleAsyncAction("workflows tail", options, async () => prepareWorkflowCliOutput(await tailWorkflowRun(runId, options), options));
4757
+ await handleAsyncAction("workflows tail", options, async () => prepareWorkflowCliOutput(await waitForWorkflowRun(runId, options), options));
4272
4758
  }))
4273
4759
  .addCommand(new Command("cancel")
4274
4760
  .description("Cancel a queued or running workflow run.")
4275
4761
  .argument("<run_id>", "Workflow run UUID.")
4276
4762
  .option("--json", "Print a JSON envelope.")
4277
4763
  .action(async (runId, options) => {
4278
- await handleAsyncAction("workflows cancel", options, async () => requestOxygen("/api/cli/workflows/cancel", {
4764
+ await handleAsyncAction("workflows cancel", options, () => requestOxygen("/api/cli/workflows/cancel", {
4279
4765
  method: "POST",
4280
4766
  body: { run_id: runId },
4281
4767
  }));
@@ -4285,7 +4771,7 @@ export function createProgram() {
4285
4771
  .argument("<workflow>", "Workflow id, slug, or name.")
4286
4772
  .option("--json", "Print a JSON envelope.")
4287
4773
  .action(async (workflow, options) => {
4288
- await handleAsyncAction("workflows enable", options, async () => requestOxygen("/api/cli/workflows/enable", {
4774
+ await handleAsyncAction("workflows enable", options, () => requestOxygen("/api/cli/workflows/enable", {
4289
4775
  method: "POST",
4290
4776
  body: { workflow },
4291
4777
  }));
@@ -4295,7 +4781,7 @@ export function createProgram() {
4295
4781
  .argument("<workflow>", "Workflow id, slug, or name.")
4296
4782
  .option("--json", "Print a JSON envelope.")
4297
4783
  .action(async (workflow, options) => {
4298
- await handleAsyncAction("workflows disable", options, async () => requestOxygen("/api/cli/workflows/disable", {
4784
+ await handleAsyncAction("workflows disable", options, () => requestOxygen("/api/cli/workflows/disable", {
4299
4785
  method: "POST",
4300
4786
  body: { workflow },
4301
4787
  }));
@@ -4308,14 +4794,14 @@ export function createProgram() {
4308
4794
  .option("--api-url <url>", "Oxygen app URL. Defaults to OXYGEN_API_URL or https://oxygen-agent.com.")
4309
4795
  .option("--json", "Print a JSON envelope.")
4310
4796
  .action(async (options) => {
4311
- await handleAsyncAction("skills list", options, async () => listAgentSkills(options));
4797
+ await handleAsyncAction("skills list", options, () => listAgentSkills(options));
4312
4798
  }))
4313
4799
  .addCommand(new Command("doctor")
4314
4800
  .description("Check Oxygen skill index reachability and local installer prerequisites.")
4315
4801
  .option("--api-url <url>", "Oxygen app URL. Defaults to OXYGEN_API_URL or https://oxygen-agent.com.")
4316
4802
  .option("--json", "Print a JSON envelope.")
4317
4803
  .action(async (options) => {
4318
- await handleAsyncAction("skills doctor", options, async () => doctorAgentSkills(options));
4804
+ await handleAsyncAction("skills doctor", options, () => doctorAgentSkills(options));
4319
4805
  }))
4320
4806
  .addCommand(new Command("install")
4321
4807
  .description("Install Oxygen agent skills into local agent skill directories.")
@@ -4326,6 +4812,7 @@ export function createProgram() {
4326
4812
  .option("--copy", "Copy skill files instead of symlinking when supported by npx skills. Default on Windows, where symlinks need Developer Mode or admin.")
4327
4813
  .option("--json", "Print a JSON envelope.")
4328
4814
  .action(async (options) => {
4815
+ // skipcq: JS-0116 — async Promise-wraps the synchronous installAgentSkills helper to satisfy handleAsyncAction's Promise<unknown> action
4329
4816
  await handleAsyncAction("skills install", options, async () => installAgentSkills(options));
4330
4817
  }));
4331
4818
  return program;
@@ -4446,8 +4933,8 @@ function referencesRecipeSdk(source) {
4446
4933
  }
4447
4934
  // Escape Next static analysis (the CLI is bundled by tsc, but mirror the
4448
4935
  // worker's escape so both load identically).
4936
+ // skipcq: JS-R1003 — intentional dynamic-import escape mirrored from the worker; not arbitrary code execution
4449
4937
  const dynamicRecipeImport = new Function("specifier", "return import(specifier);");
4450
- // skipcq: JS-R1003
4451
4938
  async function importRecipeModule(specifier) {
4452
4939
  try {
4453
4940
  return await dynamicRecipeImport(specifier);
@@ -4594,66 +5081,64 @@ export function toolStep(input) {
4594
5081
  }
4595
5082
  `;
4596
5083
  }
4597
- async function tailWorkflowRun(// skipcq: JS-R1005 -- CLI tailer coordinates polling, status transitions, and final output formatting.
4598
- runId, options) {
4599
- const timeoutSeconds = readPositiveInt(options.timeoutSeconds)
4600
- ?? WORKFLOW_TAIL_DEFAULT_TIMEOUT_SECONDS;
4601
- const intervalSeconds = readPositiveInt(options.intervalSeconds)
4602
- ?? WORKFLOW_TAIL_DEFAULT_INTERVAL_SECONDS;
4603
- const startedAt = Date.now();
4604
- const deadline = startedAt + timeoutSeconds * 1000;
4605
- let polls = 0;
4606
- while (true) {
4607
- polls += 1;
4608
- const latest = await requestOxygen("/api/cli/workflows/run", {
4609
- method: "POST",
4610
- body: { run_id: runId },
4611
- });
4612
- const run = isRecord(latest.run) ? latest.run : latest;
4613
- const status = readRecordString(run, "status");
4614
- if (isTerminalWorkflowRunStatus(status)) {
4615
- const workflowUrl = readRecordString(latest, "workflowUrl");
4616
- const runUrl = readRecordString(latest, "runUrl");
4617
- const webUrl = readRecordString(latest, "web_url");
4618
- const deepLink = readRecordString(latest, "deepLink");
5084
+ // The workflow tailer is the one bespoke wait surface: it POSTs (rather than
5085
+ // GETs a run by id), reads the run nested under the response envelope, and
5086
+ // carries the workflow/run/deep-link URLs from that envelope into its terminal
5087
+ // output. The shared waitForCliRun loop drives polling; this config injects the
5088
+ // workflow-specific fetch, terminal shape, and queued-no-worker timeout hint.
5089
+ function waitForWorkflowRun(runId, options) {
5090
+ // Captured per poll so shapeTerminal can read the envelope-level URLs while
5091
+ // fetchRun returns the nested run (where the status lives).
5092
+ let latestEnvelope = {};
5093
+ return waitForCliRun({
5094
+ runId,
5095
+ requestedTimeoutSeconds: options.timeoutSeconds,
5096
+ requestedIntervalSeconds: options.intervalSeconds,
5097
+ defaultTimeoutSeconds: WORKFLOW_TAIL_DEFAULT_TIMEOUT_SECONDS,
5098
+ defaultIntervalSeconds: WORKFLOW_TAIL_DEFAULT_INTERVAL_SECONDS,
5099
+ fetchRun: async () => {
5100
+ latestEnvelope = await requestOxygen("/api/cli/workflows/run", {
5101
+ method: "POST",
5102
+ body: { run_id: runId },
5103
+ });
5104
+ return isRecord(latestEnvelope.run) ? latestEnvelope.run : latestEnvelope;
5105
+ },
5106
+ isTerminal: isTerminalWorkflowRunStatus,
5107
+ shapeTerminal: (run, status, polls, elapsedMs) => {
5108
+ const workflowUrl = readRecordString(latestEnvelope, "workflowUrl");
5109
+ const runUrl = readRecordString(latestEnvelope, "runUrl");
5110
+ const webUrl = readRecordString(latestEnvelope, "web_url");
5111
+ const deepLink = readRecordString(latestEnvelope, "deepLink");
4619
5112
  return {
4620
5113
  run,
4621
5114
  workflowRunId: readRecordString(run, "id") ?? runId,
4622
5115
  status,
4623
5116
  terminal: true,
4624
5117
  polls,
4625
- elapsedMs: Date.now() - startedAt,
5118
+ elapsedMs,
4626
5119
  ...(workflowUrl ? { workflowUrl } : {}),
4627
5120
  ...(runUrl ? { runUrl } : {}),
4628
5121
  ...(webUrl ? { web_url: webUrl } : {}),
4629
5122
  ...(deepLink ? { deepLink } : {}),
4630
5123
  };
4631
- }
4632
- const remainingMs = deadline - Date.now();
4633
- if (remainingMs <= 0) {
4634
- const worker = isRecord(run.worker) ? run.worker : null;
5124
+ },
5125
+ timeoutCode: "workflow_tail_timeout",
5126
+ timeoutMessage: "Timed out waiting for workflow run to finish.",
5127
+ timeoutDetailIdKey: "workflow_run_id",
5128
+ timeoutExtraDetails: (latestRun, status) => {
5129
+ const worker = latestRun && isRecord(latestRun.worker) ? latestRun.worker : null;
4635
5130
  const queuedWithoutWorker = status === "queued"
4636
5131
  && worker !== null
4637
5132
  && worker.active === null
4638
5133
  && worker.lastClaim === null;
4639
- throw new OxygenError("workflow_tail_timeout", "Timed out waiting for workflow run to finish.", {
4640
- details: {
4641
- workflow_run_id: runId,
4642
- status: status ?? null,
4643
- timeout_seconds: timeoutSeconds,
4644
- polls,
4645
- ...(queuedWithoutWorker
4646
- ? {
4647
- worker_status: "queued_no_worker",
4648
- guidance: "No worker has claimed this workflow run. Check `oxygen worker queue-stats --json` for Postgres-backed worker queue health.",
4649
- }
4650
- : {}),
4651
- },
4652
- exitCode: 1,
4653
- });
4654
- }
4655
- await sleep(Math.min(intervalSeconds * 1000, remainingMs));
4656
- }
5134
+ return queuedWithoutWorker
5135
+ ? {
5136
+ worker_status: "queued_no_worker",
5137
+ guidance: "No worker has claimed this workflow run. Check `oxygen worker queue-stats --json` for Postgres-backed worker queue health.",
5138
+ }
5139
+ : {};
5140
+ },
5141
+ });
4657
5142
  }
4658
5143
  function workflowTemplateActionBody(templateId, options) {
4659
5144
  const inputJson = readOption(options.inputJson);
@@ -4668,6 +5153,28 @@ function workflowTemplateActionBody(templateId, options) {
4668
5153
  ...(options.approved ? { approved: true } : {}),
4669
5154
  };
4670
5155
  }
5156
+ // OXY-481: the deprecated `workflows templates {search,describe,preflight,apply}`
5157
+ // commands route through the canonical blueprint API. Map the legacy arg names
5158
+ // (template_id -> slug, --input-json -> inputs) and fold the old top-level
5159
+ // max_credits into inputs.max_credits, where seed blueprints read their cap.
5160
+ function workflowTemplateBlueprintBody(templateId, options, includeWorkflowOverrides = false) {
5161
+ const inputJson = readOption(options.inputJson);
5162
+ const inputs = inputJson ? parseJsonObject(inputJson) : {};
5163
+ const maxCredits = readPositiveNumber(options.maxCredits);
5164
+ if (maxCredits !== undefined) {
5165
+ inputs.max_credits = maxCredits;
5166
+ }
5167
+ const body = { slug: templateId, inputs };
5168
+ if (includeWorkflowOverrides) {
5169
+ const workflowId = readOption(options.workflowId);
5170
+ if (workflowId)
5171
+ body.workflow_id = workflowId;
5172
+ const workflowName = readOption(options.workflowName);
5173
+ if (workflowName)
5174
+ body.workflow_name = workflowName;
5175
+ }
5176
+ return body;
5177
+ }
4671
5178
  function prepareWorkflowCliOutput(value, options) {
4672
5179
  const normalized = normalizeWorkflowRunErrors(value);
4673
5180
  return (options.includeBundle ? normalized : stripWorkflowBundles(normalized));
@@ -4824,6 +5331,34 @@ function readTableRunSelection(options) {
4824
5331
  exitCode: 1,
4825
5332
  });
4826
5333
  }
5334
+ function applyAiColumnConfig(definition, options) {
5335
+ const model = readOption(options.model);
5336
+ if (model)
5337
+ definition.model = model;
5338
+ const reasoningLevel = readOption(options.reasoningLevel);
5339
+ if (reasoningLevel)
5340
+ definition.reasoningLevel = reasoningLevel;
5341
+ const runCondition = readOption(options.runCondition);
5342
+ if (runCondition) {
5343
+ definition.runCondition = {
5344
+ type: "formula",
5345
+ expression: runCondition,
5346
+ referencedColumns: readCsvOption(options.runConditionColumns),
5347
+ };
5348
+ }
5349
+ const outputSchemaJson = readOption(options.outputSchemaJson);
5350
+ const outputSchemaFile = readOption(options.outputSchemaFile);
5351
+ if (outputSchemaJson && outputSchemaFile) {
5352
+ throw new OxygenError("invalid_request", "Pass either --output-schema-json or --output-schema-file, not both.", { exitCode: 1 });
5353
+ }
5354
+ if (outputSchemaJson) {
5355
+ definition.outputSchema = parseJsonValue(outputSchemaJson, "--output-schema-json");
5356
+ }
5357
+ else if (outputSchemaFile) {
5358
+ definition.outputSchema = readJsonFileValue(resolve(outputSchemaFile), "--output-schema-file");
5359
+ }
5360
+ return definition;
5361
+ }
4827
5362
  function tableRunsListPath(options) {
4828
5363
  const table = readOption(options.table);
4829
5364
  if (!table) {
@@ -5023,7 +5558,7 @@ function readCompanySearchFilters(options) {
5023
5558
  const fundingStages = readCsvOption(options.fundingStages);
5024
5559
  if (fundingStages.length > 0)
5025
5560
  filters.funding = { stages: fundingStages };
5026
- const technologies = readIncludeExclude(options.technologies, undefined);
5561
+ const technologies = readIncludeExclude(options.technologies);
5027
5562
  if (technologies)
5028
5563
  filters.technologies = technologies;
5029
5564
  const revenue = readRangeOption(options.revenue, "--revenue");
@@ -5112,6 +5647,22 @@ function assertNotCompanySearchKind(kind) {
5112
5647
  });
5113
5648
  }
5114
5649
  }
5650
+ // People search has moved to the dedicated people-search surface. The CLI rejects
5651
+ // `oxygen search plan --kind people_search` client-side so a stale agent gets a typed,
5652
+ // self-correcting error instead of an opaque server 400. Message mirrors
5653
+ // PEOPLE_SEARCH_MOVED_MESSAGE in @oxygen/tools (CLI does not import that package).
5654
+ const PEOPLE_SEARCH_MOVED_MESSAGE = "People search is handled by the dedicated people-search surface. Use: oxygen people search plan --prompt \"<goal>\" (CLI) or oxygen_people_search_plan (MCP).";
5655
+ function assertNotPeopleSearchKind(kind) {
5656
+ if (readOption(kind) === "people_search") {
5657
+ throw new OxygenError("use_people_search", PEOPLE_SEARCH_MOVED_MESSAGE, {
5658
+ details: {
5659
+ equivalent_cli: "oxygen people search plan --prompt <goal>",
5660
+ equivalent_mcp: "oxygen_people_search_plan",
5661
+ },
5662
+ exitCode: 1,
5663
+ });
5664
+ }
5665
+ }
5115
5666
  function readCompanySearchPlanJson(value) {
5116
5667
  const parsed = parseJsonObject(readFileIfPresent(value));
5117
5668
  const data = parsed.data;
@@ -5127,7 +5678,8 @@ function readPeopleSearchPlanBody(options) {
5127
5678
  ...(targetCount !== undefined ? { target_count: targetCount } : {}),
5128
5679
  ...(options.sourceIntent ? { source_intent: options.sourceIntent } : {}),
5129
5680
  ...(filters ? { filters } : {}),
5130
- ...(options.estimate ? { estimate: true } : {}),
5681
+ // Free sizing is on by default; only --no-estimate (options.estimate === false) opts out.
5682
+ estimate: options.estimate !== false,
5131
5683
  ...(options.materializePreview ? { materialize_preview: true } : {}),
5132
5684
  };
5133
5685
  }
@@ -5154,7 +5706,8 @@ function readPeopleSearchRunBody(options) {
5154
5706
  ...(targetCount !== undefined ? { target_count: targetCount } : {}),
5155
5707
  ...(options.sourceIntent ? { source_intent: options.sourceIntent } : {}),
5156
5708
  ...(filters ? { filters } : {}),
5157
- ...(prompt && options.estimate ? { estimate: true } : {}),
5709
+ // Free sizing is on by default when planning from a prompt; --no-estimate opts out.
5710
+ ...(prompt ? { estimate: options.estimate !== false } : {}),
5158
5711
  ...(options.approved ? { approved: true } : {}),
5159
5712
  };
5160
5713
  }
@@ -5307,6 +5860,26 @@ function readFilterJsonOption(value) {
5307
5860
  }
5308
5861
  return filters;
5309
5862
  }
5863
+ function readFilterTreeJsonOption(value) {
5864
+ const raw = readOption(value);
5865
+ if (!raw)
5866
+ return undefined;
5867
+ return parseJsonObject(raw);
5868
+ }
5869
+ function readSortJsonOption(value) {
5870
+ const raw = readOption(value);
5871
+ if (!raw)
5872
+ return undefined;
5873
+ const parsed = parseJsonArray(raw);
5874
+ if (parsed.length === 0)
5875
+ return undefined;
5876
+ if (parsed.some((entry) => !entry || typeof entry !== "object" || Array.isArray(entry))) {
5877
+ throw new OxygenError("invalid_sort", "--sort-json must be an array of sort-rule objects.", {
5878
+ exitCode: 1,
5879
+ });
5880
+ }
5881
+ return parsed;
5882
+ }
5310
5883
  function parseStringArray(value) {
5311
5884
  const parsed = parseJsonArray(value);
5312
5885
  const labels = parsed
@@ -5333,6 +5906,7 @@ function normalizeSessionStepStatus(value) {
5333
5906
  exitCode: 1,
5334
5907
  });
5335
5908
  }
5909
+ // skipcq: JS-R1005 — intentional branching over file format, create/upsert, and sync/background import modes
5336
5910
  async function importRows(table, options) {
5337
5911
  const format = normalizeRowsFormat(options.format, inferRowsFileFormat(options.file));
5338
5912
  const parsedRows = await readRowsFile(options.file, format, options.sheet);
@@ -5737,118 +6311,249 @@ function emitQueueWaitStderrNote(queueWait) {
5737
6311
  if (note)
5738
6312
  process.stderr.write(`note: ${note}\n`);
5739
6313
  }
5740
- async function waitForTableIngestionRun(runId, options) {
5741
- const timeoutSeconds = readPositiveInt(options.timeoutSeconds)
5742
- ?? TABLE_INGESTION_WAIT_DEFAULT_TIMEOUT_SECONDS;
5743
- const intervalSeconds = readPositiveInt(options.intervalSeconds)
5744
- ?? TABLE_INGESTION_WAIT_DEFAULT_INTERVAL_SECONDS;
5745
- const startedAt = Date.now();
5746
- const deadline = startedAt + timeoutSeconds * 1000;
5747
- let polls = 0;
5748
- while (true) {
5749
- polls += 1;
5750
- const latestRun = await requestOxygen(`/api/cli/table-ingestion-runs/${encodeURIComponent(runId)}`);
5751
- const status = readRecordString(latestRun, "status");
5752
- if (isTerminalTableIngestionStatus(status)) {
6314
+ function buildGetSurfaceRunWaitConfig(params) {
6315
+ return {
6316
+ runId: params.runId,
6317
+ requestedTimeoutSeconds: params.requestedTimeoutSeconds,
6318
+ requestedIntervalSeconds: params.requestedIntervalSeconds,
6319
+ defaultTimeoutSeconds: params.defaultTimeoutSeconds,
6320
+ defaultIntervalSeconds: params.defaultIntervalSeconds,
6321
+ fetchRun: () => requestOxygen(`${params.endpoint}/${encodeURIComponent(params.runId)}`),
6322
+ isTerminal: params.isTerminal,
6323
+ shapeTerminal: (run, status, polls, elapsedMs) => {
6324
+ const webUrl = readRecordString(run, "web_url");
5753
6325
  return {
5754
- ingestionRun: latestRun,
5755
- ingestionRunId: readRecordString(latestRun, "id") ?? runId,
6326
+ [params.runKey]: run,
6327
+ [params.runIdKey]: readRecordString(run, "id") ?? params.runId,
5756
6328
  status,
5757
6329
  terminal: true,
5758
6330
  polls,
5759
- elapsedMs: Date.now() - startedAt,
5760
- ...(readRecordString(latestRun, "web_url") ? { web_url: readRecordString(latestRun, "web_url") } : {}),
6331
+ elapsedMs,
6332
+ ...(webUrl ? { web_url: webUrl } : {}),
5761
6333
  };
6334
+ },
6335
+ timeoutCode: params.timeoutCode,
6336
+ timeoutMessage: params.timeoutMessage,
6337
+ timeoutDetailIdKey: params.timeoutDetailIdKey,
6338
+ };
6339
+ }
6340
+ function waitForTableIngestionRun(runId, options) {
6341
+ return waitForCliRun(buildGetSurfaceRunWaitConfig({
6342
+ runId,
6343
+ endpoint: "/api/cli/table-ingestion-runs",
6344
+ requestedTimeoutSeconds: options.timeoutSeconds,
6345
+ requestedIntervalSeconds: options.intervalSeconds,
6346
+ defaultTimeoutSeconds: TABLE_INGESTION_WAIT_DEFAULT_TIMEOUT_SECONDS,
6347
+ defaultIntervalSeconds: TABLE_INGESTION_WAIT_DEFAULT_INTERVAL_SECONDS,
6348
+ isTerminal: isTerminalTableIngestionStatus,
6349
+ runKey: "ingestionRun",
6350
+ runIdKey: "ingestionRunId",
6351
+ timeoutCode: "table_ingestion_wait_timeout",
6352
+ timeoutMessage: "Timed out waiting for table ingestion run to finish.",
6353
+ timeoutDetailIdKey: "ingestion_run_id",
6354
+ }));
6355
+ }
6356
+ function waitForSearchRun(runId, options) {
6357
+ return waitForCliRun(buildGetSurfaceRunWaitConfig({
6358
+ runId,
6359
+ endpoint: "/api/cli/search/runs",
6360
+ requestedTimeoutSeconds: options.timeoutSeconds,
6361
+ requestedIntervalSeconds: options.intervalSeconds,
6362
+ // Search intentionally reuses the table-ingestion defaults and terminal
6363
+ // predicate; the status set is identical (a naming-drift smell, not a bug).
6364
+ defaultTimeoutSeconds: TABLE_INGESTION_WAIT_DEFAULT_TIMEOUT_SECONDS,
6365
+ defaultIntervalSeconds: TABLE_INGESTION_WAIT_DEFAULT_INTERVAL_SECONDS,
6366
+ isTerminal: isTerminalTableIngestionStatus,
6367
+ runKey: "searchRun",
6368
+ runIdKey: "searchRunId",
6369
+ timeoutCode: "search_run_wait_timeout",
6370
+ timeoutMessage: "Timed out waiting for search run to finish.",
6371
+ timeoutDetailIdKey: "search_run_id",
6372
+ }));
6373
+ }
6374
+ function waitForTableActionRun(runId, options) {
6375
+ return waitForCliRun(buildGetSurfaceRunWaitConfig({
6376
+ runId,
6377
+ endpoint: "/api/cli/table-action-runs",
6378
+ requestedTimeoutSeconds: options.timeoutSeconds,
6379
+ requestedIntervalSeconds: options.intervalSeconds,
6380
+ defaultTimeoutSeconds: TABLE_ACTION_RUN_WAIT_DEFAULT_TIMEOUT_SECONDS,
6381
+ defaultIntervalSeconds: TABLE_ACTION_RUN_WAIT_DEFAULT_INTERVAL_SECONDS,
6382
+ isTerminal: isTerminalTableActionRunStatus,
6383
+ runKey: "actionRun",
6384
+ runIdKey: "actionRunId",
6385
+ timeoutCode: "table_action_run_wait_timeout",
6386
+ timeoutMessage: "Timed out waiting for table action run to finish.",
6387
+ timeoutDetailIdKey: "action_run_id",
6388
+ }));
6389
+ }
6390
+ // Domain sync is paginated under the server's Cloudflare request budget; the
6391
+ // CLI re-POSTs the same body while the server reports a partial sweep with a
6392
+ // next page, then prints one aggregated summary.
6393
+ async function runDomainsSync(options) {
6394
+ const body = options.full ? { full: true } : {};
6395
+ let totalSynced = 0;
6396
+ let totalPagesFetched = 0;
6397
+ let totalRdapResolved = 0;
6398
+ let totalDnsChecked = 0;
6399
+ let sawRdapResolved = false;
6400
+ let sawDnsChecked = false;
6401
+ let requestCount = 0;
6402
+ for (;;) {
6403
+ requestCount += 1;
6404
+ const latest = await requestOxygen("/api/cli/domains/sync", {
6405
+ method: "POST",
6406
+ body,
6407
+ });
6408
+ // `full` means "restart from page 1" server-side; only the first request may
6409
+ // carry it. Continuations must drop it so the server advances the saved
6410
+ // cursor instead of re-sweeping the first pages every iteration.
6411
+ delete body.full;
6412
+ totalSynced += readCount(latest.synced);
6413
+ totalPagesFetched += readCount(latest.pages_fetched);
6414
+ if (typeof latest.rdap_resolved === "number") {
6415
+ sawRdapResolved = true;
6416
+ totalRdapResolved += readCount(latest.rdap_resolved);
5762
6417
  }
5763
- const remainingMs = deadline - Date.now();
5764
- if (remainingMs <= 0) {
5765
- throw new OxygenError("table_ingestion_wait_timeout", "Timed out waiting for table ingestion run to finish.", {
5766
- details: {
5767
- ingestion_run_id: runId,
5768
- status: status ?? null,
5769
- timeout_seconds: timeoutSeconds,
5770
- polls,
5771
- },
5772
- exitCode: 1,
5773
- });
6418
+ if (typeof latest.dns_checked === "number") {
6419
+ sawDnsChecked = true;
6420
+ totalDnsChecked += readCount(latest.dns_checked);
5774
6421
  }
5775
- await sleep(Math.min(intervalSeconds * 1000, remainingMs));
5776
- }
5777
- }
5778
- async function waitForSearchRun(runId, options) {
5779
- const timeoutSeconds = readPositiveInt(options.timeoutSeconds)
5780
- ?? TABLE_INGESTION_WAIT_DEFAULT_TIMEOUT_SECONDS;
5781
- const intervalSeconds = readPositiveInt(options.intervalSeconds)
5782
- ?? TABLE_INGESTION_WAIT_DEFAULT_INTERVAL_SECONDS;
5783
- const startedAt = Date.now();
5784
- const deadline = startedAt + timeoutSeconds * 1000;
5785
- let polls = 0;
5786
- while (true) {
5787
- polls += 1;
5788
- const latestRun = await requestOxygen(`/api/cli/search/runs/${encodeURIComponent(runId)}`);
5789
- const status = readRecordString(latestRun, "status");
5790
- if (isTerminalTableIngestionStatus(status)) {
6422
+ const hasNextPage = latest.next_page !== undefined && latest.next_page !== null;
6423
+ const shouldContinue = latest.partial === true
6424
+ && hasNextPage
6425
+ && requestCount <= DOMAINS_SYNC_MAX_CONTINUATIONS;
6426
+ if (!shouldContinue) {
5791
6427
  return {
5792
- searchRun: latestRun,
5793
- searchRunId: readRecordString(latestRun, "id") ?? runId,
5794
- status,
5795
- terminal: true,
5796
- polls,
5797
- elapsedMs: Date.now() - startedAt,
5798
- ...(readRecordString(latestRun, "web_url") ? { web_url: readRecordString(latestRun, "web_url") } : {}),
6428
+ ...latest,
6429
+ synced: totalSynced,
6430
+ pages_fetched: totalPagesFetched,
6431
+ ...(sawRdapResolved ? { rdap_resolved: totalRdapResolved } : {}),
6432
+ ...(sawDnsChecked ? { dns_checked: totalDnsChecked } : {}),
6433
+ requests: requestCount,
5799
6434
  };
5800
6435
  }
5801
- const remainingMs = deadline - Date.now();
5802
- if (remainingMs <= 0) {
5803
- throw new OxygenError("search_run_wait_timeout", "Timed out waiting for search run to finish.", {
5804
- details: {
5805
- search_run_id: runId,
5806
- status: status ?? null,
5807
- timeout_seconds: timeoutSeconds,
5808
- polls,
5809
- },
5810
- exitCode: 1,
5811
- });
5812
- }
5813
- await sleep(Math.min(intervalSeconds * 1000, remainingMs));
6436
+ // A 429 mid-sync keeps completed pages and reports a pushback window.
6437
+ const retryAfterSeconds = readCount(latest.retry_after_seconds);
6438
+ if (retryAfterSeconds > 0)
6439
+ await sleep(retryAfterSeconds * 1000);
6440
+ }
6441
+ }
6442
+ async function runDomainsBuy(domains, options) {
6443
+ const quoteId = readOption(options.quote);
6444
+ if (options.approved && !quoteId) {
6445
+ throw new Error("--quote <id> is required with --approved. Run the same command without --approved first to get the preview and its quote_id.");
5814
6446
  }
6447
+ const data = await requestOxygen("/api/cli/domains/buy", {
6448
+ method: "POST",
6449
+ body: {
6450
+ domains,
6451
+ ...(options.autoRenew ? { auto_renew: true } : {}),
6452
+ ...(options.privacy === false ? { privacy: false } : {}),
6453
+ ...(options.approved ? { approved: true, quote_id: quoteId } : {}),
6454
+ ...(options.acceptPremium ? { accept_premium_pricing: true } : {}),
6455
+ },
6456
+ });
6457
+ if (!options.approved) {
6458
+ const previewQuoteId = readRecordString(data, "quote_id");
6459
+ return previewQuoteId
6460
+ ? { ...data, rerun_command: domainsBuyRerunCommand(domains, previewQuoteId, options, data) }
6461
+ : data;
6462
+ }
6463
+ if (!options.wait)
6464
+ return data;
6465
+ const pendingDomains = Array.isArray(data.results)
6466
+ ? data.results
6467
+ .filter((entry) => {
6468
+ const status = readRecordString(entry, "status");
6469
+ return status === "submitting" || status === "in_progress";
6470
+ })
6471
+ .map((entry) => readRecordString(entry, "domain"))
6472
+ .filter((entry) => entry !== null)
6473
+ : [];
6474
+ if (pendingDomains.length === 0)
6475
+ return data;
6476
+ const waitResults = [];
6477
+ for (const domain of pendingDomains) {
6478
+ waitResults.push(await waitForDomainRegistration(domain));
6479
+ }
6480
+ return { ...data, wait: { waited: pendingDomains.length, results: waitResults } };
6481
+ }
6482
+ function domainsBuyRerunCommand(domains, quoteId, options, data) {
6483
+ // The buy preview returns items and requires_accept_premium_pricing at the
6484
+ // TOP level of data (there is no preview wrapper); reading data.preview here
6485
+ // drops the premium ack flag and makes a copy-paste of the rerun command 422.
6486
+ const items = Array.isArray(data.items) ? data.items : [];
6487
+ const needsPremiumAck = options.acceptPremium === true
6488
+ || data.requires_accept_premium_pricing === true
6489
+ || items.some((item) => readRecordString(item, "tier") === "premium");
6490
+ const flags = [
6491
+ "--approved",
6492
+ `--quote ${quoteId}`,
6493
+ ...(needsPremiumAck ? ["--accept-premium"] : []),
6494
+ ...(options.autoRenew ? ["--auto-renew"] : []),
6495
+ ...(options.privacy === false ? ["--no-privacy"] : []),
6496
+ ];
6497
+ return `${resolveCliBinaryName()} domains buy ${domains.join(" ")} ${flags.join(" ")}`;
6498
+ }
6499
+ // succeeded/failed are terminal; action_required stops the wait too because
6500
+ // only the user (in the Cloudflare dashboard) can move it forward.
6501
+ function isSettledDomainRegistrationStatus(status) {
6502
+ return status === "succeeded"
6503
+ || status === "failed"
6504
+ || status === "action_required";
5815
6505
  }
5816
- async function waitForTableActionRun(runId, options) {
5817
- const timeoutSeconds = readPositiveInt(options.timeoutSeconds)
5818
- ?? TABLE_ACTION_RUN_WAIT_DEFAULT_TIMEOUT_SECONDS;
5819
- const intervalSeconds = readPositiveInt(options.intervalSeconds)
5820
- ?? TABLE_ACTION_RUN_WAIT_DEFAULT_INTERVAL_SECONDS;
6506
+ async function waitForDomainRegistration(domain) {
5821
6507
  const startedAt = Date.now();
5822
- const deadline = startedAt + timeoutSeconds * 1000;
6508
+ const deadline = startedAt + DOMAIN_REGISTRATION_WAIT_TIMEOUT_SECONDS * 1000;
5823
6509
  let polls = 0;
5824
- while (true) {
5825
- polls += 1;
5826
- const latestRun = await requestOxygen(`/api/cli/table-action-runs/${encodeURIComponent(runId)}`);
5827
- const status = readRecordString(latestRun, "status");
5828
- if (isTerminalTableActionRunStatus(status)) {
6510
+ let lastStatus = null;
6511
+ let lastRegistration = null;
6512
+ for (;;) {
6513
+ let delayMs = DOMAIN_REGISTRATION_WAIT_INTERVAL_SECONDS * 1000;
6514
+ try {
6515
+ polls += 1;
6516
+ const latest = await requestOxygen(`/api/cli/domains/registrations/${encodeURIComponent(domain)}`);
6517
+ lastRegistration = readRecord(latest, "registration");
6518
+ lastStatus = readRecordString(lastRegistration, "status");
6519
+ if (isSettledDomainRegistrationStatus(lastStatus)) {
6520
+ if (lastStatus === "action_required") {
6521
+ process.stderr.write(`hint: ${domain} needs action in the Cloudflare dashboard before registration can finish.\n`);
6522
+ }
6523
+ return {
6524
+ domain,
6525
+ status: lastStatus,
6526
+ terminal: true,
6527
+ polls,
6528
+ elapsedMs: Date.now() - startedAt,
6529
+ ...(lastRegistration ? { registration: lastRegistration } : {}),
6530
+ ...(readRecordString(latest, "deepLink") ? { deepLink: readRecordString(latest, "deepLink") } : {}),
6531
+ };
6532
+ }
6533
+ }
6534
+ catch (error) {
6535
+ // Honor the server's rate-limit pushback instead of failing the wait.
6536
+ const retryAfterSeconds = error instanceof OxygenError
6537
+ ? readDetailsNumber(error.details, "retry_after_seconds")
6538
+ : null;
6539
+ if (retryAfterSeconds === null)
6540
+ throw error;
6541
+ delayMs = Math.max(delayMs, retryAfterSeconds * 1000);
6542
+ }
6543
+ const remainingMs = deadline - Date.now();
6544
+ if (remainingMs <= 0) {
6545
+ process.stderr.write(`hint: registration for ${domain} is still ${lastStatus ?? "pending"}; the background worker keeps polling — re-run \`${resolveCliBinaryName()} domains registration-status ${domain} --wait\` later.\n`);
5829
6546
  return {
5830
- actionRun: latestRun,
5831
- actionRunId: readRecordString(latestRun, "id") ?? runId,
5832
- status,
5833
- terminal: true,
6547
+ domain,
6548
+ status: lastStatus,
6549
+ terminal: false,
6550
+ timed_out: true,
5834
6551
  polls,
5835
6552
  elapsedMs: Date.now() - startedAt,
5836
- ...(readRecordString(latestRun, "web_url") ? { web_url: readRecordString(latestRun, "web_url") } : {}),
6553
+ ...(lastRegistration ? { registration: lastRegistration } : {}),
5837
6554
  };
5838
6555
  }
5839
- const remainingMs = deadline - Date.now();
5840
- if (remainingMs <= 0) {
5841
- throw new OxygenError("table_action_run_wait_timeout", "Timed out waiting for table action run to finish.", {
5842
- details: {
5843
- action_run_id: runId,
5844
- status: status ?? null,
5845
- timeout_seconds: timeoutSeconds,
5846
- polls,
5847
- },
5848
- exitCode: 1,
5849
- });
5850
- }
5851
- await sleep(Math.min(intervalSeconds * 1000, remainingMs));
6556
+ await sleep(Math.min(delayMs, remainingMs));
5852
6557
  }
5853
6558
  }
5854
6559
  async function exportRows(table, options) {
@@ -5877,6 +6582,7 @@ async function exportRows(table, options) {
5877
6582
  const TABLE_BUNDLE_SCHEMA_VERSION = 1;
5878
6583
  const TABLE_BUNDLE_MAX_PAGE_SIZE = 1000;
5879
6584
  const TABLE_BUNDLE_DEFAULT_PAGE_SIZE = 500;
6585
+ // skipcq: JS-R1005 — intentional branching across describe/query pagination, column-definition normalization, and output sink (stdout/file)
5880
6586
  async function exportTableBundle(table, options) {
5881
6587
  const pageSize = Math.min(readPositiveInt(options.pageSize) ?? TABLE_BUNDLE_DEFAULT_PAGE_SIZE, TABLE_BUNDLE_MAX_PAGE_SIZE);
5882
6588
  // Pull the canonical schema (incl. enrichment/tool definitions and
@@ -6787,9 +7493,9 @@ async function handleProfilesCurrentAction(options) {
6787
7493
  }
6788
7494
  }
6789
7495
  function shellQuote(value) {
6790
- if (/^[A-Za-z0-9_.\-:\/@%+=]+$/.test(value))
7496
+ if (/^[A-Za-z0-9_.\-:/@%+=]+$/.test(value))
6791
7497
  return value;
6792
- return `'${value.replace(/'/g, `'\\''`)}'`;
7498
+ return `'${value.replace(/'/g, "'\\''")}'`;
6793
7499
  }
6794
7500
  async function handleLogoutAction(options) {
6795
7501
  try {
@@ -6815,7 +7521,7 @@ async function handleLogoutAction(options) {
6815
7521
  process.exitCode = error instanceof OxygenError ? error.exitCode : 1;
6816
7522
  }
6817
7523
  }
6818
- async function handleUpdateAction(options) {
7524
+ function handleUpdateAction(options) {
6819
7525
  try {
6820
7526
  const result = updateCli(options);
6821
7527
  if (options.json) {
@@ -7472,7 +8178,7 @@ function describeProfileSource(source) {
7472
8178
  case "flag": return "from --profile flag";
7473
8179
  case "env": return "from OXYGEN_PROFILE";
7474
8180
  case "file": return "from stored active profile";
7475
- case "default": return "default fallback";
8181
+ default: return "default fallback";
7476
8182
  }
7477
8183
  }
7478
8184
  function formatLogoutSuccess(result) {
@@ -7534,8 +8240,8 @@ function renderBox(lines) {
7534
8240
  return [border, ...body, border].join("\n");
7535
8241
  }
7536
8242
  function visibleLength(value) {
7537
- // skipcq: JS-0004 — ESC (\x1b) is the ANSI CSI introducer; required to strip color codes
7538
- return value.replace(/\x1b\[[0-9;]*m/g, "").length;
8243
+ // skipcq: JS-0004, JS-W1035 — ESC (\x1b) is the ANSI CSI introducer; the literal escape is required to strip color codes
8244
+ return value.replace(/\x1b\[[0-9;]*m/gu, "").length;
7539
8245
  }
7540
8246
  function ansi(enabled) {
7541
8247
  const wrap = (open, close) => enabled
@@ -7593,14 +8299,6 @@ function buildFeedbackBody(options) {
7593
8299
  }
7594
8300
  return body;
7595
8301
  }
7596
- function splitCsvOption(value) {
7597
- if (!value)
7598
- return [];
7599
- return value
7600
- .split(",")
7601
- .map((entry) => entry.trim())
7602
- .filter((entry) => entry.length > 0);
7603
- }
7604
8302
  function buildSupportTicketBody(options) {
7605
8303
  const body = { subject: readOption(options.subject) };
7606
8304
  if (readOption(options.body))
@@ -7614,9 +8312,9 @@ function buildSupportTicketBody(options) {
7614
8312
  context.operation = readOption(options.operation);
7615
8313
  if (readOption(options.errorCode))
7616
8314
  context.error_code = readOption(options.errorCode);
7617
- const runIds = splitCsvOption(options.runIds);
7618
- const tableIds = splitCsvOption(options.tableIds);
7619
- const deepLinks = splitCsvOption(options.deepLinks);
8315
+ const runIds = readCsvOption(options.runIds);
8316
+ const tableIds = readCsvOption(options.tableIds);
8317
+ const deepLinks = readCsvOption(options.deepLinks);
7620
8318
  if (runIds.length)
7621
8319
  context.run_ids = runIds;
7622
8320
  if (tableIds.length)
@@ -7647,12 +8345,6 @@ function readCsvOption(value) {
7647
8345
  .map((entry) => entry.trim())
7648
8346
  .filter(Boolean);
7649
8347
  }
7650
- function splitCsv(value) {
7651
- return value
7652
- .split(",")
7653
- .map((entry) => entry.trim())
7654
- .filter(Boolean);
7655
- }
7656
8348
  // Assemble the optional campaign email binding from the --email-* flags. The
7657
8349
  // content spec (--email-definition-file) is the author-provided email sequence
7658
8350
  // that the API compiles to an Instantly campaign on start; provider/connection
@@ -7665,7 +8357,7 @@ function readCampaignEmailBinding(options) {
7665
8357
  if (!provider && !connectionId && !definitionPath)
7666
8358
  return undefined;
7667
8359
  const definition = definitionPath
7668
- ? JSON.parse(readFileSync(resolve(definitionPath), "utf8"))
8360
+ ? readJsonFileValue(resolve(definitionPath), "--email-definition-file")
7669
8361
  : undefined;
7670
8362
  return {
7671
8363
  ...(provider ? { provider } : {}),
@@ -7685,7 +8377,7 @@ slug, options) {
7685
8377
  if (filePath) {
7686
8378
  const fs = await import("node:fs/promises");
7687
8379
  const raw = await fs.readFile(filePath, "utf8");
7688
- body.envelope = JSON.parse(raw);
8380
+ body.envelope = parseJsonValue(raw, "--file");
7689
8381
  }
7690
8382
  const fromUrl = readOption(options.fromUrl);
7691
8383
  if (fromUrl) {
@@ -7700,12 +8392,7 @@ slug, options) {
7700
8392
  }
7701
8393
  const inputJson = readOption(options.inputJson);
7702
8394
  if (inputJson) {
7703
- try {
7704
- body.inputs = JSON.parse(inputJson);
7705
- }
7706
- catch {
7707
- throw new Error("--input-json must be valid JSON.");
7708
- }
8395
+ body.inputs = parseJsonValue(inputJson, "--input-json");
7709
8396
  }
7710
8397
  const tableRefEntries = Array.isArray(options.tableRef) ? options.tableRef : [];
7711
8398
  if (tableRefEntries.length > 0) {
@@ -7839,6 +8526,7 @@ table, options) {
7839
8526
  ? { phone_waterfall_profile: readOption(options.phoneWaterfallProfile) }
7840
8527
  : {}),
7841
8528
  ...(options.verifyPhone ? { verify_phone: true } : {}),
8529
+ ...(options.allowPremiumLanes ? { allow_premium_lanes: true } : {}),
7842
8530
  ...(readOption(options.phoneVerificationCredentialMode)
7843
8531
  ? { phone_verification_credential_mode: readOption(options.phoneVerificationCredentialMode) }
7844
8532
  : {}),