@oxygen-agent/cli 1.936.1 → 1.948.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 (33) hide show
  1. package/README.md +1 -1
  2. package/dist/command-manifest.js +24 -2
  3. package/dist/help.js +1 -0
  4. package/dist/index.js +342 -54
  5. package/dist/ugc-commands.js +353 -12
  6. package/node_modules/@oxygen/shared/dist/byok-connect.d.ts +11 -6
  7. package/node_modules/@oxygen/shared/dist/byok-connect.js +14 -6
  8. package/node_modules/@oxygen/shared/dist/capability-discovery.js +53 -5
  9. package/node_modules/@oxygen/shared/dist/email-dsn.d.ts +60 -0
  10. package/node_modules/@oxygen/shared/dist/email-dsn.js +120 -0
  11. package/node_modules/@oxygen/shared/dist/email-warmup-readiness.d.ts +64 -0
  12. package/node_modules/@oxygen/shared/dist/email-warmup-readiness.js +90 -0
  13. package/node_modules/@oxygen/shared/dist/index.d.ts +6 -0
  14. package/node_modules/@oxygen/shared/dist/index.js +6 -0
  15. package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.d.ts +50 -21
  16. package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.js +47 -21
  17. package/node_modules/@oxygen/shared/dist/langfuse.d.ts +8 -3
  18. package/node_modules/@oxygen/shared/dist/langfuse.js +177 -130
  19. package/node_modules/@oxygen/shared/dist/llm-payload.d.ts +10 -0
  20. package/node_modules/@oxygen/shared/dist/llm-payload.js +54 -0
  21. package/node_modules/@oxygen/shared/dist/llm-usage.d.ts +11 -0
  22. package/node_modules/@oxygen/shared/dist/llm-usage.js +30 -0
  23. package/node_modules/@oxygen/shared/dist/product-analytics-core.d.ts +98 -0
  24. package/node_modules/@oxygen/shared/dist/product-analytics-core.js +159 -0
  25. package/node_modules/@oxygen/shared/dist/product-analytics-environment.d.ts +18 -0
  26. package/node_modules/@oxygen/shared/dist/product-analytics-environment.js +46 -0
  27. package/node_modules/@oxygen/shared/dist/product-analytics-events.d.ts +92 -0
  28. package/node_modules/@oxygen/shared/dist/product-analytics-events.js +96 -0
  29. package/node_modules/@oxygen/shared/dist/ugc.d.ts +21 -1
  30. package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
  31. package/node_modules/@oxygen/shared/dist/version.js +1 -1
  32. package/node_modules/@oxygen/workflows/dist/graph/lint.js +22 -0
  33. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -200,8 +200,13 @@ function buildFindBody(capability, options) {
200
200
  }
201
201
  if (options.mode)
202
202
  body.mode = options.mode;
203
- const maxCredits = readPositiveNumber(options.maxCredits);
204
- if (maxCredits)
203
+ // Company only: 0 is a real ceiling meaning "run only the zero-credit lanes".
204
+ // The person capabilities are paid on every lane, so 0 stays a usage error
205
+ // there. `!== undefined` because 0 is falsy and would otherwise be dropped.
206
+ const maxCredits = capability === "company"
207
+ ? readCreditCeilingOrZero(options.maxCredits)
208
+ : readPositiveNumber(options.maxCredits);
209
+ if (maxCredits !== undefined)
205
210
  body.max_credits = maxCredits;
206
211
  // Phone-only opt-in; the route ignores it for other capabilities.
207
212
  if (options.verify)
@@ -443,6 +448,33 @@ function emitCliFailure(command, error) {
443
448
  writeMaxCreditsHint(error);
444
449
  process.exitCode = error instanceof OxygenError ? exitCodeForOxygenError(error) : 1;
445
450
  }
451
+ // A post's engagers list is the one payload in this tree that is routinely
452
+ // thousands of lines long, and the field that decides whether you may act on it
453
+ // — `truncated` — is one boolean inside it. Printed raw, a partial audience
454
+ // looks exactly like a whole one until you scroll past every person in it. So
455
+ // lead with the receipt on stderr (the same stdout/stderr split the dry-run and
456
+ // credits notices use, leaving stdout a clean envelope) and say plainly what to
457
+ // do about a short read. `--json` callers are untouched: they read the fields.
458
+ function writeEngagersReceipt(data) {
459
+ if (!data || typeof data !== "object" || Array.isArray(data))
460
+ return;
461
+ const record = data;
462
+ const pages = isRecord(record.pages_read) ? record.pages_read : {};
463
+ const line = (text) => process.stderr.write(`${text}\n`);
464
+ line(`Post ${String(record.post ?? "?")}`);
465
+ line(` ${String(record.total_engagers ?? 0)} engagers`
466
+ + ` (${String(record.reactors_count ?? 0)} reactions, ${String(record.commenters_count ?? 0)} comments)`
467
+ + ` from ${String(pages.reactions ?? 0)}+${String(pages.comments ?? 0)} pages`);
468
+ if (record.truncated === true) {
469
+ const reason = typeof record.partial_reason === "string" ? record.partial_reason : "unknown";
470
+ line(` PARTIAL (${reason}) — this post has more engagers than were read.`);
471
+ line(reason === "max_pages"
472
+ ? " Raise --max-pages (max 20), or run `oxygen engagement harvest` to walk the whole post into a table."
473
+ : " Retry, or run `oxygen engagement harvest` to walk the whole post into a table.");
474
+ }
475
+ if (typeof record.web_url === "string")
476
+ line(` ${record.web_url}`);
477
+ }
446
478
  // A dry run's stdout envelope looks like a successful result — same shape, same
447
479
  // `ok: true` — so in a terminal the only tell that nothing was fetched was
448
480
  // `meta.mode` buried inside the payload. That is how a working provider key gets
@@ -5013,7 +5045,8 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5013
5045
  .description("Source EXTERNAL market signals into a table: hiring, technology adoption, funding, acquisitions, and news. This is the sourcing half of Signals — `signals list` reads the events already captured for your workspace.")
5014
5046
  .addCommand(new Command("plan")
5015
5047
  .description("Compile a signal-sourcing request into ordered provider routes without provider calls: the chain, per-route applied/dropped filters, credit estimate, table blueprint, and whether the route can be kept LIVE on a cadence. Free.")
5016
- .requiredOption("--prompt <text-or-file>", "Signal-sourcing prompt, or a path to a prompt file.")
5048
+ .argument("[prompt]", "Prompt text or @file (same as --prompt). The --prompt flag wins if both are given.")
5049
+ .option("--prompt <text-or-file>", "Signal-sourcing prompt, or a path to a prompt file.")
5017
5050
  .requiredOption("--family <family>", "Signal family: hiring, tech, funding, acquisition, news, or job_change.")
5018
5051
  .option("--scope <scope>", "market (discover new companies) or watch_list (track companies you name via --domains). Defaults per family.")
5019
5052
  .option("--target-count <n>", "Desired row count for routing and estimates.")
@@ -5027,14 +5060,15 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5027
5060
  .option("--filters-json <json-or-file>", "Filters JSON inline or a path to a JSON file; wins over individual flags per top-level filter path.")
5028
5061
  .option("--estimate", "Run the free server-side preflight pass: resolves provider enum values and, where a provider publishes one, a free match count (zero credits).")
5029
5062
  .option("--json", "Print a JSON envelope.")
5030
- .action(async (options) => {
5063
+ .action(async (promptArg, options) => {
5031
5064
  await handleAsyncAction("signals search plan", options, () => requestOxygen("/api/cli/signals/search/plan", {
5032
5065
  method: "POST",
5033
- body: readSignalsSearchPlanBody(options),
5066
+ body: readSignalsSearchPlanBody(options, promptArg),
5034
5067
  }));
5035
5068
  }))
5036
5069
  .addCommand(new Command("run")
5037
5070
  .description("Return a dry-run request or queue a live signal-search ingestion run. Live requires --approved and --max-credits. Add --bind-feed --every to also bind a pull feed to the same table in the same call, so the table keeps refilling on a cadence.")
5071
+ .argument("[prompt]", "Prompt text or @file (same as --prompt); requires --family. The --prompt flag wins if both are given.")
5038
5072
  .option("--prompt <text-or-file>", "Signal-sourcing prompt, or a path to a prompt file. Requires --family.")
5039
5073
  .option("--plan-json <json-or-file>", "Plan JSON returned by signals search plan, or a path to a JSON file.")
5040
5074
  .option("--family <family>", "Signal family when planning from --prompt: hiring, tech, funding, acquisition, news, or job_change.")
@@ -5062,10 +5096,10 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5062
5096
  .option("--max-credits-per-cycle <n>", "Credit ceiling PER sync cycle for the bound feed.")
5063
5097
  .option("--max-rows-per-cycle <n>", "Advisory row ceiling per sync cycle for the bound feed.")
5064
5098
  .option("--json", "Print a JSON envelope.")
5065
- .action(async (options) => {
5099
+ .action(async (promptArg, options) => {
5066
5100
  await handleAsyncAction("signals search run", options, () => requestOxygen("/api/cli/signals/search/run", {
5067
5101
  method: "POST",
5068
- body: readSignalsSearchRunBody(options),
5102
+ body: readSignalsSearchRunBody(options, promptArg),
5069
5103
  }));
5070
5104
  })));
5071
5105
  const tablesCommand = program
@@ -5478,6 +5512,63 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5478
5512
  });
5479
5513
  });
5480
5514
  }));
5515
+ const watcherCommand = tablesCommand.command("watcher")
5516
+ .description("LinkedIn Profile Watcher: one editable table of daily engagers, source attribution, and current employers from posts in the past 7 days. Start with preview (free); create/resume activate paid daily monitoring after exact approval.");
5517
+ watcherCommand.command("get")
5518
+ .description("Read a table's LinkedIn Profile Watcher configuration and status. Free.")
5519
+ .argument("<table>", "Watcher table id or slug.")
5520
+ .option("--json", "Print a JSON envelope.")
5521
+ .action(async (table, options) => {
5522
+ await handleAsyncAction("tables watcher get", options, () => requestOxygen(`/api/cli/tables/linkedin-profile-watcher?table=${encodeURIComponent(table)}`, { method: "GET" }));
5523
+ });
5524
+ for (const action of ["preview", "create", "update", "pause", "resume"]) {
5525
+ const command = watcherCommand.command(action)
5526
+ .description({
5527
+ preview: "Free configuration and credit preview: no provider calls, writes, or scheduling. Omit --max-credits for a recommendation and preview hash. Ask for real profile URLs; never invent them.",
5528
+ create: "Create and activate a LinkedIn Profile Watcher after approval of its exact preview. Starts collection now and daily at 07:00 UTC under the approved per-cycle cap.",
5529
+ update: "Edit watched profiles or the per-cycle cap from the table. Preview the changes first; approval binds the exact new configuration.",
5530
+ pause: "Pause daily monitoring while retaining the table and collected rows.",
5531
+ resume: "Resume paid daily monitoring after reviewing a fresh preview and approving its per-cycle credit cap.",
5532
+ }[action])
5533
+ .option("--table <table>", "Existing watcher table id or slug; required for get/update/pause/resume.")
5534
+ .option("--json", "Print a JSON envelope.");
5535
+ if (action !== "pause") {
5536
+ command
5537
+ .option("--name <name>", "Watcher table display name.")
5538
+ .option("--project <project>", "Project id or slug; defaults to General.")
5539
+ .option("--profiles-json <json>", "JSON array of 1–10 real public LinkedIn profile URLs; replaces the watched list.")
5540
+ .option("--max-credits <credits>", "Hard credit ceiling for each daily cycle; never a monthly ceiling.");
5541
+ if (action !== "preview")
5542
+ command
5543
+ .option("--preview-hash <hash>", "Exact configuration hash returned by preview; re-preview after any change.")
5544
+ .option("--approved", "Approve this exact configuration and recurring per-cycle spending.");
5545
+ }
5546
+ if (action === "create") {
5547
+ command.requiredOption("--request-id <uuid>", "One stable UUID for this new watcher. Reuse it and the identical configuration after a timeout; never generate a new retry key.");
5548
+ }
5549
+ command.action(async (options) => {
5550
+ await handleAsyncAction(`tables watcher ${action}`, options, () => {
5551
+ const profiles = options.profilesJson === undefined ? undefined : parseJsonArray(options.profilesJson);
5552
+ if (profiles !== undefined && profiles.some((profile) => typeof profile !== "string")) {
5553
+ throw new OxygenError("invalid_input", "--profiles-json must be an array of LinkedIn profile URL strings.");
5554
+ }
5555
+ return requestOxygen("/api/cli/tables/linkedin-profile-watcher", {
5556
+ method: "POST",
5557
+ body: {
5558
+ action,
5559
+ ...(readOption(options.table) ? { table: readOption(options.table) } : {}),
5560
+ ...(readOption(options.name) ? { name: readOption(options.name) } : {}),
5561
+ ...(readOption(options.project) ? { project: readOption(options.project) } : {}),
5562
+ ...(profiles !== undefined ? { profiles } : {}),
5563
+ ...(options.maxCredits !== undefined ? { max_credits_per_cycle: readPositiveNumber(options.maxCredits) } : {}),
5564
+ ...(readOption(options.requestId) ? { request_id: readOption(options.requestId) } : {}),
5565
+ ...(readOption(options.previewHash) ? { preview_hash: readOption(options.previewHash) } : {}),
5566
+ ...(options.approved ? { approved: true } : {}),
5567
+ },
5568
+ });
5569
+ });
5570
+ });
5571
+ }
5481
5572
  tablesCommand.addCommand(new Command("relate")
5482
5573
  .description("Relate two tables: define empty Tables-owned relation columns on the source and target. Then use `oxygen tables link` to populate row-to-row edges. Works on any workspace table; plain tables stay plain and are never registered as CRM objects. Defaults to dry-run.")
5483
5574
  .argument("<table>", "Source table id or slug.")
@@ -7468,10 +7559,11 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7468
7559
  .argument("<table>", "Table id or slug.")
7469
7560
  .option("--preset <preset>", "Add a pre-built enrichment bundle instead of one column: `person_enrich` (one LinkedIn profile lookup, then headline, bio, location and followers for free) or `company_enrich` (domain, LinkedIn page, headcount, industry and full profile, cascading across providers until one answers). Oxygen finds the identity column itself — override with --input. Creating the columns is free; run them afterwards, --dry-run first.")
7470
7561
  .option("--input <slot=column...>", "Bind a preset input to an exact column, e.g. --input url=linkedin_url, or --input company_name=account --input domain=website. Repeatable. Only needed when the automatic match is wrong or missing.", collectRepeatable, [])
7471
- .option("--label <label>", "Display label for the new column. Required unless --prompt-key supplies a default title.")
7562
+ .option("--capability <capability>", "Seed a ready-to-run enrichment column WITHOUT running it (0 credits): verify_email grades the address a row already holds \u2014 MillionVerifier first, catch-all domains escalate to BounceBan; work_email, mobile_phone and linkedin_url find a value the row is missing through the managed waterfall. Sets kind=enrichment and jsonb; label and key default from the capability. Preview cost with `enrich-column preview --capability <same>` and run later with `enrich-column run --approved --max-credits <n>`.")
7563
+ .option("--label <label>", "Display label for the new column. Required unless --prompt-key or --capability supplies a default title.")
7472
7564
  .option("--key <key>", "Optional stable column key. Defaults to a normalized label.")
7473
7565
  .option("--data-type <type>", "Column data type: text, numeric, boolean, jsonb, or timestamptz.")
7474
- .option("--kind <kind>", "Column kind: manual, research, ai, formula, enrichment, tool, bind, or lookup. Defaults to manual. Use research for anything you would look up on the web. `lookup` reads a value out of another table; to LINK two tables row-to-row use `oxygen tables relate` instead \u2014 relation columns are two-sided and cannot be added here.")
7566
+ .option("--kind <kind>", "Column kind: manual, research, ai, formula, enrichment, tool, bind, or lookup. Defaults to manual. Use research for anything you would look up on the web. Enrichment columns always hold a jsonb cell, and --data-type is set for you \u2014 use --capability to seed a ready-to-run one. `lookup` reads a value out of another table; to LINK two tables row-to-row use `oxygen tables relate` instead \u2014 relation columns are two-sided and cannot be added here.")
7475
7567
  .option("--semantic-type <type>", "Optional semantic type such as company_domain.")
7476
7568
  .option("--definition-json <json>", "Optional JSON object with column definition metadata.")
7477
7569
  .option("--prompt <text-or-file>", "AI or research column prompt, or a path to a prompt file — a value that resolves to a readable file is read as one, matching --prompt everywhere else in this CLI. On its own it sets kind=ai; pair it with --kind research to search the web per row instead. If the prompt names its output sections — a line reading 'Return the following sections:' followed by 'Score: ...', 'Reasoning: ...' — the column answers in exactly that shape and each section becomes a referenceable sub-column; otherwise it answers in plain text. Use --no-structured-output to keep it plain text either way. Reference other columns inline as {{column_key}} — no --input-mapping needed; unknown keys are rejected here instead of failing per row. Merges into --definition-json (the escape hatch for everything else); a `prompt` in both is an error.")
@@ -7505,6 +7597,36 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7505
7597
  // skipcq: JS-R1005 — intentional per-option branching to assemble the columns-add request body
7506
7598
  .action(async (table, options) => {
7507
7599
  await handleAsyncAction("columns add", options, async () => {
7600
+ // --capability seeds a COMPLETE enrichment column server-side (kind,
7601
+ // jsonb data type, intent, the default provider order, label and
7602
+ // key), so any flag that authors a different column is a conflict,
7603
+ // not a modifier. Caught here rather than server-side so the user
7604
+ // never gets back a column they did not ask for.
7605
+ const capability = readOption(options.capability);
7606
+ if (capability) {
7607
+ const conflicting = [
7608
+ readOption(options.preset) ? "--preset" : null,
7609
+ readOption(options.prompt) ? "--prompt" : null,
7610
+ readOption(options.promptKey) ? "--prompt-key" : null,
7611
+ readOption(options.bindObject) ? "--bind-object" : null,
7612
+ readOption(options.bindMap) ? "--bind-map" : null,
7613
+ options.bindCreate ? "--bind-create" : null,
7614
+ readOption(options.lookupTable) ? "--lookup-table" : null,
7615
+ readOption(options.lookupMatch) ? "--lookup-match" : null,
7616
+ readOption(options.lookupMode) ? "--lookup-mode" : null,
7617
+ readOption(options.lookupReturn) ? "--lookup-return" : null,
7618
+ readOption(options.lookupOrder) ? "--lookup-order" : null,
7619
+ readOption(options.lookupAggregate) ? "--lookup-aggregate" : null,
7620
+ readOption(options.lookupNormalize) ? "--lookup-normalize" : null,
7621
+ ].filter((flag) => flag !== null);
7622
+ if (conflicting.length > 0) {
7623
+ throw new OxygenError("invalid_request", `--capability ${capability} seeds a complete enrichment column, so it cannot be combined with ${conflicting.join(", ")}. Drop one of the two.`, { exitCode: 1 });
7624
+ }
7625
+ const capabilityKind = readOption(options.kind)?.toLowerCase() ?? null;
7626
+ if (capabilityKind && capabilityKind !== "enrichment") {
7627
+ throw new OxygenError("invalid_request", `--capability authors an enrichment column, but --kind ${capabilityKind} was requested. Drop --kind, or drop --capability.`, { exitCode: 1 });
7628
+ }
7629
+ }
7508
7630
  // A preset names its own columns, so --label does not apply to it.
7509
7631
  const preset = readOption(options.preset);
7510
7632
  if (preset) {
@@ -7522,7 +7644,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7522
7644
  body: presetBody,
7523
7645
  });
7524
7646
  }
7525
- if (!options.promptKey && !options.label) {
7647
+ // --capability supplies its own label ("Email Verification", ...)
7648
+ // server-side, the same one the web picker writes.
7649
+ if (!options.promptKey && !capability && !options.label) {
7526
7650
  throw new OxygenError("invalid_request", "--label is required.", { exitCode: 1 });
7527
7651
  }
7528
7652
  if (options.promptKey && !options.inputMapping) {
@@ -7547,6 +7671,15 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7547
7671
  column.definition = parseJsonObject(options.definitionJson);
7548
7672
  const requestedKind = readOption(options.kind)?.toLowerCase() ?? null;
7549
7673
  const isResearch = requestedKind === "research";
7674
+ if (capability)
7675
+ column.capability = capability;
7676
+ // An enrichment cell is always the provider envelope, so the server
7677
+ // rejects any other data type ("Enrichment columns must use jsonb
7678
+ // data type."). Default it, the way --bind-object already does,
7679
+ // instead of making the caller bolt on --data-type jsonb.
7680
+ if (requestedKind === "enrichment" && !options.dataType) {
7681
+ column.data_type = "jsonb";
7682
+ }
7550
7683
  if (prompt !== null) {
7551
7684
  if (requestedKind && requestedKind !== "ai" && !isResearch) {
7552
7685
  throw new OxygenError("invalid_request", `--prompt authors an AI or research column, but --kind ${requestedKind} was requested. Drop --kind, or drop --prompt.`, { exitCode: 1 });
@@ -7652,7 +7785,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7652
7785
  .argument("<column>", "Column id or key.")
7653
7786
  .option("--row-id <row_id>", "Workspace row id to run. Get one from `oxygen tables query <table> --limit 1 --json` — the field is `_row_id`, not `id`.")
7654
7787
  .option("--limit <n>", "Run the next N rows whose target cell is still empty (--force runs the first N regardless). Repeat until rowCount is 0 to page through a table. Defaults to 10; inline deterministic runs have a hard cap of 25.")
7655
- .option("--all", "Run all rows. Requires --background.")
7788
+ .option("--all", "Run all rows. Requires --background, except with --dry-run, which previews the background run without queueing it.")
7656
7789
  .option("--filter-json <json>", "Row selector filter object or array for background runs. Do not combine with --all, --limit, or --row-id.")
7657
7790
  .option("--formula-values <mode>", "With --filter-json on a formula column, first refresh that selector with `columns run <table> <column> --force` (0 credits), then pass 'materialized'. The run filters the stored snapshot and persists this freshness acknowledgement.")
7658
7791
  .option("--force", "Run even when the target cell already has a value.")
@@ -7685,18 +7818,27 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7685
7818
  Boolean(readOption(options.rowId)),
7686
7819
  Boolean(effectiveFilterSelection),
7687
7820
  ].filter(Boolean).length;
7688
- if (selectedModes > 1) {
7689
- throw new OxygenError("invalid_selection", "Pass only one of --all, --limit, --row-id, or --filter-json.", {
7690
- exitCode: 1,
7691
- });
7692
- }
7693
- if (options.all && !options.background) {
7694
- throw new OxygenError("invalid_column_run", "--all requires --background.", {
7695
- exitCode: 1,
7696
- });
7697
- }
7698
7821
  // skipcq: JS-R1005 — intentional branching for local/background/filter column-run modes
7699
7822
  await handleAsyncAction("columns run", options, async () => {
7823
+ // Selection-shape errors are raised inside the action so --json
7824
+ // callers get the failure envelope, not a bare stack trace.
7825
+ if (selectedModes > 1) {
7826
+ throw new OxygenError("invalid_selection", "Pass only one of --all, --limit, --row-id, or --filter-json.", {
7827
+ exitCode: 1,
7828
+ });
7829
+ }
7830
+ // A dry run of an all-rows run previews the background run the live
7831
+ // command would create and queues nothing, so demanding --background
7832
+ // for it only teaches a flag with no behavioural basis (blind eval
7833
+ // 2026-09-10: the agent tripped the error, then re-ran with the flag).
7834
+ if (options.all && options.dryRun && !options.background && !options.local) {
7835
+ options.background = true;
7836
+ }
7837
+ if (options.all && !options.background) {
7838
+ throw new OxygenError("invalid_column_run", "--all requires --background.", {
7839
+ exitCode: 1,
7840
+ });
7841
+ }
7700
7842
  if (options.local) {
7701
7843
  if (options.background) {
7702
7844
  throw new OxygenError("invalid_column_run", "Pass either --local or --background, not both.", {
@@ -8539,7 +8681,8 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8539
8681
  .description("Plan, dry-run, or queue provider-backed company search.")
8540
8682
  .addCommand(new Command("plan")
8541
8683
  .description("Compile a company-search prompt into ordered provider routes without provider calls.")
8542
- .requiredOption("--prompt <text-or-file>", "Company-search prompt, or a path to a prompt file.")
8684
+ .argument("[prompt]", "Prompt text or @file (same as --prompt). The --prompt flag wins if both are given.")
8685
+ .option("--prompt <text-or-file>", "Company-search prompt, or a path to a prompt file.")
8543
8686
  .option("--target-count <n>", "Desired company count. Single-plan ceiling 50,000; larger requests return an explicit clamp warning and segmentation guidance.")
8544
8687
  .option("--source-intent <intent>", "Override detected intent: sizing, structured, lookalike, technology, hiring, local, known_source, concept, web, url, or fallback.")
8545
8688
  .option("--filters-json <json-or-file>", "CompanySearchFilters JSON inline or a @file/path; wins over individual flags per top-level filter path.")
@@ -8557,14 +8700,15 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8557
8700
  .option("--estimate", "Run a free server-side preflight pass: resolves provider enums and a free count probe for an estimated match count (zero credits).")
8558
8701
  .option("--materialize-preview", "Create a preview table with route rows.")
8559
8702
  .option("--json", "Print a JSON envelope.")
8560
- .action(async (options) => {
8703
+ .action(async (promptArg, options) => {
8561
8704
  await handleAsyncAction("companies search plan", options, () => requestOxygen("/api/cli/companies/search/plan", {
8562
8705
  method: "POST",
8563
- body: readCompaniesSearchPlanBody(options),
8706
+ body: readCompaniesSearchPlanBody(options, promptArg),
8564
8707
  }));
8565
8708
  }))
8566
8709
  .addCommand(new Command("run")
8567
8710
  .description("Return a dry-run request or queue a live company-search ingestion run.")
8711
+ .argument("[prompt]", "Prompt text or @file (same as --prompt). The --prompt flag wins if both are given.")
8568
8712
  .option("--prompt <text-or-file>", "Company-search prompt, or a path to a prompt file.")
8569
8713
  .option("--plan-json <json-or-file>", "Plan JSON returned by companies search plan, or a path to a JSON file.")
8570
8714
  .option("--route-id <id>", "Route id from the plan to execute.")
@@ -8592,10 +8736,10 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
8592
8736
  .option("--estimate", "Run a free server-side preflight pass when planning from --prompt: resolves provider enums and a free count probe (zero credits).")
8593
8737
  .option("--approved", "Required for live runs after inspecting dry-run output.")
8594
8738
  .option("--json", "Print a JSON envelope.")
8595
- .action(async (options) => {
8739
+ .action(async (promptArg, options) => {
8596
8740
  await handleAsyncAction("companies search run", options, () => requestOxygen("/api/cli/companies/search/run", {
8597
8741
  method: "POST",
8598
- body: readCompaniesSearchRunBody(options),
8742
+ body: readCompaniesSearchRunBody(options, promptArg),
8599
8743
  }));
8600
8744
  })))
8601
8745
  .addCommand(new Command("enrich")
@@ -9311,6 +9455,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9311
9455
  .description("Board of the PRIMARY managed external data providers (enrichment, people/company search, web search, scraping, signals, AI-column web grounding, LLM inference): per provider health probe, 7d traffic, balance, 30d/7d COGS, rate policy, spend ceilings, breaker, and posture, plus the platform runaway guards and how old each snapshot is. Reads the snapshots the crons write; --refresh re-runs those same zero-credit snapshots first. Staff only.")
9312
9456
  .option("--refresh", "Re-run the zero-credit snapshot work the three crons run (provider health probes, managed balances, cost snapshot) before reading the board. No paid provider call and no credit spend; takes up to a few minutes.")
9313
9457
  .option("--stages <csv>", "Limit --refresh to these stages: health, balances, costs. Defaults to all three.")
9458
+ .option("--force", "Refresh even inside the server's cooldown on re-probing every managed vendor. Only with --refresh.")
9314
9459
  .option("--json", "Print a JSON envelope.")
9315
9460
  .action(async (options) => {
9316
9461
  const stages = readCsvOption(options.stages);
@@ -9320,6 +9465,13 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9320
9465
  emitCliFailure("admin primary-providers", new OxygenError("invalid_request", "--stages only applies to --refresh. Add --refresh to re-run those snapshots.", { exitCode: 2 }));
9321
9466
  return;
9322
9467
  }
9468
+ if (options.force && !options.refresh) {
9469
+ // Same reason as --stages: --force only relaxes the refresh
9470
+ // cooldown, so on its own it reads the very board the caller
9471
+ // believes it just forced a re-probe of.
9472
+ emitCliFailure("admin primary-providers", new OxygenError("invalid_request", "--force only applies to --refresh. Add --refresh to re-run those snapshots.", { exitCode: 2 }));
9473
+ return;
9474
+ }
9323
9475
  let failed = false;
9324
9476
  const board = await requestOxygen("/api/cli/admin/primary-providers", options.refresh
9325
9477
  ? {
@@ -9330,7 +9482,11 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9330
9482
  // finishes — the operator reads a failure for work that
9331
9483
  // succeeded and re-runs the outbound probes.
9332
9484
  timeoutMs: 300_000,
9333
- body: { refresh: true, ...(stages.length > 0 ? { stages } : {}) },
9485
+ body: {
9486
+ refresh: true,
9487
+ ...(stages.length > 0 ? { stages } : {}),
9488
+ ...(options.force ? { force: true } : {}),
9489
+ },
9334
9490
  }
9335
9491
  : undefined).catch((error) => {
9336
9492
  emitCliFailure("admin primary-providers", error);
@@ -10455,11 +10611,20 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
10455
10611
  .option("--return <mode>", "Legacy response shape: raw, compact, or summary. Defaults to raw.")
10456
10612
  .option("--return-mode <mode>", "Response shape: raw, compact, or summary. Prefer summary for large search responses.")
10457
10613
  .option("--oxygen-cursor <cursor>", "Short Oxygen cursor returned as oxygen_next_cursor by a previous tool run.")
10458
- .option("--max-credits <n>", "Credit ceiling for live paid tools; not needed for no-bill tools.")
10614
+ .option("--max-credits <n>", "Credit ceiling for live paid tools; not needed for no-bill tools, and 0 is accepted for no-bill operations.")
10459
10615
  .option("--approved", "Required for live paid tools and external writes after inspecting dry-run output.")
10460
10616
  .option("--json", "Print a JSON envelope.")
10461
10617
  .action(async (toolId, options) => {
10462
- const maxCredits = readPositiveNumber(options.maxCredits);
10618
+ // Zero is a real ceiling here, not a typo: /api/cli/tools/run only
10619
+ // demands a positive max_credits for BILLED tools, so a documented
10620
+ // no-bill operation is legitimately run with --max-credits 0. The
10621
+ // positive-only reader rejected that locally, before the request.
10622
+ //
10623
+ // Non-negative NUMBER, not readPositiveNumberOrZero (a whole seat
10624
+ // count): this command's own dry-run hint prints "--max-credits
10625
+ // 24.94", so an integer reader would refuse the very command the CLI
10626
+ // just told the operator to run.
10627
+ const maxCredits = readNonNegativeNumber(options.maxCredits);
10463
10628
  await handleAsyncAction("tools run", options, () => requestOxygen("/api/cli/tools/run", {
10464
10629
  method: "POST",
10465
10630
  body: {
@@ -10612,7 +10777,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
10612
10777
  .option("--linkedin-url <url>", "Company LinkedIn URL.")
10613
10778
  .option("--fields <fields>", "Comma-separated company fields. Defaults to domain,linkedin_url,headcount,industry.")
10614
10779
  .option("--mode <mode>", "dry_run (default) previews the plan and spends nothing; live spends credits and returns the answer.")
10615
- .option("--max-credits <credits>", "Spend ceiling. Required for --mode live.")
10780
+ .option("--max-credits <credits>", "Spend ceiling. Required for --mode live; 0 runs only the zero-credit lanes (the priced lanes are skipped as credit_ceiling_reached).")
10616
10781
  .option("--json", "Print a JSON envelope.")
10617
10782
  .action(async (options) => {
10618
10783
  await handleAsyncAction("find company", options, () => requestOxygen("/api/cli/find/run", { method: "POST", body: buildFindBody("company", options) }));
@@ -10621,11 +10786,11 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
10621
10786
  .command("verify")
10622
10787
  .description("Check whether emails you already have are safe to send to. dry_run previews the provider chain for free; live spends and needs --max-credits.")
10623
10788
  .addCommand(new Command("email")
10624
- .description("Verify one or more email addresses. MillionVerifier answers first; addresses on catch-all (accept-all) domains, which it can only flag as risky, escalate automatically to BounceBan to get a real answer. Returns valid / invalid / catch_all / unknown per address.")
10789
+ .description("Verify one or more email addresses. MillionVerifier answers first; addresses on catch-all (accept-all) domains, which it can only flag as risky, escalate automatically to BounceBan to get a real answer. Returns valid / invalid / catch_all / unknown per address. An address whose escalation could not run keeps catch_all — record it as unconfirmed, never verified — and its escalation_unavailable_reason plus next_action say why and what to do: provider_account_dry on the managed lane is Oxygen's BounceBan balance, not your workspace credits, and you were not charged for it.")
10625
10790
  .argument("<emails...>", "One or more email addresses to verify.")
10626
10791
  .option("--mode <mode>", "dry_run (default) previews the plan and spends nothing; live spends credits and returns the answer.")
10627
10792
  .option("--approved", "Same as --mode live. Accepted because the global help footer names --approved as the way to authorize a credit-spending command.")
10628
- .option("--max-credits <credits>", "Spend ceiling for the whole run. Required for --mode live. Run the free dry_run first — it returns estimate.recommended_max_credits, the value that guarantees every catch-all address still gets escalated.")
10793
+ .option("--max-credits <credits>", "Spend ceiling for the whole run. Required for --mode live. Run the free dry_run first — it returns estimate.recommended_max_credits, the value that guarantees every catch-all address still gets escalated. Re-verifying the exact same address in this workspace replays MillionVerifier's first pass free for up to 30 days, so a repeat run can report credits_used 0 — the catch-all escalation is never cached, and its reservation is released in full when that call returns no verdict.")
10629
10794
  .option("--json", "Print a JSON envelope.")
10630
10795
  .action(async (emails, options) => {
10631
10796
  await handleAsyncAction("verify email", options, () => requestOxygen("/api/cli/verify/run", {
@@ -11473,7 +11638,46 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
11473
11638
  });
11474
11639
  })));
11475
11640
  program.addCommand(new Command("engagement")
11476
- .description("Capture LinkedIn intent from one known post (harvest needs its public URL or the composite social_id from `oxygen posts get`), and react or comment on it. Recurring capture — a post's engagers on a cadence, 'who viewed my profile', new followers, new connections — is a live table: arm it with `oxygen linkedin intent setup` and operate it with `oxygen feeds list|pause|resume|run`. For recurring competitor-profile monitoring that discovers future posts, start with `oxygen recipes list competitor --json`. Harvests run as a slow, durable drip under a conservative read budget.")
11641
+ .description("Capture LinkedIn intent from one known post (harvest needs its public URL or the composite social_id from `oxygen posts get`), and react or comment on it. Recurring capture — a post's engagers on a cadence, 'who viewed my profile', new followers, new connections — is a live table: arm it with `oxygen linkedin intent setup` and operate it with `oxygen feeds list|pause|resume|run`. For daily monitoring across profiles and their recent post engagers, start with `oxygen tables watcher preview --help` (free credit review; one editable table). Harvests run as a slow, durable drip under a conservative read budget.")
11642
+ .addCommand(new Command("engagers")
11643
+ .description("Read one post's reactors and commenters right now as a de-duplicated people list ready to enroll. Pages both sources up to --max-pages (default 5, max 20) x 100 per page; `truncated: true` in the envelope means a cap stopped a source that still had more — raise --max-pages, or use `oxygen engagement harvest` for a post too big to read in one request. Nothing is sent and no credits are spent, but every page is one LinkedIn read against the sender account. --post is the composite social_id from `oxygen posts get` (NOT the activity URN).")
11644
+ .requiredOption("--post <social_id>", "Composite post social_id from `oxygen posts get` (NOT the activity URN).")
11645
+ .option("--account <ref>", "Sender account that reads (sender id, connection id, or Unipile account id). Omit for the org default.")
11646
+ .option("--limit <n>", "Engagers per PAGE (default 100, which is also the provider maximum). Per source you get --limit x --max-pages.")
11647
+ .option("--max-pages <n>", "Pages to walk per source, 1-20 (default 5, so 500 reactors + 500 commenters).")
11648
+ .option("--no-reactions", "Skip reactors.")
11649
+ .option("--no-comments", "Skip commenters.")
11650
+ .option("--json", "Print a JSON envelope.")
11651
+ .action(async (options) => {
11652
+ await handleAsyncAction("engagement engagers", options, async () => {
11653
+ const post = readOption(options.post);
11654
+ if (!post)
11655
+ throw new Error("--post is required (the composite social_id from `oxygen posts get`).");
11656
+ const account = readOption(options.account);
11657
+ const limit = readPositiveInteger(options.limit);
11658
+ const maxPages = readPositiveInteger(options.maxPages);
11659
+ const params = new URLSearchParams({ post });
11660
+ if (account)
11661
+ params.set("account", account);
11662
+ if (limit !== undefined)
11663
+ params.set("limit", String(limit));
11664
+ // Forwarded as-is: the route owns the 1-20 range so an
11665
+ // out-of-range value fails loudly instead of silently reading
11666
+ // fewer pages than the caller asked for.
11667
+ if (maxPages !== undefined)
11668
+ params.set("max_pages", String(maxPages));
11669
+ if (options.reactions === false)
11670
+ params.set("include_reactions", "false");
11671
+ if (options.comments === false)
11672
+ params.set("include_comments", "false");
11673
+ const data = await requestOxygen(`/api/cli/linkedin/engagement?${params.toString()}`);
11674
+ // Ahead of the payload, and only for a human: a machine caller
11675
+ // reads truncated/partial_reason off the envelope itself.
11676
+ if (!options.json)
11677
+ writeEngagersReceipt(data);
11678
+ return data;
11679
+ });
11680
+ }))
11477
11681
  .addCommand(new Command("harvest")
11478
11682
  .description("Start (or re-arm) a harvest of a post's engagers into a workspace table you can enroll into a sequence. Engagers drip into the table over many ticks; poll `engagement status` to watch it fill. No messages are sent. Cookieless harvests spend Oxygen credits per scraper page and require --max-credits.")
11479
11683
  .requiredOption("--post <social_id_or_url>", "Composite post social_id from `oxygen posts get` (NOT the activity URN), or the public LinkedIn post URL for cookieless.")
@@ -13669,18 +13873,63 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13669
13873
  });
13670
13874
  }))
13671
13875
  .addCommand(new Command("remove")
13672
- .description("Remove a lead provider id from the org do-not-contact list (re-enable contact).")
13673
- .requiredOption("--lead <provider_id>", "The lead provider id to un-suppress.")
13674
- .option("--json", "Print a JSON envelope.")
13675
- .action(async (options) => {
13676
- await handleAsyncAction("suppressions remove", options, () => {
13677
- const lead = readOption(options.lead);
13678
- if (!lead)
13679
- throw new Error("--lead is required.");
13680
- return requestOxygen(`/api/cli/suppressions?lead_provider_id=${encodeURIComponent(lead)}`, {
13681
- method: "DELETE",
13876
+ .description("Remove one lead provider id (--lead), or every identity of one merged person (--subject-id), from the org do-not-contact list (re-enable contact). A subject id is stamped on each identifier of an imported contact: `hubspot:contacts:<id>` from `suppressions hubspot sync`, `manual:<uuid>` returned by `suppressions import-person`. Read it as metadata.dnc_subject_id in `suppressions list` (LinkedIn rows only) or in `suppressions addresses|phones|companies --json`, which is where an email- or phone-only contact appears.")
13877
+ .option("--lead <provider_id>", "The lead provider id to un-suppress. Mutually exclusive with --subject-id.")
13878
+ .option("--subject-id <id>", "DNC subject id (metadata.dnc_subject_id). Clears that person's email, phone, LinkedIn, and company suppressions in one transaction.")
13879
+ .option("--json", "Print a JSON envelope.")
13880
+ .action(async (options) => {
13881
+ const subjectId = readOption(options.subjectId);
13882
+ if (!subjectId) {
13883
+ await handleAsyncAction("suppressions remove", options, () => {
13884
+ const lead = readOption(options.lead);
13885
+ if (!lead)
13886
+ throw new Error("Pass --lead <provider_id> or --subject-id <id>.");
13887
+ return requestOxygen(`/api/cli/suppressions?lead_provider_id=${encodeURIComponent(lead)}`, {
13888
+ method: "DELETE",
13889
+ });
13682
13890
  });
13683
- });
13891
+ return;
13892
+ }
13893
+ try {
13894
+ if (readOption(options.lead)) {
13895
+ throw new Error("Pass either --lead or --subject-id, not both.");
13896
+ }
13897
+ const data = await requestOxygen(`/api/cli/suppressions?subject_id=${encodeURIComponent(subjectId)}`, { method: "DELETE" });
13898
+ if (options.json) {
13899
+ writeJson(success("suppressions remove", data));
13900
+ }
13901
+ else {
13902
+ // Same compact multi-ledger receipt style as `suppressions import`:
13903
+ // the per-channel counts plus up to 10 cleared identifiers (the full
13904
+ // list is always in the --json envelope).
13905
+ const parsed = (data ?? {});
13906
+ const identities = Array.isArray(parsed.removed_identities) ? parsed.removed_identities : [];
13907
+ const removedCount = parsed.removed_count ?? 0;
13908
+ writeJson({
13909
+ subject_id: parsed.subject_id ?? subjectId,
13910
+ removed_count: removedCount,
13911
+ removed_emails: parsed.removed?.email ?? 0,
13912
+ removed_phones: parsed.removed?.phone ?? 0,
13913
+ removed_linkedin: parsed.removed?.linkedin ?? 0,
13914
+ removed_companies: parsed.removed?.company ?? 0,
13915
+ removed_identities: identities.slice(0, 10),
13916
+ // Counted off removed_count, not the echoed array: the API caps
13917
+ // that list too, so --json is NOT a way to see the rest. The
13918
+ // counts above are the exact record of what was cleared.
13919
+ ...(removedCount > Math.min(identities.length, 10)
13920
+ ? {
13921
+ removed_identities_note: `Showing ${Math.min(identities.length, 10)} of ${removedCount} cleared identifiers; the counts above are exact.`,
13922
+ }
13923
+ : {}),
13924
+ ...(parsed.note ? { note: parsed.note } : {}),
13925
+ deep_link: parsed.deepLink,
13926
+ });
13927
+ }
13928
+ writeCreditsReceipt(data);
13929
+ }
13930
+ catch (error) {
13931
+ emitCliFailure("suppressions remove", error);
13932
+ }
13684
13933
  }))
13685
13934
  .addCommand(new Command("import")
13686
13935
  .description("Bulk-import a do-not-contact blocklist from a file (newline / comma / whitespace separated, max 5000). An entry with '@' goes on the per-address email list; a bare domain (e.g. acme.com) blocks email to that whole domain; a LinkedIn profile URL or member id (ACo...) lands on the people do-not-contact list; any other URL is rejected. A domain block is a sharp tool, so --reason is REQUIRED when the file contains any domains; otherwise manual is the default. Idempotent. Consumes 0 credits.")
@@ -13776,6 +14025,12 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13776
14025
  });
13777
14026
  }))
13778
14027
  .addCommand(new Command("import-person")
14028
+ // Deliberately NOT extended with the subjects[] pointer: this help text
14029
+ // is already 199 chars, and the generated CLI reference cell truncates a
14030
+ // description past 200 to its FIRST sentence — appending anything drops
14031
+ // the "domain is company-wide, never inferred from --email" warning from
14032
+ // the public table. The receipt itself carries `subjects[]`, and
14033
+ // `suppressions remove --help` names where subject ids come from.
13779
14034
  .description("Import one person as one DNC subject with any combination of email, LinkedIn, E.164 phone, and an explicitly supplied company domain. The domain is company-wide and is never inferred from --email.")
13780
14035
  .option("--name <name>", "Optional contact display name.")
13781
14036
  .option("--email <address>", "Email address to suppress.")
@@ -14276,7 +14531,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
14276
14531
  program.addCommand(new Command("mailboxes")
14277
14532
  .description("Native email sending pool: register/refresh Google/Microsoft mailboxes (including secure local credential-file transfer), pause/disable inboxes, connect EmailGuard monitoring, and inspect/control OXYGEN Warm-up. Fresh managed InboxKit Google/Microsoft/Azure orders activate exact-scope warm-up automatically after provisioning under their approved default-on add-on. Google reuses the managed credential just in time; Microsoft/Azure uses native export. Standalone warm-up plans and credit caps are for BYOK/imported mailboxes, managed opt-outs, or later separate enrollment. OXYGEN Warm-up never owns campaign dispatch. Safe import preflight: run `oxygen mailboxes compatibility --catalog-only --json`, then `oxygen mailboxes import --file <path> --validate-only --json` (add the credential source flags shown by import help when the file contains credentials).")
14278
14533
  .addCommand(new Command("list")
14279
- .description("List the org's sending mailboxes with provider, status, warmup state, source (managed = bought through Oxygen, byok = bring-your-own), worker-owned connectionRepair and warmupRepair state, and a pool overview (including counts by source). Read each mailbox's warmupTruth for warm-up (state, last_send, pause, dispatch, next_action with the exact command); warmupState is only the stored rail token and reads `error` for a provider-paused seat. mode=automatic means OXYGEN owns the next bounded retry — do not ask for browser consent or disable/re-enable an existing warm-up seat. To see only Google/Microsoft mailboxes that still need OAuth connection, including attempt budgets and manual remedies, use `oxygen mailboxes oauth-health --json`.")
14534
+ .description("List the org's sending mailboxes with provider, status, warmup state, source (managed = bought through Oxygen, byok = bring-your-own), worker-owned connectionRepair and warmupRepair state, and a pool overview (including counts by source). Read each mailbox's warmupTruth for warm-up (state, last_send, pause, dispatch, next_action with the exact command); warmupState is only the stored rail token and reads `error` for a provider-paused seat. mode=automatic means OXYGEN owns the next bounded retry — do not ask for browser consent or disable/re-enable an existing warm-up seat. To see only Google/Microsoft mailboxes that still need OAuth connection, including attempt budgets and manual remedies, use `oxygen mailboxes oauth-health --json`. Read the fleet from `summary` — `summary.by_warmup`, `by_status`, `by_provider`, `by_auth_mode`, `by_transport`, and `by_source` already aggregate every mailbox, so you never need to iterate the `mailboxes` array to count them.")
14280
14535
  .option("--status <status>", "Filter by status: active, paused, disabled, or provisioning (ordered, still being set up).")
14281
14536
  .option("--tag <tags>", "Comma-separated workspace tags — matches mailboxes carrying ANY of these tags (see `oxygen tags list`).")
14282
14537
  .option("--json", "Print a JSON envelope.")
@@ -14330,7 +14585,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
14330
14585
  });
14331
14586
  }))
14332
14587
  .addCommand(new Command("health")
14333
- .description("Fleet email-health: the sending pool rolled up by external deliverability reputation (healthy/degraded/critical/unknown), per-mailbox scores, connected health providers, and DIRECTIONAL recommendations. Pure read — 0 credits; never pauses a mailbox.")
14588
+ .description("Fleet email-health: the sending pool rolled up by external deliverability reputation (healthy/degraded/critical/unknown), per-mailbox scores, connected health providers, and DIRECTIONAL recommendations — plus OXYGEN's OWN evidence for the last 7 days, which needs no provider connected: the bounce notifications each mailbox received classified as provider rejection / dead address / mailbox full / delay (a Gmail reputation block shows up as provider_rejected), distinct recipients that first hard-bounced, sequence sends OXYGEN logged (source=sequence only), warm-up day, health score and stop cause, the advisory daily cap that warm-up age supports, and the same rollup per sending domain ranked worst-first. Notifications whose body was never stored count as signals.dsn7d.unclassified, which means OXYGEN could not look — not that nothing was wrong. Read health_coverage to see how many mailboxes an external provider has actually scored. Start from the two fleet reads instead of scanning the mailbox array: warmup.byCause (why warm-up stopped, counted once for the whole pool) and signals.capOverRecommendedMailboxes (mailboxes whose configured cap is above the one warm-up supports — per mailbox, compare dailyCap with recommendedDailyCap; the gap is flagged as the cap_exceeds_readiness warning). Every count is scoped, and the scope is stamped into the payload as signals.window (7d) and signals.sendSource (sequence sends only), on the fleet object and on every mailbox: a 0 means nothing was recorded in that window for that send source, NOT that the fleet is un-blocklisted everywhere. Pure read — 0 credits; never pauses a mailbox or changes a cap.")
14334
14589
  .option("--json", "Print a JSON envelope.")
14335
14590
  .action(async (options) => {
14336
14591
  await handleAsyncAction("mailboxes health", options, () => requestOxygen("/api/cli/mailboxes/health"));
@@ -18453,11 +18708,15 @@ function readFeedBindBody(table, options) {
18453
18708
  ...(options.approved ? { approved: true } : {}),
18454
18709
  };
18455
18710
  }
18456
- function readSignalsSearchPlanBody(options) {
18711
+ function readSignalsSearchPlanBody(options, promptArg) {
18712
+ // The prompt may arrive as --prompt or as the positional argument; the flag wins.
18713
+ const promptSource = options.prompt ?? promptArg;
18457
18714
  const targetCount = readPositiveInt(options.targetCount);
18458
18715
  const filters = readSignalSearchFilters(options);
18459
18716
  return {
18460
- prompt: readFileIfPresent(options.prompt),
18717
+ // Omit when neither was given so the request still reaches the server, which
18718
+ // answers with a clean `prompt is required.` instead of crashing readFileIfPresent.
18719
+ ...(promptSource !== undefined ? { prompt: readFileIfPresent(promptSource) } : {}),
18461
18720
  family: options.family,
18462
18721
  ...(readOption(options.scope) ? { scope: readOption(options.scope) } : {}),
18463
18722
  ...(targetCount !== undefined ? { target_count: targetCount } : {}),
@@ -18465,8 +18724,10 @@ function readSignalsSearchPlanBody(options) {
18465
18724
  ...(options.estimate ? { estimate: true } : {}),
18466
18725
  };
18467
18726
  }
18468
- function readSignalsSearchRunBody(options) {
18469
- const prompt = options.prompt ? readFileIfPresent(options.prompt) : null;
18727
+ function readSignalsSearchRunBody(options, promptArg) {
18728
+ // The prompt may arrive as --prompt or as the positional argument; the flag wins.
18729
+ const promptSource = options.prompt ?? promptArg;
18730
+ const prompt = promptSource ? readFileIfPresent(promptSource) : null;
18470
18731
  const plan = options.planJson ? readSearchPlanJson(options.planJson) : null;
18471
18732
  if (!prompt && !plan) {
18472
18733
  throw new OxygenError("invalid_request", "Pass --prompt or --plan-json.", { exitCode: 1 });
@@ -18538,11 +18799,15 @@ function readSignalSearchFilters(options) {
18538
18799
  }
18539
18800
  return Object.keys(filters).length > 0 ? filters : null;
18540
18801
  }
18541
- function readCompaniesSearchPlanBody(options) {
18802
+ function readCompaniesSearchPlanBody(options, promptArg) {
18803
+ // The prompt may arrive as --prompt or as the positional argument; the flag wins.
18804
+ const promptSource = options.prompt ?? promptArg;
18542
18805
  const targetCount = readPositiveInt(options.targetCount);
18543
18806
  const filters = readCompanySearchFilters(options);
18544
18807
  return {
18545
- prompt: readFileIfPresent(options.prompt),
18808
+ // Omit when neither was given so the request still reaches the server, which
18809
+ // answers with a clean `prompt is required.` instead of crashing readFileIfPresent.
18810
+ ...(promptSource !== undefined ? { prompt: readFileIfPresent(promptSource) } : {}),
18546
18811
  ...(targetCount !== undefined ? { target_count: targetCount } : {}),
18547
18812
  ...(options.sourceIntent ? { source_intent: options.sourceIntent } : {}),
18548
18813
  ...(filters ? { filters } : {}),
@@ -18550,8 +18815,10 @@ function readCompaniesSearchPlanBody(options) {
18550
18815
  ...(options.materializePreview ? { materialize_preview: true } : {}),
18551
18816
  };
18552
18817
  }
18553
- function readCompaniesSearchRunBody(options) {
18554
- const prompt = options.prompt ? readFileIfPresent(options.prompt) : null;
18818
+ function readCompaniesSearchRunBody(options, promptArg) {
18819
+ // The prompt may arrive as --prompt or as the positional argument; the flag wins.
18820
+ const promptSource = options.prompt ?? promptArg;
18821
+ const prompt = promptSource ? readFileIfPresent(promptSource) : null;
18555
18822
  const plan = options.planJson ? readSearchPlanJson(options.planJson) : null;
18556
18823
  if (!prompt && !plan) {
18557
18824
  throw new OxygenError("invalid_request", "Pass --prompt or --plan-json.", { exitCode: 1 });
@@ -24341,6 +24608,27 @@ function readPositiveNumber(value) {
24341
24608
  }
24342
24609
  return parsed;
24343
24610
  }
24611
+ /**
24612
+ * A spend ceiling where ZERO IS VALID and meaningful: `find company
24613
+ * --max-credits 0` asks for the lanes that cost nothing (crustdata's identify
24614
+ * lane resolves domain↔LinkedIn for 0 credits) and skips every priced one.
24615
+ * readPositiveNumber rejected it client-side, so the free lanes were
24616
+ * unreachable from the CLI at all (Plain T-111). Fractional ceilings stay legal
24617
+ * — lane estimates are fractional — so this is not the whole-number reader.
24618
+ */
24619
+ function readCreditCeilingOrZero(value) {
24620
+ const trimmed = value?.trim();
24621
+ if (!trimmed)
24622
+ return undefined;
24623
+ const parsed = Number(trimmed);
24624
+ if (!Number.isFinite(parsed) || parsed < 0) {
24625
+ throw new OxygenError("invalid_number", "Expected a number of 0 or more.", {
24626
+ details: { value },
24627
+ exitCode: 1,
24628
+ });
24629
+ }
24630
+ return parsed;
24631
+ }
24344
24632
  /**
24345
24633
  * A whole count (days, rows, windows). Its positive-number sibling accepts 2.5,
24346
24634
  * which for a scan bound is always a typo — rejected here so it costs no round trip,