@oxygen-agent/cli 1.1011.855 → 1.1011.1091

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 (71) hide show
  1. package/README.md +1 -1
  2. package/dist/admin-lead-magnet-commands.d.ts +7 -0
  3. package/dist/admin-lead-magnet-commands.js +128 -0
  4. package/dist/command-manifest.js +29 -0
  5. package/dist/crm-support-flags.d.ts +1 -0
  6. package/dist/crm-support-flags.js +5 -1
  7. package/dist/customer-operation-commands.js +30 -1
  8. package/dist/functions-commands.js +1 -1
  9. package/dist/help.js +10 -2
  10. package/dist/http-client.d.ts +4 -0
  11. package/dist/http-client.js +21 -10
  12. package/dist/index.js +444 -208
  13. package/dist/sender-limits-flags.d.ts +7 -0
  14. package/dist/sender-limits-flags.js +13 -0
  15. package/dist/workspace-utility-flags.d.ts +7 -0
  16. package/dist/workspace-utility-flags.js +45 -0
  17. package/node_modules/@oxygen/shared/dist/attempt-provenance.d.ts +22 -0
  18. package/node_modules/@oxygen/shared/dist/attempt-provenance.js +83 -0
  19. package/node_modules/@oxygen/shared/dist/billing-anchors.d.ts +6 -0
  20. package/node_modules/@oxygen/shared/dist/billing-anchors.js +7 -1
  21. package/node_modules/@oxygen/shared/dist/capability-discovery.d.ts +6 -0
  22. package/node_modules/@oxygen/shared/dist/capability-discovery.js +279 -13
  23. package/node_modules/@oxygen/shared/dist/cli-result.js +6 -0
  24. package/node_modules/@oxygen/shared/dist/contracts/browser-operations.js +21 -9
  25. package/node_modules/@oxygen/shared/dist/contracts/crm-attributes.d.ts +7 -1
  26. package/node_modules/@oxygen/shared/dist/contracts/crm-attributes.js +5 -1
  27. package/node_modules/@oxygen/shared/dist/contracts/crm-support.d.ts +14 -0
  28. package/node_modules/@oxygen/shared/dist/contracts/crm-support.js +4 -0
  29. package/node_modules/@oxygen/shared/dist/contracts/customer-operations.js +13 -11
  30. package/node_modules/@oxygen/shared/dist/contracts/limits-read.js +11 -1
  31. package/node_modules/@oxygen/shared/dist/contracts/operations.d.ts +6 -0
  32. package/node_modules/@oxygen/shared/dist/contracts/publishing-reads.js +1 -0
  33. package/node_modules/@oxygen/shared/dist/contracts/schema.js +10 -6
  34. package/node_modules/@oxygen/shared/dist/contracts/spintax.d.ts +18 -0
  35. package/node_modules/@oxygen/shared/dist/contracts/spintax.js +11 -0
  36. package/node_modules/@oxygen/shared/dist/contracts/table-row-removal.d.ts +8 -0
  37. package/node_modules/@oxygen/shared/dist/contracts/table-row-removal.js +21 -0
  38. package/node_modules/@oxygen/shared/dist/contracts/workspace-utilities.d.ts +162 -0
  39. package/node_modules/@oxygen/shared/dist/contracts/workspace-utilities.js +46 -0
  40. package/node_modules/@oxygen/shared/dist/credit-topup-review.d.ts +11 -0
  41. package/node_modules/@oxygen/shared/dist/credit-topup-review.js +3 -0
  42. package/node_modules/@oxygen/shared/dist/emailguard-placement-probe.d.ts +70 -0
  43. package/node_modules/@oxygen/shared/dist/emailguard-placement-probe.js +161 -0
  44. package/node_modules/@oxygen/shared/dist/future-signup-events.d.ts +1 -1
  45. package/node_modules/@oxygen/shared/dist/future-signup-events.js +4 -0
  46. package/node_modules/@oxygen/shared/dist/knowledge-seed-content.js +2 -2
  47. package/node_modules/@oxygen/shared/dist/linkedin-quota-denial.d.ts +48 -0
  48. package/node_modules/@oxygen/shared/dist/linkedin-quota-denial.js +94 -0
  49. package/node_modules/@oxygen/shared/dist/partner-coupon-bonus.d.ts +45 -0
  50. package/node_modules/@oxygen/shared/dist/partner-coupon-bonus.js +57 -0
  51. package/node_modules/@oxygen/shared/dist/provider-balance-signal.js +3 -2
  52. package/node_modules/@oxygen/shared/dist/table-filter-operators.d.ts +6 -0
  53. package/node_modules/@oxygen/shared/dist/table-filter-operators.js +50 -0
  54. package/node_modules/@oxygen/shared/dist/telemetry.js +4 -0
  55. package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
  56. package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
  57. package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
  58. package/node_modules/@oxygen/shared/dist/version.js +1 -1
  59. package/node_modules/@oxygen/shared/dist/whatsapp-sender-limits.d.ts +42 -0
  60. package/node_modules/@oxygen/shared/dist/whatsapp-sender-limits.js +68 -0
  61. package/node_modules/@oxygen/shared/dist/workflow-trigger-metadata.d.ts +15 -0
  62. package/node_modules/@oxygen/shared/dist/workflow-trigger-metadata.js +33 -0
  63. package/node_modules/@oxygen/shared/package.json +5 -5
  64. package/node_modules/@oxygen/workflows/dist/index.d.ts +1 -0
  65. package/node_modules/@oxygen/workflows/dist/index.js +1 -0
  66. package/node_modules/@oxygen/workflows/dist/recipe-timestamp.d.ts +22 -0
  67. package/node_modules/@oxygen/workflows/dist/recipe-timestamp.js +72 -0
  68. package/node_modules/@oxygen/workflows/dist/usage-estimate.js +9 -5
  69. package/package.json +1 -1
  70. package/node_modules/@oxygen/shared/dist/vercel-sandbox-fetch.d.ts +0 -9
  71. package/node_modules/@oxygen/shared/dist/vercel-sandbox-fetch.js +0 -33
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { registerAdminLeadMagnetCommands } from "./admin-lead-magnet-commands.js";
1
2
  import { renderPrimaryProviderBoard } from "./admin-primary-providers-render.js";
2
3
  import { isTruthyEnv, markStdinConsumed, maybeScheduleBackgroundUpdate, readAutoUpdateStatus, runBackgroundUpdate, } from "./auto-update.js";
3
4
  import { createBrowserLoginSession, createRelayLoginSession, openBrowser } from "./browser-login.js";
@@ -9,7 +10,7 @@ import { registerConnectorsCommands } from "./connectors-commands.js";
9
10
  import { addContractFlags, readContractFlags } from "./contract-flags.js";
10
11
  import { clearCredentials, defaultApiUrl, listCredentialProfiles, loadCredentials, normalizeApiUrl, pickProfileNameForIdentity, pickProfileNameForUserSession, readEnvToken, resolveActiveProfile, saveCredentials, switchCredentialProfile, updateActiveOrganizationForProfile, } from "./credentials.js";
11
12
  import { registerCrmFileReadCommands } from "./crm-file-read-commands.js";
12
- import { registerCrmSupportFlags, readCrmNoteEditFields, readCrmPhotoFields, readCrmFileLabel, buildCrmObjectsPath, buildCrmConnectionsPath } from "./crm-support-flags.js";
13
+ import { registerCrmSupportFlags, readCrmNoteEditFields, readCrmPhotoFields, readCrmFileLabel, buildCrmObjectsPath, buildCrmConnectionsPath, buildCrmRecordPath } from "./crm-support-flags.js";
13
14
  import { registerCustomerOperationCommands } from "./customer-operation-commands.js";
14
15
  import { DATABASE_QUERY_FLAG_OVERLAY } from "./database-query-flags.js";
15
16
  import { addFeedReadFlags, feedReadWindowParams } from "./feed-read-flags.js";
@@ -25,7 +26,7 @@ import { formatMailboxRampNotice } from "./mailbox-ramp-notice.js";
25
26
  import { waitForCliRun } from "./run-wait.js";
26
27
  import { resolveCliBinaryName } from "./runtime.js";
27
28
  import { formatSearchAiFilterTotalsNotice } from "./search-ai-filter-notice.js";
28
- import { buildSenderLimitsBody, parseWorkingDaysOption, SENDER_LIMITS_FLAG_OVERLAY } from "./sender-limits-flags.js";
29
+ import { addWhatsAppLimitFlags, buildSenderLimitsBody, parseWorkingDaysOption, SENDER_LIMITS_FLAG_OVERLAY } from "./sender-limits-flags.js";
29
30
  import { addSessionOutput, addSessionStatus, getSessionUsage, startSession, updateSessionStep, } from "./session.js";
30
31
  import { registerSettingsCommands } from "./settings-commands.js";
31
32
  import { doctorAgentSkills, getAgentSkill, installAgentSkills, listAgentSkills, runAutomaticSkillsInstall, searchAgentSkills, } from "./skills.js";
@@ -40,6 +41,7 @@ import { registerVisualCommands } from "./visual-commands.js";
40
41
  import { WHATSAPP_ACCOUNT_FLAGS, whatsappAccountQuery } from "./whatsapp-account-flags.js";
41
42
  import { formatWorkflowPlanLimitNotices } from "./workflow-plan-limit-notices.js";
42
43
  import { WORKFLOW_RUN_LIST_FLAG_OVERLAY, readWorkflowRunListFlags } from "./workflow-run-list-flags.js";
44
+ import { addProviderLimitFlags, addSuppressionListFlags, providerLimitBody, providerLimitQuery, suppressionListQuery } from "./workspace-utility-flags.js";
43
45
  import { isRecipeDefinition } from "@oxygen/recipe-sdk";
44
46
  import { AGENCY_DIRECTORY_REGIONS, AGENCY_DIRECTORY_SERVICES, COLLAB_GATE_KINDS, COLLAB_GATE_PROSE, COLLAB_SUBJECT_KINDS, COLLAB_SUBJECT_KINDS_PROSE, COLLAB_SUBJECT_LABELS, COLLAB_SUBJECT_PROSE, GATE_KIND_SUBJECTS, describeWorkflowStatusChange, formatCellForDisplay, formatCopilotPlanDuration, formatCopilotPlanSeconds, formatPublicBudgetScopes, SUBJECT_PATH_FORMS_PROSE, formatSubjectPath, exitCodeForOxygenError, parseSubjectPath, parseSubjectRef, parseWorkflowStatusChange, chunk, isVersionGreater, isVersionLess, KNOWLEDGE_BOOTSTRAP_MAX_CREDITS, MAX_CLI_JSON_BODY_BYTES, MAX_MCP_TOOL_NAME_LENGTH, normalizeCopilotPlanStepStatus, OXYGEN_CAPABILITY_ROUTES, OXYGEN_VERSION, OxygenError, getCapabilityRouteMatch, inferUserCapabilityRoute, parseKnowledgePageMarkdown, PLAN_LIMITS, serializeCapabilityRoute, sleep, success, TAG_KINDS_PROSE, toFailure, workflowMcpToolName, } from "@oxygen/shared";
45
47
  import { PURCHASABLE_PLAN_KEYS, planVolumeBonusPercent, resolveBasePricingPlan } from "@oxygen/shared/billing";
@@ -66,12 +68,14 @@ import { WHATSAPP_ACCOUNTS_READ_CONTRACT } from "@oxygen/shared/contracts/whatsa
66
68
  import { WORKFLOW_RUN_LIST_CONTRACT, workflowRunListQueryParams } from "@oxygen/shared/contracts/workflow-run-list";
67
69
  import { MANAGED_DATA_SUPPLIERS } from "@oxygen/shared/data-suppliers";
68
70
  import { MAX_BUFFERED_IMPORT_PARSE_BYTES, assertImportFormatWithinBufferedLimit, inferImportColumnLabels, inferRowsFileFormat, normalizeImportColumnKey, normalizeRowsForNewTable, normalizeRowsFormat, parseRowsFileBuffer, parseXlsxWorkbookBuffer, } from "@oxygen/shared/file-import";
71
+ import { KNOWLEDGE_PAGE_TYPES } from "@oxygen/shared/knowledge-constants";
69
72
  import { LINKEDIN_SENDER_LIMIT_DEFAULTS, LINKEDIN_SENDER_LIMIT_MAXIMUMS, } from "@oxygen/shared/linkedin-sequences";
70
73
  import { MAILBOX_IMPORT_FILE_MAX_BYTES as SHARED_MAILBOX_IMPORT_FILE_MAX_BYTES, MAILBOX_IMPORT_ROW_LIMIT as SHARED_MAILBOX_IMPORT_ROW_LIMIT, normalizeMailboxImportFile as normalizeSharedMailboxImportFile, normalizeMailboxImportVendor as normalizeSharedMailboxImportVendor, normalizeMailboxWorkbookRows, parseMailboxImportText, summarizeMailboxImportValidation as summarizeSharedMailboxImportValidation, } from "@oxygen/shared/mailbox-import";
71
74
  import { PUBLIC_WEBHOOK_INGRESS_PER_TARGET_PER_MINUTE } from "@oxygen/shared/plan-band";
72
75
  import { AI_COLUMN_CREDITS, AI_COLUMN_WEB_SEARCH_TYPICAL_CREDITS } from "@oxygen/shared/pricing-sheet";
73
76
  import { TAG_COLORS } from "@oxygen/shared/select-options";
74
77
  import { SEQUENCE_LIFECYCLES, sequenceDisplayStatus } from "@oxygen/shared/sequence-lifecycle";
78
+ import { FILTER_TREE_OPERATORS } from "@oxygen/shared/table-filter-operators";
75
79
  import { assertRecipeBundleSafe, assertPortableWorkflowDefinition, assertWorkflowGraphManifest, assertWorkflowManifest, buildRecipeManifest, compileWorkflowDefinition, isAnyWorkflowManifest, isRecipeManifest, isWorkflowDefinition, isWorkflowGraphManifest, isWorkflowManifest, hashPortableWorkflowGraphManifest, WORKFLOW_GRAPH_COMPILER_VERSION, WORKFLOW_GRAPH_MANIFEST_VERSION, } from "@oxygen/workflows";
76
80
  import { Command, CommanderError, Option } from "commander";
77
81
  import { execFileSync } from "node:child_process";
@@ -352,6 +356,20 @@ function suppressionListParams(options) {
352
356
  // feed): `translateScheduleEvery` in apps/web resolves all four forms, so this
353
357
  // string is the single place the CLI spells them out.
354
358
  const CADENCE_FLAG_DESCRIPTION = "Shorthand schedule: hourly, every_6h, daily@<hour>, or weekly@<day><hour> (e.g. weekly@mon9). Hours 0-23 UTC.";
359
+ /** A --value for the pricing book: a number, true/false for a charge's active flag, or "none" for no plan limit. */
360
+ function readPriceBookValue(raw) {
361
+ if (raw === undefined)
362
+ return undefined;
363
+ const value = raw.trim();
364
+ if (value === "true")
365
+ return true;
366
+ if (value === "false")
367
+ return false;
368
+ if (value === "none" || value === "null")
369
+ return null;
370
+ const number = Number(value);
371
+ return value !== "" && Number.isFinite(number) ? number : value;
372
+ }
355
373
  const BROWSER_LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
356
374
  const BROWSER_LOGIN_FALLBACK_HINT_MS = 15 * 1000;
357
375
  const OXYGEN_SPINNER_INTERVAL_MS = 90;
@@ -1703,6 +1721,15 @@ function writeMaxCreditsHint(error) {
1703
1721
  process.stderr.write(`hint: the estimate is ${estimated} credits; re-run with --max-credits ${estimated} or higher\n`);
1704
1722
  return;
1705
1723
  }
1724
+ case "copilot_send_timeout": {
1725
+ const nextStep = readDetailsString(error.details, "next_step");
1726
+ const interrupt = readDetailsString(error.details, "interrupt_command");
1727
+ if (nextStep)
1728
+ process.stderr.write(`hint: the turn is still running; read it with \`${nextStep}\`\n`);
1729
+ if (interrupt)
1730
+ process.stderr.write(`hint: stop it (and its spend) with \`${interrupt}\`\n`);
1731
+ return;
1732
+ }
1706
1733
  default:
1707
1734
  }
1708
1735
  }
@@ -3798,6 +3825,7 @@ export function createProgram() {
3798
3825
  const workspaceCommand = program
3799
3826
  .command("workspace")
3800
3827
  .description("Inspect this workspace or end its managed external infrastructure.")
3828
+ .addHelpText("before", "\nActive workspace and identity: oxygen whoami --summary-json (read-only, 0 credits).\n")
3801
3829
  .addCommand(new Command("map")
3802
3830
  .description("What this workspace actually contains: the newest tables, CRM objects, sequences, workflows, agents, wiki pages, posts and projects, each with its id, last activity, tags and link. Read-only, 0 credits. Capability search tells you what OXYGEN can do; this tells you what is here.")
3803
3831
  .option("--kind <kinds>", "Comma-separated kinds to include: table, crm_object, sequence, workflow, agent, knowledge_page, post, project.")
@@ -4013,7 +4041,7 @@ export function createProgram() {
4013
4041
  });
4014
4042
  }))
4015
4043
  .addCommand(new Command("billing-link")
4016
- .description("Cover a workspace with a plan you already pay for in another organization (one plan, many workspaces) instead of buying a second subscription. Omit --owner when exactly one of your organizations can pay and Oxygen will use it; `orgs billing-owners` lists them all. You must be an admin of BOTH organizations, the workspace being linked must not hold a live subscription of its own, and an org-bound key (oxy_live_…) can only link the workspace it is bound to. Credit billing moves, workspace data access does not; plan gates and sending seats then resolve through the owner, so the linked workspace connects senders against the owner's seats and seats are bought there. `plan_tier` in the response is the plan the workspace now runs on; `free` means the owner holds no active paid plan, so connecting senders stays blocked. From the owner, `billing usage --group-by workspace` reports the credits each linked workspace spent.")
4044
+ .description("Cover a workspace with another organization's paid plan instead of buying a second subscription. Omit --owner when exactly one owner is eligible; orgs billing-owners lists them. You must administer both organizations, the workspace must not hold its own live subscription, and an org-bound key can link only its workspace. The owner funds credits; data access stays unchanged. billing estimate reports sender_billing: credit_reservations uses the owner's credits, including when plan_tier is free; legacy sending_seats uses the owner's paid plan and seats. BYOK and Instagram/TikTok/YouTube/Facebook publishing still need a paid plan. billing usage --group-by workspace reports each linked workspace's spend.")
4017
4045
  .option("--owner <organization>", "Billing owner organization id, Clerk org id, or slug. Optional — when omitted, Oxygen uses your one eligible organization, and refuses to pick if there is more than one.")
4018
4046
  .option("--organization <organization>", "Workspace organization to link. Defaults to the active organization.")
4019
4047
  .option("--organization-id <id>", "Alias for --organization.")
@@ -4041,7 +4069,7 @@ export function createProgram() {
4041
4069
  });
4042
4070
  }))
4043
4071
  .addCommand(new Command("billing-unlink")
4044
- .description("Return a workspace to owning its own billing: it stops being covered by another organization's plan and needs a subscription of its own again.")
4072
+ .description("Return a workspace to its own billing owner and credit balance. Its own subscription state, plan and sender_billing model determine which actions need a plan.")
4045
4073
  .option("--organization <organization>", "Workspace organization to unlink. Defaults to the active organization.")
4046
4074
  .option("--organization-id <id>", "Alias for --organization.")
4047
4075
  .option("--json", "Print a JSON envelope.")
@@ -4188,11 +4216,12 @@ Examples:
4188
4216
  await handleAsyncAction("support admin list", options, () => requestOxygen(withSupportListQuery("/api/cli/admin/support/tickets", options)));
4189
4217
  }))
4190
4218
  .addCommand(new Command("get")
4191
- .description("Show one live Plain Thread, its routing metadata, and customer-visible messages (staff only).")
4219
+ .description("Show one live Plain Thread, its routing metadata, and customer-visible messages (staff only). Add --include-notes to also read back the Thread's team-only notes and staged reply drafts as `internal_notes` (kind note, reply_draft, suggested_reply_provenance or undelivered_customer_report); read-only, never shown to the customer.")
4192
4220
  .argument("<ticketId>", "Plain Thread ID (th_...) or Thread ref (T-<n>), as shown by `oxygen support admin list`.")
4221
+ .option("--include-notes", "Also return team-only internal notes and staged drafts.")
4193
4222
  .option("--json", "Print a JSON envelope.")
4194
4223
  .action(async (ticketId, options) => {
4195
- await handleAsyncAction("support admin get", options, () => requestOxygen(`/api/cli/admin/support/tickets/${encodeURIComponent(ticketId)}`));
4224
+ await handleAsyncAction("support admin get", options, () => requestOxygen(`/api/cli/admin/support/tickets/${encodeURIComponent(ticketId)}${options.includeNotes ? "?include_notes=1" : ""}`));
4196
4225
  }))
4197
4226
  .addCommand(new Command("open")
4198
4227
  .description("Return the exact Plain Inbox URL for one live Thread (staff only).")
@@ -4756,7 +4785,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
4756
4785
  }));
4757
4786
  }))
4758
4787
  .addCommand(new Command("color")
4759
- .description("Set a project's colour in the Tables rail. `--color auto` restores the automatic colour.")
4788
+ .description("Set a folder's colour in the Tables sidebar (rail) for everyone in the workspace. `--color auto` restores the automatic colour. Free.")
4760
4789
  .argument("<project>", "Project slug or id.")
4761
4790
  .requiredOption("--color <color>", "Colour name (gray, red, orange, amber, yellow, green, teal, blue, indigo, purple, pink) or `auto` to clear.")
4762
4791
  .option("--json", "Print a JSON envelope.")
@@ -5189,9 +5218,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5189
5218
  }));
5190
5219
  })))
5191
5220
  .addCommand(new Command("comments")
5192
- .description("Publishing Community for public comments on every recent post owned by a connected LinkedIn account, including posts created directly on LinkedIn. Start with `oxygen publishing comments list --view unanswered --json`. These are network conversations, not workspace review notes. The local queue targets a six-hour background discovery + comment poll; provider quota/backoff can delay it, and list reports the exact freshness plus empty_queue_is_current. If that field is false, do not conclude there are no comments: wait for next_sync_at. The worker retries automatically; there is no manual retry by design because it could loop against LinkedIn quota. Queue reads use 0 credits and make no provider call; only explicit approval can post a public reply.")
5221
+ .description("Publishing Community for public comments on every recent post owned by a connected LinkedIn account, including posts created directly on LinkedIn. Start with `oxygen publishing comments list --view unanswered --json`. These are network conversations, not workspace review notes. The local queue targets a six-hour background discovery + comment poll; provider quota/backoff can delay it. Read freshness, empty_queue_is_current and guidance before trusting an empty queue. automatic_retry_at reports a future retry; next_sync_at alone does not confirm one. An unconfirmed provider read can pause refresh; contact support when guidance says to. No manual retry is available. Queue reads use 0 credits and make no provider call; only explicit approval can post a public reply.")
5193
5222
  .addCommand(new Command("list")
5194
- .description("List the Publishing Community work queue, oldest first, with polling freshness/backoff even when empty. Defaults to the Unanswered view: needs_reply, draft, awaiting_approval, and action_unavailable. All means every lifecycle state inside the rolling 30-day recent-owned-post scope, not lifetime LinkedIn history. Read-only: no provider call, 0 credits.")
5223
+ .description("List the Publishing Community work queue, oldest first, with polling freshness/backoff even when empty. Defaults to the Unanswered view: needs_reply, draft, awaiting_approval, and action_unavailable. All covers every lifecycle state on published owned posts within 30 days plus UGC-linked posts, not lifetime LinkedIn history. Completed coverage counts earlier successful polls and can overlap failed coverage after a later error. Read-only: no provider call, 0 credits.")
5195
5224
  .option("--view <view>", "Community view: unanswered (default) or all; all is bounded to recent owned posts.")
5196
5225
  .option("--status <status>", "Advanced exact-state filter: needs_reply, draft, awaiting_approval, replied, resolved, ignored, spam, or action_unavailable. Cannot be combined with --view.")
5197
5226
  .option("--post <post_id>", "Only comments on one scheduled Publishing post.")
@@ -5287,7 +5316,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5287
5316
  }));
5288
5317
  })))
5289
5318
  .addCommand(new Command("analytics")
5290
- .description("Stored LinkedIn and X engagement snapshots synced in the background. LinkedIn includes owned posts discovered from the provider; X includes only posts published through OXYGEN. Reading analytics makes no provider call and uses 0 credits. A snapshotted post completed a sync pass; use per-metric coverage to see which values were populated. Null is unknown, never zero. Stale or pending snapshots retry automatically; no manual provider retry is available or needed.")
5319
+ .description("Stored LinkedIn and X engagement snapshots synced in the background. LinkedIn includes owned posts discovered from the provider; X includes only posts published through OXYGEN. Reading analytics makes no provider call and uses 0 credits. A snapshotted post completed a sync pass; use per-metric coverage to see which values were populated. Null is unknown, never zero. Read freshness and guidance for delayed refresh; an unconfirmed provider read can pause it. No manual provider retry is available.")
5291
5320
  .addCommand(new Command("summary")
5292
5321
  .description("Workspace LinkedIn or X analytics. LinkedIn includes owned posts discovered from the provider; X currently includes posts published through OXYGEN. Uses each post's latest stored snapshot; snapshotted_posts (legacy measured_posts) counts sync passes, while coverage counts non-null values. Read-only: no provider call, 0 credits.")
5293
5322
  .option("--channel <channel>", "Channel to summarize: linkedin (default) or x.")
@@ -5344,7 +5373,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5344
5373
  await handleAsyncAction("publishing analytics post", options, () => requestOxygen(publishingReadQueryPath(buildPublishingAnalyticsPath(`/api/cli/publishing/analytics/posts/${encodeURIComponent(postId)}`, { since: readOption(options.since) }), publishingAnalyticsQueryContract, options)));
5345
5374
  }))
5346
5375
  .addCommand(addContractFlags(new Command("account"), publishingAnalyticsQueryContract)
5347
- .description("Stored per-account metric series (followers) for connected LinkedIn and X publishing accounts, read once a day in the background. Read-only: no provider call, 0 credits.")
5376
+ .description("Stored per-account metric series (followers) for connected LinkedIn and X publishing accounts, read once a day in the background. Each account reports its snapshot freshness; stale values remain available. Read-only: no provider call, 0 credits.")
5348
5377
  .option("--provider <provider>", "Channel to read: linkedin or x. Omit for every connected channel.")
5349
5378
  .option("--since <date>", "ISO date to start the series from.")
5350
5379
  .option("--account <key>", PUBLISHING_ANALYTICS_ACCOUNT_HELP, collectRepeatable, [])
@@ -5513,7 +5542,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5513
5542
  .command("dashboards")
5514
5543
  .description("Your saved dashboards, plus the built-in GTM funnel. `summary` reads the built-in Command Center funnel across the sequencer, unibox and CRM; everything else creates and edits dashboards you save yourself and open again later, from charts, saved table views, notes, embeds and live sources.")
5515
5544
  .addCommand(new Command("summary")
5516
- .description("Show the GTM funnel summary: touches → replies → positive → meetings → deals → won, with this-period revenue, channel split, and needs-action triage counts.")
5545
+ .description("Show the GTM funnel summary: touches → replies → positive → meetings → deals → won, with this-period revenue, channel split, and current needs-action triage. Unread replies across channels and interested email conversations are current inbox counts, independent of the selected range.")
5517
5546
  .option("--range <range>", "Preset range: 7d, 14d, 28d, 30d, 90d, 180d, 365d, or all. Defaults to 14d.")
5518
5547
  .option("--from <date>", "Custom start date (YYYY-MM-DD).")
5519
5548
  .option("--to <date>", "Custom end date (YYYY-MM-DD).")
@@ -5805,16 +5834,16 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5805
5834
  // and options.json is undefined here. optsWithGlobals() reads --json
5806
5835
  // wherever it landed, so the envelope is emitted like every other
5807
5836
  // crm_* command instead of printing the bare result.
5808
- await handleAsyncAction("crm object create", { json: Boolean(command.optsWithGlobals().json) }, () => requestOxygen("/api/cli/crm/objects", {
5837
+ await handleAsyncAction("crm objects create", { json: Boolean(command.optsWithGlobals().json) }, () => requestOxygen("/api/cli/crm/objects", {
5809
5838
  method: "POST",
5810
5839
  body: buildCrmObjectCreateBody(options),
5811
5840
  }));
5812
5841
  }))
5813
5842
  .addCommand(new Command("delete")
5814
- .description("Delete a CUSTOM CRM object: removes the object and schedules its backing table for deletion (restorable for 14 days). Standard objects (companies, people, deals) are permanent and refused. Defaults to dry-run; --live requires --confirm. Re-run on an object a teammate already deleted, it reports alreadyArchived with the table's deletion state and purge date.")
5843
+ .description("Delete a CUSTOM CRM object: removes the object and schedules its backing table for deletion (restorable for 14 days). Standard objects (companies, people, deals) are permanent and refused. Defaults to dry-run; --live requires --confirm. Re-run on an object a teammate already deleted, it reports alreadyArchived with the table's deletion state and purge date. Restoring that table returns its rows as an ordinary table; the object stays deleted. Purging it also removes the object's archived definition.")
5815
5844
  .argument("<object>", "Custom CRM object slug, such as projects or tasks.")
5816
- .option("--keep-table", "Deregister the object but keep its table as an ordinary workspace table (no delete timer).")
5817
- .option("--purge-after <days>", "Days until the backing table's physical purge. Defaults to 14; restorable via `tables restore` until then.")
5845
+ .option("--keep-table", "Deregister the object but keep its table as an ordinary workspace table (no delete timer). Ignored once the table is already scheduled.")
5846
+ .option("--purge-after <days>", "Days until the backing table's physical purge. Defaults to 14; restorable via `tables restore` until then. Ignored once the table is already scheduled.")
5818
5847
  .option("--dry-run", "Preview the object, rows, relationships, and edges the delete would take. Default.")
5819
5848
  .option("--live", "Apply the delete. Requires --confirm.")
5820
5849
  .option("--confirm", "Confirm the live delete after inspecting the dry-run impact.")
@@ -5822,7 +5851,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5822
5851
  .action(async (object, options, command) => {
5823
5852
  // See `objects create` above: optsWithGlobals() recovers --json,
5824
5853
  // which the parent command otherwise swallows.
5825
- await handleAsyncAction("crm object delete", { json: Boolean(command.optsWithGlobals().json) }, () => requestOxygen(`/api/cli/crm/objects/${encodeURIComponent(object)}`, {
5854
+ await handleAsyncAction("crm objects delete", { json: Boolean(command.optsWithGlobals().json) }, () => requestOxygen(`/api/cli/crm/objects/${encodeURIComponent(object)}`, {
5826
5855
  method: "DELETE",
5827
5856
  body: buildCrmObjectDeleteBody(options),
5828
5857
  }));
@@ -5838,7 +5867,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5838
5867
  .action(async (object, options, command) => {
5839
5868
  // See `objects create` above: optsWithGlobals() recovers --json,
5840
5869
  // which the parent command otherwise swallows.
5841
- await handleAsyncAction("crm object add-attr", { json: Boolean(command.optsWithGlobals().json) }, () => requestOxygen(`/api/cli/crm/objects/${encodeURIComponent(object)}/attributes`, {
5870
+ await handleAsyncAction("crm objects add-attr", { json: Boolean(command.optsWithGlobals().json) }, () => requestOxygen(`/api/cli/crm/objects/${encodeURIComponent(object)}/attributes`, {
5842
5871
  method: "POST",
5843
5872
  body: buildCrmObjectAddAttrBody(options),
5844
5873
  }));
@@ -5855,7 +5884,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5855
5884
  .option("--live", "Apply the change. Default is dry-run.")
5856
5885
  .option("--json", "Print a JSON envelope.")
5857
5886
  .action(async (object, attribute, options, command) => {
5858
- await handleAsyncAction("crm object update-attr", { json: Boolean(command.optsWithGlobals().json) }, () => requestOxygen(`/api/cli/crm/objects/${encodeURIComponent(object)}/attributes/${encodeURIComponent(attribute)}`, { method: "PATCH", body: buildCrmObjectUpdateAttrBody(options) }));
5887
+ await handleAsyncAction("crm objects update-attr", { json: Boolean(command.optsWithGlobals().json) }, () => requestOxygen(`/api/cli/crm/objects/${encodeURIComponent(object)}/attributes/${encodeURIComponent(attribute)}`, { method: "PATCH", body: buildCrmObjectUpdateAttrBody(options) }));
5859
5888
  }))
5860
5889
  .addCommand(new Command("remove-attr")
5861
5890
  .description("Remove a field you added to a CRM object. The column is archived (restorable with `columns restore <table> <key>`, which brings the field back; no cell destroyed), the object stops listing it, and the key is free for a new field at once. Oxygen-shipped fields, the title field, identity fields and relations cannot be removed. Defaults to dry-run.")
@@ -5865,7 +5894,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5865
5894
  .option("--live", "Remove the field. Default is dry-run.")
5866
5895
  .option("--json", "Print a JSON envelope.")
5867
5896
  .action(async (object, attribute, options, command) => {
5868
- await handleAsyncAction("crm object remove-attr", { json: Boolean(command.optsWithGlobals().json) }, () => requestOxygen(`/api/cli/crm/objects/${encodeURIComponent(object)}/attributes/${encodeURIComponent(attribute)}`, { method: "DELETE", body: { mode: resolveLiveDryRunMode(options) } }));
5897
+ await handleAsyncAction("crm objects remove-attr", { json: Boolean(command.optsWithGlobals().json) }, () => requestOxygen(`/api/cli/crm/objects/${encodeURIComponent(object)}/attributes/${encodeURIComponent(attribute)}`, { method: "DELETE", body: { mode: resolveLiveDryRunMode(options) } }));
5869
5898
  })))
5870
5899
  .addCommand(new Command("search")
5871
5900
  .description("Search CRM records by identity or record label. Takes ONE query; narrow the objects with --object, not a second argument.")
@@ -5901,7 +5930,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5901
5930
  .argument("<row_id>", "CRM record row id.")
5902
5931
  .option("--json", "Print a JSON envelope.")
5903
5932
  .action(async (object, rowId, options) => {
5904
- await handleAsyncAction("crm get", options, () => requestOxygen(`/api/cli/crm/objects/${encodeURIComponent(object)}/records/${encodeURIComponent(rowId)}`));
5933
+ await handleAsyncAction("crm get", options, () => requestOxygen(buildCrmRecordPath(object, rowId, options)));
5905
5934
  }))
5906
5935
  .addCommand(new Command("connections")
5907
5936
  .description("Show the conversations and sequence enrollments connected to one CRM record, resolved through its people and identities \u2014 never by fuzzy name or domain.")
@@ -5926,7 +5955,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5926
5955
  await handleAsyncAction("crm files list", options, () => requestOxygen(`/api/cli/crm/objects/${encodeURIComponent(object)}/records/${encodeURIComponent(rowId)}/files`));
5927
5956
  }))
5928
5957
  .addCommand(new Command("attach")
5929
- .description("Upload a local file and attach it to a CRM record. Attaching the same file twice is a no-op, not a duplicate.")
5958
+ .description("Upload a local file and attach it to a CRM record. Each call creates a new file and attachment, including identical retries. If a response is lost, inspect the record's files before retrying. No credits.")
5930
5959
  .argument("<object>", "CRM object slug, such as companies or people.")
5931
5960
  .argument("<record>", "CRM record row id, or an identity value such as acme.com or sarah@acme.com.")
5932
5961
  .argument("<path>", "Local file path.")
@@ -6161,6 +6190,14 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6161
6190
  },
6162
6191
  });
6163
6192
  });
6193
+ }))
6194
+ .addCommand(new Command("keep")
6195
+ .description("Keep one sample record as real. A new workspace starts with sample companies, CEOs and deals: their reads carry `_sample: true` (`sample: true` on `crm pipeline` deals) and pipeline totals leave them out. Keep one only when it is real; it then counts like any record. Linked records stay as they are: a kept sample deal's company and contact stay samples until each is kept. Free and idempotent, no undo.")
6196
+ .argument("<object>", "CRM object slug: companies, people or deals.")
6197
+ .argument("<record>", "Row id (`crm pipeline` lists each deal's), or an identity value such as stripe.com.")
6198
+ .option("--json", "Print a JSON envelope.")
6199
+ .action(async (object, record, options) => {
6200
+ await handleAsyncAction("crm keep", options, () => requestOxygen(`/api/cli/crm/objects/${encodeURIComponent(object)}/records/${encodeURIComponent(record)}/keep`, { method: "POST" }));
6164
6201
  }))
6165
6202
  .addCommand(new Command("relationships")
6166
6203
  .description("Define CRM relationships and manage record relationship edges.")
@@ -6259,7 +6296,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6259
6296
  await handleAsyncAction("crm activity timeline", options, () => requestOxygen(buildCrmTimelinePath(object, rowId, options)));
6260
6297
  })))
6261
6298
  .addCommand(new Command("describe")
6262
- .description("Describe one configured CRM object: its fields, identities and relationships. Pass --field <key> to read just one field (its name, type, choices, flags) instead of the whole object. A deleted custom object answers not found; `crm objects delete <slug> --dry-run` shows its archived state.")
6299
+ .description("Describe one configured CRM object: its fields, identities and relationships. Pass --field <key> to read just one field (its name, type, choices, flags) instead of the whole object. Custom slugs accept hyphens. A deleted custom object answers crm_object_not_found with state archived; its next step, `crm objects delete <slug> --dry-run`, shows the purge time.")
6263
6300
  .argument("<object>", "CRM object slug, such as companies or people.")
6264
6301
  .option("--field <key>", "Narrow the answer to one field by its key, e.g. --field pipeline_stage. Also accepted as --attribute.")
6265
6302
  .option("--attribute <key>", "Alias for --field.")
@@ -6293,7 +6330,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6293
6330
  }));
6294
6331
  }))
6295
6332
  .addCommand(new Command("pipeline")
6296
- .description("Show the deals pipeline grouped by stage, with per-stage counts and amount totals.")
6333
+ .description("Show the deals pipeline grouped by stage, with per-stage counts and amount totals. Seeded sample deals are listed with `sample: true` but never counted; `samples` reports them.")
6297
6334
  .option("--pipeline <name>", "Filter to one pipeline, such as sales, expansion, or renewal.")
6298
6335
  .option("--limit <limit>", "Maximum deals to return per stage.")
6299
6336
  .option("--json", "Print a JSON envelope.")
@@ -6371,7 +6408,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6371
6408
  .requiredOption("--object <object>", "Provider object: hubspot contacts|companies, attio people|companies, or pipedrive persons|organizations|deals. Import Pipedrive persons and organizations before deals so each deal links to them.")
6372
6409
  .option("--into <object>", "Oxygen CRM object slug to import into. Defaults to the natural mapping.")
6373
6410
  .option("--limit <n>", "Max records to pull this run.")
6374
- .option("--max-credits <n>", "OXYGEN spend ceiling for the run. Own-API-key calls cost 0.05 credits each; OAuth follows its tool quote, and Workflow steps are billed separately. Use --limit for rows; CRM API quota also applies.")
6411
+ .option("--max-credits <n>", "OXYGEN credit ceiling for the run. Review the current quote: own-API-key calls cost 0.05 credits each after repricing takes effect; OAuth follows its tool quote. Workflow steps count toward the ceiling after repricing takes effect. Use --limit for rows; provider billing and API quota are separate.")
6375
6412
  .option("--approved", "Confirm a live import after inspecting a dry run.")
6376
6413
  .option("--dry-run", "Preview the import without pulling. Default.")
6377
6414
  .option("--live", "Run the live import. Requires --approved and --max-credits.")
@@ -6390,7 +6427,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6390
6427
  .option("--cron <cron>", "Cron expression for the scheduled reconciliation cadence, e.g. '*/15 * * * *'.")
6391
6428
  .option("--timezone <tz>", "IANA timezone the cron schedule runs in, e.g. America/New_York. Defaults to UTC.")
6392
6429
  .option("--max-rows <n>", "Per-cycle row cap, separate from the --max-credits OXYGEN spend ceiling. OAuth provider calls follow their tool quote; own-API-key calls and live Workflow steps have separate charges.")
6393
- .option("--max-credits <n>", "Managed-credit + external-quota cap per cycle.")
6430
+ .option("--max-credits <n>", "OXYGEN credit ceiling per cycle. Provider/data-read fees count toward it; Workflow steps count after repricing takes effect. Provider billing and API quota are separate.")
6394
6431
  .option("--field-mappings-json <json>", "HubSpot custom-property mappings as a JSON array of {oxygen_field, provider_field, direction}. Pass [] to clear; canonical identity/name mappings stay fixed.")
6395
6432
  .option("--approved", "Confirm a live configuration after inspecting a dry run.")
6396
6433
  .option("--dry-run", "Preview the configuration and next cycle without writing. Inbound/bidirectional previews perform bounded CRM reads and may use external API quota. Default.")
@@ -6409,8 +6446,8 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6409
6446
  .option("--direction <direction>", "Override this cycle's direction: inbound, outbound, or bidirectional.")
6410
6447
  .option("--since <iso>", "Read provider changes since this watermark.")
6411
6448
  .option("--config-id <id>", "Use this standing CRM sync configuration for the cycle.")
6412
- .option("--max-rows <n>", "Per-cycle row cap. The real volume brake on provider writes (BYOK writes cost 0 Oxygen credits).")
6413
- .option("--max-credits <n>", "Managed-credit + external-quota cap for this cycle.")
6449
+ .option("--max-rows <n>", "Per-cycle row cap, separate from the --max-credits OXYGEN spend ceiling. OAuth provider calls follow their tool quote; own-API-key calls and live Workflow steps have separate charges.")
6450
+ .option("--max-credits <n>", "OXYGEN credit ceiling per cycle. Provider/data-read fees count toward it; Workflow steps count after repricing takes effect. Provider billing and API quota are separate.")
6414
6451
  .option("--approved", "Confirm a live cycle after inspecting a dry run.")
6415
6452
  .option("--dry-run", "Preview the cycle without pulling or pushing. Default.")
6416
6453
  .option("--live", "Run the live reconciliation cycle. Requires --approved and --max-credits.")
@@ -6429,7 +6466,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6429
6466
  .option("--cron <cron>", "Cron expression for the cadence, e.g. '*/15 * * * *'. Defaults to every 15 minutes.")
6430
6467
  .option("--timezone <tz>", "IANA timezone the cron runs in. Defaults to UTC.")
6431
6468
  .requiredOption("--max-rows <n>", "Per-cycle row cap — the volume brake on provider writes (required).")
6432
- .requiredOption("--max-credits <n>", "Managed-credit + external-quota cap per cycle (required).")
6469
+ .requiredOption("--max-credits <n>", "OXYGEN credit ceiling per cycle. Provider/data-read fees count toward it; Workflow steps count after repricing takes effect. Provider billing and API quota are separate.")
6433
6470
  .option("--field-mappings-json <json>", "HubSpot custom-property mappings as a JSON array of {oxygen_field, provider_field, direction}. Omit to preserve the configured mappings.")
6434
6471
  .option("--approved", "Confirm enabling scheduled provider writes after inspecting a dry run.")
6435
6472
  .option("--no-run-now", "Do not run an immediate first cycle after enabling.")
@@ -6466,7 +6503,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6466
6503
  await handleAsyncAction("crm sync status", resolved, () => requestOxygen(buildCrmSyncConfigStatusPath(resolved)));
6467
6504
  }))
6468
6505
  .addCommand(new Command("links")
6469
- .description("Show provider record links (HubSpot/Attio ids) mapped to one Oxygen CRM record.")
6506
+ .description("Show provider record links (HubSpot/Attio/Pipedrive ids) mapped to one Oxygen CRM record.")
6470
6507
  .argument("<object>", "Oxygen CRM object slug, such as companies or people.")
6471
6508
  .argument("<row_id>", "Oxygen CRM record row id.")
6472
6509
  .option("--json", "Print a JSON envelope.")
@@ -6705,6 +6742,61 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
6705
6742
  return requestOxygen(`/api/cli/tables/sources${qs}`);
6706
6743
  });
6707
6744
  }))
6745
+ .addCommand(new Command("records")
6746
+ .description("Copy a Records object or saved view into a working Table, once or kept in sync. One-way: Records stay unchanged, the Table is keyed by record_id, cells edited in the Table are kept, and records that leave the source stay as rows with in_records=false.")
6747
+ .addCommand(new Command("import")
6748
+ .description("Create a Table from a Records object or saved view. --dry-run counts the records and lists the columns without creating anything. --refresh sync also arms a schedule (a Workflow you can pause) that repeats the copy. The copy costs 0 credits and calls no provider; each scheduled sync bills one workflow step (credits_per_scheduled_sync).")
6749
+ .requiredOption("--object <slug>", "Records object slug, such as companies, people or deals (`crm objects`).")
6750
+ .option("--view <id|name>", "Saved view on that object, by id or exact name (`tables views list <object table>`). Omit for every record.")
6751
+ .option("--name <table>", "Name for the new Table. Defaults to the object and view name.")
6752
+ .option("--project <project>", "Project id or slug. Defaults to General.")
6753
+ .option("--refresh <once|sync>", "once (default) copies now; sync also keeps it in sync (needs --approved).")
6754
+ .option("--every <cadence>", "With --refresh sync: hourly, every_6h, daily@<hour> or weekly@<day><hour> (UTC). Default daily@6.")
6755
+ .option("--dry-run", "Preview without creating the Table.")
6756
+ .option("--approved", "Approve the standing per-sync charge that --refresh sync arms.")
6757
+ .option("--json", "Print a JSON envelope.")
6758
+ .action(async (options) => {
6759
+ await handleAsyncAction("tables records import", options, () => requestOxygen("/api/cli/tables/records-import", {
6760
+ method: "POST",
6761
+ body: {
6762
+ object: options.object,
6763
+ ...(readOption(options.view) ? { view: readOption(options.view) } : {}),
6764
+ ...(readOption(options.name) ? { name: readOption(options.name) } : {}),
6765
+ ...(readOption(options.project) ? { project: readOption(options.project) } : {}),
6766
+ ...(readOption(options.refresh) ? { refresh: readOption(options.refresh) } : {}),
6767
+ ...(readOption(options.every) ? { every: readOption(options.every) } : {}),
6768
+ ...(options.dryRun ? { dry_run: true } : {}),
6769
+ ...(options.approved ? { approved: true } : {}),
6770
+ },
6771
+ }));
6772
+ }))
6773
+ .addCommand(new Command("sync")
6774
+ .description("Copy a Records-imported Table's source again now: new records are added, changed cells updated, edited cells kept, and departed records marked in_records=false (they flip back if they return). Without --refresh the schedule stays as it is; --refresh sync --approved arms or retimes it, --refresh once removes it. preserved_cells counts copied-field cells left as someone edited them in the Table; columns you added are never touched and not counted. Running it now costs 0 credits; each scheduled sync bills one workflow step (credits_per_scheduled_sync in an import dry run).")
6775
+ .argument("<table>", "Table id, slug or URL.")
6776
+ .option("--refresh <once|sync>", "Omit to keep the schedule; sync (with --approved) arms or retimes it; once removes it.")
6777
+ .option("--every <cadence>", "With --refresh sync: hourly, every_6h, daily@<hour> or weekly@<day><hour> (UTC). Default daily@6.")
6778
+ .option("--dry-run", "Count what would change without writing.")
6779
+ .option("--approved", "Approve the standing per-sync charge that --refresh sync arms.")
6780
+ .option("--json", "Print a JSON envelope.")
6781
+ .action(async (table, options) => {
6782
+ await handleAsyncAction("tables records sync", options, () => requestOxygen("/api/cli/tables/records-sync", {
6783
+ method: "POST",
6784
+ body: {
6785
+ table,
6786
+ ...(readOption(options.refresh) ? { refresh: readOption(options.refresh) } : {}),
6787
+ ...(readOption(options.every) ? { every: readOption(options.every) } : {}),
6788
+ ...(options.dryRun ? { dry_run: true } : {}),
6789
+ ...(options.approved ? { approved: true } : {}),
6790
+ },
6791
+ }));
6792
+ }))
6793
+ .addCommand(new Command("status")
6794
+ .description("Show a Records-imported Table's source object and view, the last sync's counts and its schedule. Read-only, 0 credits.")
6795
+ .argument("<table>", "Table id, slug or URL.")
6796
+ .option("--json", "Print a JSON envelope.")
6797
+ .action(async (table, options) => {
6798
+ await handleAsyncAction("tables records status", options, () => requestOxygen(`/api/cli/tables/records-sync?table=${encodeURIComponent(table)}`));
6799
+ })))
6708
6800
  .addCommand(new Command("hubspot")
6709
6801
  .description("Import HubSpot contacts or companies into a new table or Records, optionally from a saved list and with do-not-contact suppression.")
6710
6802
  .addCommand(new Command("lists")
@@ -7004,7 +7096,8 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7004
7096
  });
7005
7097
  }))
7006
7098
  .addCommand(new Command("delete-rows")
7007
- .description("Delete multiple workspace table rows. Leads enrolled in a campaign stay enrolled; the response names those campaigns.")
7099
+ .description("Delete exact workspace row IDs. For a matching filtered selection, use tables rows remove-selection. Campaign enrollments stay enrolled; the response names those campaigns.")
7100
+ .addHelpText("after", "\nFor a matching filtered selection, use tables rows remove-selection --help.")
7008
7101
  .argument("<table>", "Table id or slug.")
7009
7102
  .option("--row-ids-json <json>", "JSON array of workspace row UUIDs.")
7010
7103
  .option("--row-ids-file <path>", "File containing row UUIDs or rows with a _row_id column.")
@@ -7147,7 +7240,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7147
7240
  .option("--offset <n>", "Skip this many rows and start there — jump straight to a row without paging to it. Mutually exclusive with --cursor.")
7148
7241
  .option("--fields <columns>", "Comma-separated column keys or ids to include.")
7149
7242
  .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.")
7150
- .option("--filter-tree-json <json>", "Airtable-style filter group, e.g. '{\"type\":\"group\",\"conjunction\":\"and\",\"children\":[{\"type\":\"leaf\",\"columnKey\":\"stage\",\"operator\":\"is\",\"value\":\"won\"}]}'. Add \"path\":\"a.b\" to a leaf to match a key inside a JSON cell, e.g. the rows one Function run touched: {\"columnKey\":\"<function_column>\",\"path\":\"action_run_id\",\"operator\":\"is\",\"value\":\"<run_id>\"}. Mutually exclusive with --filter-json.")
7243
+ .option("--filter-tree-json <json>", `Airtable-style filter group, e.g. '{\"type\":\"group\",\"conjunction\":\"and\",\"children\":[{\"type\":\"leaf\",\"columnKey\":\"stage\",\"operator\":\"is\",\"value\":\"won\"}]}'. Add \"path\":\"a.b\" to a leaf to match a key inside a JSON cell, e.g. the rows one Function run touched: {\"columnKey\":\"<function_column>\",\"path\":\"action_run_id\",\"operator\":\"is\",\"value\":\"<run_id>\"}. Leaf operators: ${FILTER_TREE_OPERATORS.join(", ")} (is_true/is_false need a checkbox column; --filter-json's is_null/is_not_null are is_empty/is_not_empty here). Mutually exclusive with --filter-json.`)
7151
7244
  .option("--search <text>", "Find rows by one piece of text, matched against every text, number, date and checkbox column at once — the grid's magnifier. Combines with --filter-tree-json (both must hold). Use --filter-tree-json when you know which column to match.")
7152
7245
  .option("--formula-values <mode>", "Formula filters are refused by default because displayed formulas are evaluated live. Refresh the formula's stored cells with `columns run <table> <column> --force` (0 credits), then pass 'materialized'; filtering uses that stored snapshot and returns a freshness note.")
7153
7246
  .option("--sort-json <json>", "Ordered sort rules, e.g. '[{\"columnKey\":\"_created_at\",\"direction\":\"desc\"}]'. Earlier rules dominate. Mutually exclusive with --filter-json.")
@@ -7431,13 +7524,13 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7431
7524
  });
7432
7525
  }
7433
7526
  const followersCommand = tablesCommand.command("followers")
7434
- .description("Order a LinkedIn company page's followers as a table of leads — a competitor's audience. `check` is free and quotes the exact cost; `order` places it. The order is fulfilled by ScrapeLi, so the table is created immediately and its rows land later; there is no published delivery time.");
7527
+ .description("Order a LinkedIn company page's followers as a table of leads — a competitor's audience. `check` is free and quotes the order; fee-positive own-key previews leave the follower count unknown. `order` places it. The order is fulfilled by ScrapeLi, so the table is created immediately and its rows land later; there is no published delivery time.");
7435
7528
  followersCommand.command("check")
7436
- .description("Free: read the page's follower count and quote the exact credits an order would reserve. No spend, no table; `orderable: false` means no order can be placed yet.")
7529
+ .description("Free order preview. Managed quotes the follower cost; your own key quotes placement and later check fees, with a total ceiling you choose. No spend or table; `orderable: false` means the order is unavailable.")
7437
7530
  .requiredOption("--company <url>", "LinkedIn company page URL, e.g. https://www.linkedin.com/company/hubspot.")
7438
7531
  .option("--records <n>", "How many followers to price. Minimum 100; defaults to 1000.")
7439
7532
  .option("--package <package>", "Basic (default): profile, job title, location, employer. Advanced (10x the credits): also a verified work email and region.")
7440
- .option("--credential-mode <mode>", "managed (default): Oxygen credits. user_api_key: your own ScrapeLi key saved in Connections, billed by ScrapeLi.")
7533
+ .option("--credential-mode <mode>", "managed (default): Oxygen credits. user_api_key: your saved ScrapeLi key, billed by ScrapeLi, plus Oxygen placement/recovery platform fees.")
7441
7534
  .option("--json", "Print a JSON envelope.")
7442
7535
  .action(async (options) => {
7443
7536
  await handleAsyncAction("tables followers check", options, () => requestOxygen("/api/cli/tables/linkedin-company-followers", {
@@ -7452,16 +7545,16 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7452
7545
  }));
7453
7546
  });
7454
7547
  followersCommand.command("order")
7455
- .description("Place the follower order after checking it. Requires --approved, and with managed credits --max-credits at or above the quoted cost: this buys a real audience and is never approved implicitly. Creates the table now; rows land when ScrapeLi delivers.")
7548
+ .description("Place the follower order after checking it. Requires --approved and --max-credits: managed uses the quoted order cost; own-key uses a TOTAL placement/recovery platform-fee ceiling. A ceiling equal to placement cannot recover rows. Budget exhaustion lands no partial rows; refetched pages consume the same ceiling. Creates the Table now; rows arrive later.")
7456
7549
  .requiredOption("--approved", "Approve this purchase. Required, as on every command that buys data.")
7457
7550
  .requiredOption("--company <url>", "LinkedIn company page URL whose followers to order.")
7458
- .option("--max-credits <credits>", "Hard credit ceiling for this order. Must be at least the amount `tables followers check` quoted. Not needed with --credential-mode user_api_key.")
7459
- .requiredOption("--request-id <uuid>", "One stable UUID for this order. Reuse it after a timeout; a new key would buy the same audience twice.")
7551
+ .option("--max-credits <credits>", "Approved order ceiling. On your key, TOTAL platform fees for placement and later checks; exhaustion stops recovery. At least the quoted placement fee. ScrapeLi charges separately.")
7552
+ .requiredOption("--request-id <uuid>", "One stable UUID and identical order payload for retries. A new ID can buy the audience again.")
7460
7553
  .option("--records <n>", "How many followers to buy. Minimum 100; defaults to 1000.")
7461
7554
  .option("--package <package>", "Basic (default) or Advanced (10x the credits; adds a verified work email and region).")
7462
7555
  .option("--name <name>", "Table name. Defaults to \"<Company> Followers\".")
7463
7556
  .option("--project <project>", "Project id or slug; defaults to General.")
7464
- .option("--credential-mode <mode>", "managed (default): Oxygen credits. user_api_key: your own ScrapeLi key saved in Connections, billed by ScrapeLi.")
7557
+ .option("--credential-mode <mode>", "managed (default): Oxygen credits. user_api_key: your saved ScrapeLi key, billed by ScrapeLi, plus Oxygen placement/recovery platform fees.")
7465
7558
  .option("--json", "Print a JSON envelope.")
7466
7559
  .action(async (options) => {
7467
7560
  await handleAsyncAction("tables followers order", options, () => requestOxygen("/api/cli/tables/linkedin-company-followers", {
@@ -7480,6 +7573,23 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7480
7573
  },
7481
7574
  }));
7482
7575
  });
7576
+ followersCommand.command("status")
7577
+ .description("Read a fee-budgeted own-key follower order's total fee ceiling, allocated attempts, recorded fees and recovery outcome by its original request ID, including interrupted setup. Optional --table-id verifies the saved Table binding. Recorded fees may settle later. Makes no provider call.")
7578
+ .requiredOption("--request-id <uuid>", "Original order request ID.")
7579
+ .option("--table-id <id>", "Optional saved Table ID; verifies the order belongs to that Table.")
7580
+ .option("--json", "Print a JSON envelope.")
7581
+ .action(async (options) => {
7582
+ await handleAsyncAction("tables followers status", options, () => {
7583
+ const requestId = readOption(options.requestId);
7584
+ if (!requestId)
7585
+ throw new OxygenError("invalid_input", "--request-id must be the original nonblank order request ID.", { exitCode: 2 });
7586
+ const query = new URLSearchParams({ request_id: requestId });
7587
+ const tableId = readOption(options.tableId);
7588
+ if (tableId)
7589
+ query.set("table_id", tableId);
7590
+ return requestOxygen(`/api/cli/tables/linkedin-company-followers?${query}`, { method: "GET" });
7591
+ });
7592
+ });
7483
7593
  tablesCommand.addCommand(new Command("relate")
7484
7594
  .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.")
7485
7595
  .argument("<table>", "Source table id or slug.")
@@ -8329,7 +8439,7 @@ Examples:
8329
8439
  .option("--body <text>", "Asset body text.")
8330
8440
  .option("--data-json <json>", "Flexible JSON object for structured asset data.")
8331
8441
  .option("--asset-json <json>", "Full asset JSON object. CLI flags override matching fields.")
8332
- .option("--default", "Pin as the canonical default for its type (auto-applied to copy generation). brand/voice/positioning defaults are injected into AI message columns.")
8442
+ .option("--default", "Ask to pin as the canonical default for its type (auto-applied to copy generation). For brand, voice, positioning and playbooks the asset saves unpinned and the pin waits for approval of a proposal (pin.ok false; oxygen knowledge proposals approve <id>); other types pin at once.")
8333
8443
  .option("--json", "Print a JSON envelope.")
8334
8444
  .action(async (options) => {
8335
8445
  await handleAsyncAction("context asset upsert", options, () => requestOxygen("/api/cli/context/assets/upsert", {
@@ -8338,7 +8448,7 @@ Examples:
8338
8448
  }));
8339
8449
  }))
8340
8450
  .addCommand(new Command("set-default")
8341
- .description("Pin a context asset as the canonical default for its type. The canonical brand/voice/positioning docs are auto-applied to AI copy generation.")
8451
+ .description("Pin a context asset as the canonical default for its type. The canonical brand/voice/positioning docs are auto-applied to AI copy generation. For brand, voice, positioning and playbooks a pin or unpin files a proposal a human approves (oxygen knowledge proposals approve <id>) and fails with knowledge_approval_required; other types pin at once.")
8342
8452
  .argument("<asset_id>", "Context asset UUID.")
8343
8453
  .option("--off", "Unpin instead of pin (clears the canonical default for this type).")
8344
8454
  .option("--json", "Print a JSON envelope.")
@@ -8349,7 +8459,7 @@ Examples:
8349
8459
  }));
8350
8460
  }))
8351
8461
  .addCommand(new Command("archive")
8352
- .description("Archive a context asset.")
8462
+ .description("Archive a context asset. Archiving the canonical brand, voice, positioning or playbook files an approval proposal and fails with knowledge_approval_required.")
8353
8463
  .argument("<asset_id>", "Context asset UUID.")
8354
8464
  .option("--json", "Print a JSON envelope.")
8355
8465
  .action(async (assetId, options) => {
@@ -8371,7 +8481,7 @@ Examples:
8371
8481
  .option("--live", "Authorize this manual run to spend and write. Requires --max-credits. Without it the command previews and nothing is billed.")
8372
8482
  .option("--approved", "Same as --live. Accepted because the global help footer names --approved as the way to authorize a credit-spending command.")
8373
8483
  .option("--max-credits <credits>", `Hard credit ceiling for the whole pass. Required with --live, and clamped server-side to ${KNOWLEDGE_BOOTSTRAP_MAX_CREDITS}.`)
8374
- .option("--force", "Clear an existing once-ever marker first and re-arm the pass. This is the one deliberate way to research the same workspace twice — pair it with --live to actually spend again.")
8484
+ .option("--force", "Re-arm past an existing once-ever marker, the one deliberate way to research the same workspace twice. Without --live it only previews the re-arm for free: nothing is cleared or written. With --live it replaces the marker with a new pass and spends again.")
8375
8485
  .option("--json", "Print a JSON envelope.")
8376
8486
  .action(async (options) => {
8377
8487
  await handleAsyncAction("knowledge bootstrap", options, () => requestOxygen("/api/cli/knowledge/bootstrap", {
@@ -8442,14 +8552,14 @@ Examples:
8442
8552
  .addCommand(new Command("page")
8443
8553
  .description("Knowledge wiki pages (slug-addressed, revision-guarded).")
8444
8554
  .addCommand(new Command("upsert")
8445
- .description("Create or update a knowledge wiki page.")
8446
- .option("--slug <slug>", "Stable page slug to create or address. Omit to create by title.")
8447
- .option("--folder <folder_id>", "Folder UUID, or root to remove folder placement. Omit to preserve the current folder.")
8555
+ .description("Create or update a knowledge wiki page. Free internal write; canonical edits require proposal approval.")
8556
+ .option("--slug <slug>", "Stable page key. Omit to derive from title; an existing slug updates unless --create-only is set.")
8557
+ .option("--folder <folder_id>", "Folder UUID, or root to clear placement; omit to preserve. Moving creates a revision and changes data.knowledge_folder_id.")
8448
8558
  .option("--create-only", "Fail if the page already exists instead of updating it.")
8449
- .option("--id <page_id>", "Existing page UUID to update. Omit to create.")
8450
- .option("--type <type>", "Page type (positioning, competitor, research_note, playbook, strategy, other). Defaults to other on create.")
8559
+ .option("--id <page_id>", "Existing page UUID to update. For a new file, omit and use --create-only.")
8560
+ .option("--type <type>", `Page type: ${KNOWLEDGE_PAGE_TYPES.join(", ")}. voice, brand and positioning are canonical copy once pinned. Defaults to other on create.`)
8451
8561
  .option("--title <title>", "Page title. Required on create.")
8452
- .option("--status <status>", "draft, active, or archived.")
8562
+ .option("--status <status>", "draft, active, or archived. Restore with draft and --expected-revision; omit --body to retain content.")
8453
8563
  .option("--tags <csv>", "Comma-separated page tags.")
8454
8564
  .option("--summary <text>", "Short page summary.")
8455
8565
  .option("--body <text>", "Page body (Markdown, supports [[wikilinks]]).")
@@ -8468,7 +8578,7 @@ Examples:
8468
8578
  });
8469
8579
  }))
8470
8580
  .addCommand(addContractFlags(new Command("get")
8471
- .description("Read one knowledge wiki page by slug or UUID. Read-only, 0 credits.")
8581
+ .description("Read one knowledge wiki page by slug or UUID. Use --view sources for its evidence ledger or --view full for content, links and sources. Read-only, 0 credits.")
8472
8582
  .argument("<slug_or_id>", "Page slug or UUID.")
8473
8583
  .option("--json", "Print a JSON envelope."), knowledgePageReadContract, { slug: { omit: true }, id: { omit: true } })
8474
8584
  .action(async (slugOrId, options) => {
@@ -8488,14 +8598,22 @@ Examples:
8488
8598
  }));
8489
8599
  }))
8490
8600
  .addCommand(new Command("archive")
8491
- .description("Archive a knowledge wiki page.")
8601
+ .description("Archive a knowledge wiki page while retaining its content and history. Links become unresolved. Restore with page upsert --status draft and the current --expected-revision.")
8492
8602
  .argument("<slug_or_id>", "Page slug or UUID.")
8603
+ .option("--expected-revision <n>", "Fail if the current page revision differs from the version you reviewed.")
8493
8604
  .option("--json", "Print a JSON envelope.")
8494
8605
  .action(async (slugOrId, options) => {
8495
8606
  await handleAsyncAction("knowledge page archive", options, async () => {
8607
+ const expectedRevision = readPositiveInt(options.expectedRevision);
8608
+ if (options.expectedRevision !== undefined && (expectedRevision === undefined || !Number.isSafeInteger(expectedRevision))) {
8609
+ throw new OxygenError("invalid_request", "--expected-revision must be a positive integer.", { exitCode: 2 });
8610
+ }
8496
8611
  const data = await requestOxygen("/api/cli/knowledge/pages/archive", {
8497
8612
  method: "POST",
8498
- body: knowledgePageRefBody(slugOrId),
8613
+ body: {
8614
+ ...knowledgePageRefBody(slugOrId),
8615
+ ...(expectedRevision !== undefined ? { expected_revision: expectedRevision } : {}),
8616
+ },
8499
8617
  });
8500
8618
  await markKnowledgeMirrorStaleAfterWrite();
8501
8619
  return data;
@@ -8565,7 +8683,7 @@ Examples:
8565
8683
  .description("Search wiki pages by meaning and keywords. Read-only, 0 credits. Plain language uses hybrid retrieval when available; check match_type and semantic_status. Quotes, OR, and exclusions stay lexical. If the platform-funded embedding allowance is unavailable, keyword search still works.")
8566
8684
  .argument("<query>", "Search text. Supports quoted phrases, OR, and -exclude.")
8567
8685
  .option("--type <type>", "Filter by page type.")
8568
- .option("--status <status>", "Filter by draft, active, or archived.")
8686
+ .option("--status <status>", "Filter by draft, active, or archived. Omitted, archived pages are excluded.")
8569
8687
  .option("--tags <csv>", "Comma-separated tags that must be present.")
8570
8688
  .option("--limit <n>", "Maximum pages to return.")
8571
8689
  .option("--json", "Print a JSON envelope.")
@@ -8737,13 +8855,14 @@ Examples:
8737
8855
  }))
8738
8856
  .addCommand(knowledgeBootstrapCommand)
8739
8857
  .addCommand(new Command("synthesize")
8740
- .description("Synthesize knowledge from workspace evidence (one low-cost AI call, priced as one low-reasoning AI column cell): campaign learnings from auto-filed outreach outcomes, or your writing voice (a voice guide) learned from your sent LinkedIn and WhatsApp messages. Every run spends credits, the preview included, unless fewer than 3 sent messages exist (insufficient_source_messages, nothing charged); without --approved nothing is saved. To keep the guide a preview showed, pass its body back with --approved --body (no new AI call); --approved alone synthesizes and charges again. A pinned voice guide is the canonical voice at once and grounds message, reply, sequence and post drafts and AI columns.")
8858
+ .description("Synthesize knowledge from workspace evidence (one low-cost AI call, priced as one low-reasoning AI column cell): campaign learnings from auto-filed outreach outcomes, or your writing voice (a voice guide) learned from your sent LinkedIn and WhatsApp messages. Every run spends credits, the preview included, unless fewer than 3 sent messages exist (insufficient_source_messages, nothing charged); without --approved nothing is saved. To keep the guide a preview showed, pass its body back with --approved --body (no new AI call); --approved alone synthesizes and charges again. A saved voice guide becomes the canonical voice once a human approves its pin proposal (oxygen knowledge proposals approve <id>); it then grounds message, reply, sequence and post drafts and AI columns.")
8741
8859
  .option("--kind <kind>", "What to synthesize: campaign_learnings (default) or voice (your writing voice from sent LinkedIn and WhatsApp messages).", "campaign_learnings")
8742
8860
  .option("--sequence <sequence_id>", "Scope campaign learnings to one sequence's learnings page. Omit for the org-wide [[outreach-learnings]] page.")
8743
- .option("--approved", "Write the synthesized result (learnings page revision / pinned voice guide) instead of previewing.")
8744
- .option("--body <markdown>", "With --approved: save this body verbatim (the guide a preview showed, or your edit) with no new AI call.")
8861
+ .option("--approved", "Write the synthesized result (learnings page revision / voice guide saved with a pin proposal) instead of previewing.")
8862
+ .option("--body <markdown>", "With --approved: save this body verbatim (the guide a preview showed, or your edit) with no new AI call, at 0 credits.")
8745
8863
  .option("--title <title>", "Optional title for a newly created page/asset.")
8746
8864
  .option("--limit <n>", "Voice only: how many recent sent messages to learn from (max 200).")
8865
+ .option("--max-credits <n>", "Hard cap OXYGEN enforces on this call: it is charged exactly its estimate, so never more than n. A higher estimate is refused before any AI call, with nothing charged, and the refusal reports the estimate (--max-credits 0 shows it for free). --approved --body makes no AI call and needs none.")
8747
8866
  .option("--json", "Print a JSON envelope.")
8748
8867
  .action(async (options) => {
8749
8868
  await handleAsyncAction("knowledge synthesize", options, () => requestOxygen("/api/cli/knowledge/synthesize", {
@@ -8755,6 +8874,7 @@ Examples:
8755
8874
  ...(readOption(options.body) ? { body: readOption(options.body) } : {}),
8756
8875
  ...(readOption(options.title) ? { title: readOption(options.title) } : {}),
8757
8876
  ...(readPositiveInt(options.limit) !== undefined ? { limit: readPositiveInt(options.limit) } : {}),
8877
+ ...(readNonNegativeNumber(options.maxCredits) !== undefined ? { max_credits: readNonNegativeNumber(options.maxCredits) } : {}),
8758
8878
  },
8759
8879
  }));
8760
8880
  }))
@@ -9715,8 +9835,8 @@ Examples:
9715
9835
  .option("--reasoning-level <level>", REASONING_LEVEL_OPTION_HELP)
9716
9836
  .option("--run-condition <formula>", "Formula expression gating whether the AI column runs per row. A formula over bare column keys (eu_israel = true), not {{tokens}}.")
9717
9837
  .option("--run-condition-columns <csv>", "Comma-separated column keys referenced by --run-condition.")
9718
- .option("--no-structured-output", "Keep an AI column answering in plain text even when its prompt names output sections. Use this for copy — an email body is one answer, not a set of fields.")
9719
9838
  .option("--structured-output", "Undo --no-structured-output: let the prompt's named sections shape the answer again.")
9839
+ .option("--no-structured-output", "Keep an AI column answering in plain text even when its prompt names output sections. Use this for copy — an email body is one answer, not a set of fields.")
9720
9840
  .option("--output-schema-json <json>", "AI column output JSON schema as inline JSON.")
9721
9841
  .option("--output-schema-file <path>", "AI column output JSON schema read from a file path.")
9722
9842
  .addOption(new Option("--classify <labels>", "Classification column: label every row with one of these comma-separated labels (2-20), answering --prompt. Each cell holds the label, a 0-1 confidence and uncertain:true below --min-confidence (the best guess is kept). Flat ~0.075 credits/row; see `columns run --dry-run`."))
@@ -10161,8 +10281,10 @@ Examples:
10161
10281
  .option("--reasoning-level <level>", REASONING_LEVEL_OPTION_HELP)
10162
10282
  .option("--run-condition <formula>", "Formula expression gating whether the AI column runs per row. A formula over bare column keys (eu_israel = true), not {{tokens}}.")
10163
10283
  .option("--run-condition-columns <csv>", "Comma-separated column keys referenced by --run-condition.")
10284
+ // Positive first keeps omission undefined; negative first defaults true
10285
+ // and silently overwrites a saved prose choice on an unrelated update.
10286
+ .option("--structured-output", "Restore the prompt's named JSON fields. Existing AI columns must store jsonb; for a text column, add a new AI column with --data-type jsonb.")
10164
10287
  .option("--no-structured-output", "Keep an AI column answering in plain text even when its prompt names output sections. Use this for copy — an email body is one answer, not a set of fields.")
10165
- .option("--structured-output", "Undo --no-structured-output: let the prompt's named sections shape the answer again.")
10166
10288
  .option("--output-schema-json <json>", "AI column output JSON schema as inline JSON.")
10167
10289
  .option("--output-schema-file <path>", "AI column output JSON schema read from a file path.")
10168
10290
  .option("--research-query <template>", "Research columns: replace the per-row web search query template, e.g. \"{{company_name}} pricing page\".")
@@ -10189,7 +10311,8 @@ Examples:
10189
10311
  readOption(options.reasoningLevel) ||
10190
10312
  readOption(options.runCondition) ||
10191
10313
  readOption(options.outputSchemaJson) ||
10192
- readOption(options.outputSchemaFile);
10314
+ readOption(options.outputSchemaFile) ||
10315
+ typeof options.structuredOutput === "boolean";
10193
10316
  let definition = options.definitionJson
10194
10317
  ? parseJsonObject(options.definitionJson)
10195
10318
  : undefined;
@@ -10951,9 +11074,10 @@ Examples:
10951
11074
  await handleAsyncAction("companies search filters", options, () => requestOxygen(`/api/cli/companies/search/preview${readCompanySourceQuery(options.source)}`));
10952
11075
  }))
10953
11076
  .addCommand(new Command("preview")
10954
- .description("Preview up to 50 rows free, using native filters from companies search filters. --source google_maps previews 20 businesses with a q business-and-area filter; total_results is unavailable when the API publishes no total. --source hiring previews matching job postings with an exact total.")
11077
+ .description("Preview up to 50 rows free, using native filters from companies search filters. --source google_maps previews 20 businesses with a q business-and-area filter; total_results is unavailable when the API publishes no total. --source hiring previews matching job postings with an exact total. Totals, notes and the priced next step come first and the rows last; each row is compact (name, domain, LinkedIn, headcount, industry, location, a one-line description) unless --view full.")
10955
11078
  .requiredOption("--filters-json <json-or-file>", 'Native filters as JSON or @file/path. Google Maps requires q, placeId or cid; other sources accept {} for all results.')
10956
11079
  .option("--source <source>", "company_search (default), hiring or google_maps. Filters for hiring nest under job and company, e.g. {\"job\":{\"title\":{\"include\":[\"Account Executive\"]}}}.")
11080
+ .option("--view <view>", "compact (default) or full: full adds each row's provider record as raw_source, about 2.5 KB a company.")
10957
11081
  .option("--json", "Print a JSON envelope.")
10958
11082
  .action(async (options) => {
10959
11083
  await handleAsyncAction("companies search preview", options, () => requestOxygen("/api/cli/companies/search/preview", {
@@ -10961,6 +11085,7 @@ Examples:
10961
11085
  body: {
10962
11086
  filters: parseJsonObject(readFileIfPresent(options.filtersJson)),
10963
11087
  ...(readCompanySearchSource(options.source) !== "company_search" ? { source: readCompanySearchSource(options.source) } : {}),
11088
+ ...(options.view !== undefined ? { view: options.view } : {}),
10964
11089
  },
10965
11090
  }));
10966
11091
  }))
@@ -11082,7 +11207,7 @@ Examples:
11082
11207
  await handleAsyncAction("people search filters", options, () => requestOxygen("/api/cli/people/search/preview"));
11083
11208
  }))
11084
11209
  .addCommand(new Command("preview")
11085
- .description("Preview up to 50 people free, using native filters from people search filters. For the companies in one of your tables, pass --from-table with --linkedin-url-column; for a few named companies, put their LinkedIn company URLs in company.linkedin_url. Then people search run --source people_search --source-filters-json <same filters> for a quoted, approved table fill; people search run --from-table instead runs one paid search per company in a table and covers each one.")
11210
+ .description("Preview up to 50 people free, using native filters from people search filters. For the companies in one of your tables, pass --from-table with --linkedin-url-column; for a few named companies, put their LinkedIn company URLs in company.linkedin_url. Totals, per-company counts, company_fit (what a person row cannot tell you about fit) and the priced next step come first, the rows last. Then people search run --source people_search --source-filters-json <same filters> for a quoted, approved table fill; people search run --from-table instead runs one paid search per company in a table and covers each one.")
11086
11211
  .requiredOption("--filters-json <json-or-file>", 'Nested native filters as JSON or @file/path, e.g. {"people":{"job_title":{"include":["VP Sales"]}},"company":{"linkedin_url":["https://linkedin.com/company/acme"]}}; {} searches all.')
11087
11212
  .option("--from-table <table>", "Free sample of Find decision makers at companies: search the first 50 companies in this table in one free page. Pair with --linkedin-url-column.")
11088
11213
  .option("--linkedin-url-column <key>", "Column of --from-table that holds each company's LinkedIn URL.")
@@ -11269,49 +11394,32 @@ Examples:
11269
11394
  }))
11270
11395
  .addCommand(new Command("provider")
11271
11396
  .description("Per-provider call-rate overrides and BYOK daily warning thresholds. With no subcommand, lists policies (same as `limits provider list`).")
11272
- .addCommand(new Command("list")
11273
- .description("List enforced provider overrides and the plan-tier BYOK daily threshold with its enforcement mode.")
11274
- .option("--provider <provider>", "Filter saved overrides to one provider id; plan-tier defaults are always returned.")
11397
+ .addCommand(addProviderLimitFlags(new Command("list")
11398
+ .description("List enforced provider overrides and the plan-tier BYOK daily threshold with its enforcement mode."), "list")
11275
11399
  .option("--json", "Print a JSON envelope.")
11276
11400
  .action(async (options) => {
11277
- const query = new URLSearchParams();
11278
- if (readOption(options.provider))
11279
- query.set("provider", readOption(options.provider) ?? "");
11280
- const suffix = query.toString();
11401
+ const suffix = providerLimitQuery(options, "list");
11281
11402
  await handleAsyncAction("limits provider list", options, () => requestOxygen(`/api/cli/limits/provider${suffix ? `?${suffix}` : ""}`));
11282
11403
  }), { isDefault: true })
11283
- .addCommand(new Command("set")
11284
- .description("Set an enforced per-provider call ceiling. Use --window-seconds 86400 for an explicit daily policy.")
11285
- .requiredOption("--provider <provider>", "Provider id (e.g. hubspot, leadmagic).")
11286
- .requiredOption("--limit <n>", "Calls allowed per window.")
11287
- .requiredOption("--window-seconds <n>", "Window size in seconds (86400 = one day).")
11288
- .option("--operation <operation>", "Scope to one operation; omitted = provider-wide.")
11404
+ .addCommand(addProviderLimitFlags(new Command("set")
11405
+ .description("Set an enforced provider request limit; admin/owner only. Approve the exact provider, operation and window before running; execution saves the policy. Raising a cap can permit more use. 0 credits."), "set")
11289
11406
  .option("--json", "Print a JSON envelope.")
11290
11407
  .action(async (options) => {
11291
11408
  await handleAsyncAction("limits provider set", options, () => requestOxygen("/api/cli/limits/provider", {
11292
11409
  method: "POST",
11293
- body: {
11294
- provider: options.provider,
11295
- limit: Number(options.limit),
11296
- window_seconds: Number(options.windowSeconds),
11297
- ...(options.operation ? { operation: options.operation } : {}),
11298
- },
11410
+ body: providerLimitBody(options),
11299
11411
  }));
11300
11412
  }))
11301
- .addCommand(new Command("delete")
11302
- .description("Remove a per-provider override (falls back to the plan-tier default).")
11303
- .requiredOption("--provider <provider>", "Provider id.")
11304
- .option("--operation <operation>", "Operation scope; omitted targets the provider-wide row.")
11413
+ .addCommand(addProviderLimitFlags(new Command("delete")
11414
+ .description("Remove one provider override; admin/owner only. Approve its selector before running; execution deletes the policy. Other overrides remain; BYOK defaults may be observe-only. 0 credits."), "delete")
11305
11415
  .option("--json", "Print a JSON envelope.")
11306
11416
  .action(async (options) => {
11307
- const query = new URLSearchParams({ provider: options.provider });
11308
- if (options.operation)
11309
- query.set("operation", options.operation);
11417
+ const query = providerLimitQuery(options, "delete");
11310
11418
  await handleAsyncAction("limits provider delete", options, () => requestOxygen(`/api/cli/limits/provider?${query.toString()}`, { method: "DELETE" }));
11311
11419
  })));
11312
11420
  program
11313
11421
  .command("billing")
11314
- .description("Plan and managed credit commands. Spend splits into FLEXIBLE (ad-hoc: enrichment, AI, automation — drawn from your free-to-spend balance) and FIXED recurring per-resource monthly commitments blocked out of it; see `billing commitments`. THREE CLOCKS, deliberately different: the CREDIT CYCLE that `billing allowance` reports against (your plan's monthly grant window); each resource's own COMMITMENT RENEWAL, anchored to the day you connected it, so `billing commitments --json` next_due_at rarely matches the cycle end; and your SUBSCRIPTION PERIOD in `billing balance` (annual plans span many credit cycles). A number from one clock will not reconcile against another. On a plan that pays for several workspaces, `billing usage --group-by workspace` totals what each one spent. To price a month of outreach before building it (leads, enrichment, sequence steps, senders), run `billing estimate`. Failed-payment grace, suspension, and recovery: https://oxygen-agent.com/docs/safety/billing.")
11422
+ .description("Plan and managed credit commands. Credits reserved but not yet charged are listed hold by hold with `billing usage --meter holds`. Spend splits into FLEXIBLE (ad-hoc: enrichment, AI, automation — drawn from your free-to-spend balance) and FIXED recurring per-resource monthly commitments blocked out of it; see `billing commitments`. THREE CLOCKS, deliberately different: the CREDIT CYCLE that `billing allowance` reports against (your plan's monthly grant window); each resource's own COMMITMENT RENEWAL, anchored to the day you connected it, so `billing commitments --json` next_due_at rarely matches the cycle end; and your SUBSCRIPTION PERIOD in `billing balance` (annual plans span many credit cycles). A number from one clock will not reconcile against another. On a plan that pays for several workspaces, `billing usage --group-by workspace` totals what each one spent. To price a month of outreach before building it (leads, enrichment, sequence steps, senders), run `billing estimate`. Failed-payment grace, suspension, and recovery: https://oxygen-agent.com/docs/safety/billing.")
11315
11423
  .addCommand(new Command("change")
11316
11424
  .description("Create an approval link (`approval_url`) to start or change a plan; nothing changes until an organization admin confirms. Compare all six prices, credits and yearly terms with `billing balance` first. Use --interval month --term year for twelve discounted monthly payments, renewing for another year: the $99 plan costs $82.50 per month before tax, not $990 upfront. Credits arrive monthly after successful payment. On a workspace that has never had a plan, the Checkout for any monthly plan from $99 up carries the first-month offer, half its first month, while its 24-hour window is open (`offer`; `billing balance` lists every size under `first_month_offer.plans`): `amount_due_now` is then the discounted first month and `next_invoice_total` the regular price, both before tax. Creating that Checkout starts the window if nothing has started it yet. Upgrades during an annual commitment apply immediately with proration and keep its end date; downgrades and switching to a monthly commitment wait for the term end. Switching from a monthly commitment to yearly starts at the next monthly boundary. Existing prepaid yearly plans keep their cadence unless a new term is selected; transitions wait until their paid period ends. Omit interval and term to preserve the current contract. For an existing subscription, a yearly preview returns charges, start and term-end dates; nothing changes until separate confirmation.")
11317
11425
  .requiredOption("--to <plan>",
@@ -11345,16 +11453,17 @@ Examples:
11345
11453
  }));
11346
11454
  }))
11347
11455
  .addCommand(new Command("balance")
11348
- .description("Compare all six subscription sizes in `available_plans`: monthly prices, yearly paid-monthly prices, credit allowances and savings. `annual_monthly_billing_available` says whether yearly paid monthly is offered; `annual_monthly_terms` explains the 12-month commitment, annual renewal, remaining payments after cancellation and plan-change timing. `first_month_offer` is the first-month offer this workspace can still claim: every size it applies to (`plans`, from `minimum_plan` up) with its first-month and regular price before tax, and `ends_at`, null until its 24-hour window starts. The window starts the first time the workspace meets the offer: the app showing it, signup from a browser that saw it on the website, or creating its Checkout with `billing change`. Also show the current plan, commitment, subscription entitlement, and managed credit balance: available and reserved credits, FIXED credits committed to recurring per-resource charges, and the FLEXIBLE free-to-spend remainder. `next_renewal` answers \"will I be charged?\": read `will_charge` first — true only when this workspace's own Stripe plan is going to charge its card, in which case `charge_at` and the subscription's recurring amount in money say when and roughly how much (the exact amount, with any promotion code or tax, is on the invoice); `period_ends_at` is the trial or period end either way, `cancellation_scheduled` says whether a cancellation is already set, and `stop_command` names the one command that changes it (`billing cancel`, undone by `billing resume`) or is null when nothing here can stop it (staff-invoiced, billed through another workspace, not active). `billing_interval` is payment cadence; `commitment` distinguishes yearly paid monthly from monthly and legacy prepaid yearly plans. Credits are granted monthly; `next_credit_grant_at` is the next grant. For a yearly commitment, cancellation stops annual renewal but remaining monthly payments continue until commitment.ends_at. `renewals_past_due` lists infrastructure renewals (managed-inbox domains, warm-ups) OXYGEN could NOT charge this month, what they cost, and the exact top-up that clears them — nothing is cancelled and billing retries automatically after a top-up. `warnings` also flags a recurring_shortfall when your connected infrastructure costs more per month than the plan grants. `partner_bonus` says whether this workspace earns the agency partner bonus (+15% credits a month for a published directory partner on $99 or above), how many credits, and why not when it does not; the bonus arrives as its own ledger line (`billing usage --category partner_bonus`), never inside plan.monthly_credits. A workspace linked to another organization (`orgs billing-link`) reads its billing at that owner: the balance is the owner's pool, `shared_billing` is true and `billing_owner_organization_id` names the owner. After failed-payment grace expires, mutations stop while read/export and billing recovery remain available. Recovery: https://oxygen-agent.com/billing. Policy: https://oxygen-agent.com/docs/safety/billing.")
11456
+ .description("Compare all six subscription sizes in `available_plans`: monthly prices, yearly paid-monthly prices, credit allowances and savings. `annual_monthly_billing_available` says whether yearly paid monthly is offered; `annual_monthly_terms` explains the 12-month commitment, annual renewal, remaining payments after cancellation and plan-change timing. `first_month_offer` is the first-month offer this workspace can still claim: every size it applies to (`plans`, from `minimum_plan` up) with its first-month and regular price before tax, and `ends_at`, null until its 24-hour window starts. The window starts the first time the workspace meets the offer: the app showing it, signup from a browser that saw it on the website, or creating its Checkout with `billing change`. Also show the current plan, commitment, subscription entitlement, and managed credit balance: available and reserved credits, FIXED credits committed to recurring per-resource charges, and the FLEXIBLE free-to-spend remainder. `next_renewal` answers \"will I be charged?\": read `will_charge` first — true only when this workspace's own Stripe plan is going to charge its card, in which case `charge_at` and the subscription's recurring amount in money say when and roughly how much (the exact amount, with any promotion code or tax, is on the invoice); `period_ends_at` is the trial or period end either way, `cancellation_scheduled` says whether a cancellation is already set, and `stop_command` names the one command that changes it (`billing cancel`, undone by `billing resume`) or is null when nothing here can stop it (staff-invoiced, billed through another workspace, not active). `billing_interval` is payment cadence; `commitment` distinguishes yearly paid monthly from monthly and legacy prepaid yearly plans. Credits are granted monthly; `next_credit_grant_at` is the next grant. For a yearly commitment, cancellation stops annual renewal but remaining monthly payments continue until commitment.ends_at. `renewals_past_due` lists infrastructure renewals (managed-inbox domains, warm-ups) OXYGEN could NOT charge this month, what they cost, and the exact top-up that clears them — nothing is cancelled and billing retries automatically after a top-up. `warnings` also flags a recurring_shortfall when your connected infrastructure costs more per month than the plan grants. `partner_bonus` says whether this workspace earns the agency partner bonus (+15% credits a month for a published directory partner on $99 or above), how many credits, and why not when it does not; the bonus arrives as its own ledger line (`billing usage --category partner_bonus`), never inside plan.monthly_credits. A workspace linked to another organization (`orgs billing-link`) reads its billing at that owner: the balance is the owner's pool, `shared_billing` is true and `billing_owner_organization_id` names the owner. After failed-payment grace expires, mutations stop while read/export and billing recovery remain available. Recovery: https://oxygen-agent.com/billing. Policy: https://oxygen-agent.com/docs/safety/billing. 0 credits; reserves nothing. Due plan grants or credit expiry may apply.")
11349
11457
  .option("--json", "Print a JSON envelope.")
11350
11458
  .action(async (options) => {
11351
11459
  await handleAsyncAction("billing balance", options, () => requestOxygen("/api/cli/billing/balance"));
11352
11460
  }))
11353
11461
  .addCommand(new Command("commitments")
11354
- .description("List the fixed monthly credit commitments blocked at your subscription renewal — per connected sending mailbox, OXYGEN-sold mailbox, warm-up, deliverability, connected LinkedIn, WhatsApp and X account, and rented phone number — with unit price, quantity, and next-due date. When your balance cannot cover an account's renewal, sending from that account pauses at once and it disconnects 7 days later (a phone number is released after 30): paused_quantity, past_due_since, disconnects_at and topup_url say which kind, since when and by when to top up; a top-up resumes it within minutes. This is the FORWARD run-rate: what your currently-connected resources will cost at their next renewal. It is NOT this cycle's charges — compare `billing allowance` fixed_spent_credits for that, and expect the two to differ. Each resource renews on its OWN anchor (the day it was connected), so next_due_at is per-resource and rarely lines up with the credit cycle. The top-level count is the number of individual resource rows; the commitments list groups them by kind. A past next_due_at is an unpaid renewal still due. To stop all infrastructure renewals while retaining records, preview `workspace infrastructure-exit`; plan cancellation alone does not stop these resources. Read-only, 0 Oxygen credits.")
11462
+ .description("List recurring commitments, or inspect monthly connection prices and first-debit timing with --view connection-prices without creating authorization. Default: list the fixed monthly credit commitments — per connected sending mailbox, OXYGEN-sold mailbox, warm-up, deliverability, connected LinkedIn, WhatsApp and X account, and rented phone number — with unit price, quantity, and next-due date. When your balance cannot cover an account's renewal, sending from that account pauses at once and it disconnects 7 days later (a phone number is released after 30): paused_quantity, past_due_since, disconnects_at and topup_url say which kind, since when and by when to top up; a top-up resumes it within minutes. This is the FORWARD run-rate: what your currently-connected resources will cost at their next renewal. It is NOT this cycle's charges — compare `billing allowance` fixed_spent_credits for that, and expect the two to differ. Each resource renews on its OWN anchor (the day it was connected), so next_due_at is per-resource and rarely lines up with the credit cycle. The top-level count is the number of individual resource rows; the commitments list groups them by kind. A past next_due_at is an unpaid renewal still due. To stop all infrastructure renewals while retaining records, preview `workspace infrastructure-exit`; plan cancellation alone does not stop these resources. Read-only, 0 Oxygen credits.")
11463
+ .addOption(new Option("--view <view>", "Inspect connection-prices before authorizing a new account, or current commitments (default).").choices(["commitments", "connection-prices"]))
11355
11464
  .option("--json", "Print a JSON envelope.")
11356
11465
  .action(async (options) => {
11357
- await handleAsyncAction("billing commitments", options, () => requestOxygen("/api/cli/billing/commitments"));
11466
+ await handleAsyncAction("billing commitments", options, () => requestOxygen(`/api/cli/billing/commitments${options.view ? `?view=${encodeURIComponent(options.view)}` : ""}`));
11358
11467
  }))
11359
11468
  .addCommand(new Command("estimate")
11360
11469
  .description("Estimate what a month of outreach costs before you build it: leads × enrichment × sequence steps × senders. Returns one line per cost (enrichment credits expected and worst case, each mailbox and LinkedIn account at its monthly price, sends at 0 credits), `totals`, `plan_fit` (does your plan cover it, and the top-up if not), `recommended_plan` (the cheapest plan plus top-ups that covers it) and `limits` (emails per mailbox, LinkedIn invites and messages per account; `limits_hit` names each one the scenario exceeds and `senders_needed` how many senders it takes). Steps are counted per channel: a 3-step sequence of two emails and one LinkedIn invite is `--email-steps 2 --linkedin-steps 1`, and `summary` restates what was priced. `monthly_usd` is all in: plan, top-ups and dollar seats. Prices are the ones in force; `sender_billing` says whether senders are paid as dollar seats or monthly credit reservations. Example: `oxygen billing estimate --leads 500 --enrich work_email --email-steps 2 --linkedin-steps 1 --mailboxes 1 --linkedin-accounts 1 --json`. `web_url` opens the same estimate in Settings → Plan limits. Read-only, 0 Oxygen credits.")
@@ -11383,13 +11492,13 @@ Examples:
11383
11492
  }));
11384
11493
  }))
11385
11494
  .addCommand(new Command("seats")
11386
- .description("Show sending capacity per channel: how many seats are purchased, how many are grandfathered, how many senders are connected, and how many are left. Sending seats are what let you CONNECT a sender — a LinkedIn account, WhatsApp number, phone number, or email mailbox — and they are billed in dollars on their own subscription, separate from your plan and separate from credits. You do not need a plan to buy seats. Seats belong to the billing owner: a workspace linked to another organization's plan with `orgs billing-link` connects senders against that organization's seats and buys them there. A grandfathered value of null means unlimited for that channel. Email mailbox allocation is read from this workspace's own database; when it cannot be read, the email seat's allocated/available/can_connect read null and `email_sender_allocation` is `tenant_unavailable`. When seats are retired because connected accounts reserve credits instead, the envelope says `status: seats_retired` with a `next_step`; seats already held keep covering their accounts until they end at renewal (`held_seats_end_at`), and from then on each of those accounts reserves credits instead, never both (`after_seats_end` on each channel gives the monthly reservation). Read-only, 0 Oxygen credits.")
11495
+ .description("Show sending seats, current billing-period end and next-connection eligibility (null: unknown, with a reason). Includes purchased, grandfathered (null: unlimited), connected and left per channel. Seats belong to the billing owner. Email mailbox allocation is workspace-scoped; unreadable mailbox data leaves allocated/available/can_connect null. When retired, held_seats_end_at_reason and held_seats_end_at_message distinguish no paying subscription from an unknown end date; after_seats_end gives monthly credit prices (null: unconfirmed). connect_eligibility explains a single new connection's credit affordability, sponsorship and attachment capacity; paying-seat coverage may remain unknown. Existing accounts and reconnects differ. Charges 0 credits and creates no reservation; like billing balance, it may apply a due plan grant or already-due sponsored-credit expiry.")
11387
11496
  .option("--json", "Print a JSON envelope.")
11388
11497
  .action(async (options) => {
11389
11498
  await handleAsyncAction("billing seats", options, () => requestOxygen("/api/cli/billing/seats"));
11390
11499
  })
11391
11500
  .addCommand(new Command("set")
11392
- .description("Set how many sending seats of one channel this workspace holds. The quantity is ABSOLUTE, not a delta: `--quantity 3` means you end up with three, so a retried command cannot buy extra. Increasing charges the card pro rata immediately; decreasing credits you pro rata. Reducing below the number of senders you have connected is REFUSED and names how many to disconnect first, because an orphaned sender is one you keep paying a provider for while Oxygen has stopped honouring it. An email_sender reduction is refused with `seat_allocation_unavailable` (503) while the workspace's mailbox count cannot be read; retry it in a moment. Once seats are retired, any increase is refused with `seats_retired` and lowering held seats still works. Charges real money.")
11501
+ .description("Manage legacy dollar sending seats for one channel; check `billing seats` for retirement status. The quantity is ABSOLUTE, not a delta: `--quantity 3` means you end up with three, so a retried command cannot buy extra. Increasing charges the card pro rata immediately; decreasing credits you pro rata. Reducing below the number of senders you have connected is REFUSED and names how many to disconnect first, because an orphaned sender is one you keep paying a provider for while Oxygen has stopped honouring it. An email_sender reduction is refused with `seat_allocation_unavailable` (503) while the workspace's mailbox count cannot be read; retry it in a moment. Once seats are retired, any increase is refused with `seats_retired` and lowering held seats still works. Charges real money.")
11393
11502
  .requiredOption("--seat-key <kind>", "linkedin, whatsapp, phone_number, or email_sender.")
11394
11503
  .requiredOption("--quantity <n>", "Total seats to hold for this channel, 0 or more.")
11395
11504
  .option("--json", "Print a JSON envelope.")
@@ -11409,14 +11518,14 @@ Examples:
11409
11518
  await handleAsyncAction("billing invoices", options, () => requestOxygen("/api/cli/billing/invoices"));
11410
11519
  }))
11411
11520
  .addCommand(new Command("allowance")
11412
- .description("Show this billing cycle's credit pool as one breakdown: fixed spend, flexible spend, credits committed to the next renewal, and what is free to spend — the numbers behind the bar on the credit-usage page. The parts always sum to the total, with ONE deliberate exception: `renewals_past_due` (and its region=past_due segment) reports renewals that FAILED to charge, which is money not taken and therefore outside total_credits. fixed_spent_credits is what has ALREADY been charged this cycle, NOT your monthly run-rate — for the forward figure billed at the next renewal see `billing commitments`, which is normally much larger. For organization wallet scope, reserved_credits is in-flight spend already removed from the available balance: do not subtract it again from free_to_spend_credits. Reserved credits can exceed the remaining free balance without implying an overdraft. For workspace_cap scope, free_to_spend_credits is remaining allowance, not the billing owner's wallet; live execution still checks that wallet and spend policies. Every segment carries region=fixed|flexible. Read-only, 0 Oxygen credits.")
11521
+ .description("Show this billing cycle's credit pool as one breakdown: fixed spend, flexible spend, credits committed to the next renewal, and what is free to spend — the numbers behind the bar on the credit-usage page. The parts always sum to the total, with ONE deliberate exception: `renewals_past_due` (and its region=past_due segment) reports renewals that FAILED to charge, which is money not taken and therefore outside total_credits. fixed_spent_credits is what has ALREADY been charged this cycle, NOT your monthly run-rate — for the forward figure billed at the next renewal see `billing commitments`, which is normally much larger. For organization wallet scope, reserved_credits is in-flight spend already removed from the available balance: do not subtract it again from free_to_spend_credits. Reserved credits can exceed the remaining free balance without implying an overdraft. For workspace_cap scope, free_to_spend_credits is remaining allowance, not the billing owner's wallet; live execution still checks that wallet and spend policies. Every segment carries region=fixed|flexible. 0 credits; reserves nothing. Due plan grants or credit expiry may apply.")
11413
11522
  .option("--json", "Print a JSON envelope.")
11414
11523
  .action(async (options) => {
11415
11524
  await handleAsyncAction("billing allowance", options, () => requestOxygen("/api/cli/billing/allowance"));
11416
11525
  }))
11417
11526
  .addCommand(new Command("usage")
11418
- .description("Show credit ledger events (each tagged with its FIXED/FLEXIBLE spend_class), workflow step usage (automation_actions: credits per step, credits billed; writing rows is free), calls with your own API keys (byok_calls: the platform fee per call and the fee credits this month; your provider bills the call itself), or connected LinkedIn and WhatsApp accounts (provider_seats: the rolling-30d peak and its implied Unipile cost; what each account is charged is its credit reservation in `billing commitments`). On a plan that pays for several workspaces (an agency covering client workspaces), `--group-by workspace` totals the credits each workspace spent (`by_workspace`) and `--workspace <id|slug>` narrows the ledger to one of them; a linked client workspace sees only its own spend.")
11419
- .option("--meter <meter>", "credits, automation_actions, byok_calls, or provider_seats (rolling-30d peak connected accounts and the flat per-account charge; each account's reservation is listed by `billing commitments`). Defaults to credits.")
11527
+ .description("Show credit ledger events (each tagged with its FIXED/FLEXIBLE spend_class), workflow step usage (automation_actions: credits per step, credits billed; writing rows is free), calls with your own API keys (byok_calls: the platform fee per call and the fee credits this month; your provider bills the call itself), or connected LinkedIn and WhatsApp accounts (provider_seats: the rolling-30d peak and its implied connectivity cost; what each account is charged is its credit reservation in `billing commitments`). On a plan that pays for several workspaces (an agency covering client workspaces), `--group-by workspace` totals the credits each workspace spent (`by_workspace`) and `--workspace <id|slug>` narrows the ledger to one of them; a linked client workspace sees only its own spend. managed_credits_used counts captured credits; for where credits went by category, provider or tool, with shares, run `billing audit`.")
11528
+ .option("--meter <meter>", "credits, automation_actions, byok_calls, provider_seats (rolling-30d peak connected accounts and the flat per-account charge; each account's reservation is listed by `billing commitments`), or holds (each held reservation behind reserved_credits in this workspace: credits, run, why it is held (an unmarked LinkedIn lookup hold cannot say whether its result was unknown or delivered), and automatic_return: only a LinkedIn lookup hold from 24 hours to 30 days old is eligible, only while that is switched on, and an hourly release returns it on a later pass with no fixed deadline; read-only, newest first, --days up to 365 and --limit apply). Defaults to credits.")
11420
11529
  .option("--days <n>", "Lookback window in days. Defaults to 30.")
11421
11530
  .option("--from <iso>", "Only include ledger events at or after this ISO timestamp.")
11422
11531
  .option("--to <iso>", "Only include ledger events at or before this ISO timestamp.")
@@ -11444,7 +11553,7 @@ Examples:
11444
11553
  });
11445
11554
  }))
11446
11555
  .addCommand(new Command("audit")
11447
- .description("Summarize where managed credits were granted, reserved, captured, released, or spent. Groups carrying a category are tagged with a FIXED/FLEXIBLE spend_class, and totals include fixed_credits_spent/flexible_credits_spent — pass --group-by category (or include it alongside other keys) for that split to be meaningful.")
11556
+ .description("Summarize where managed credits were granted, reserved, captured, released, or spent. Every result lists spend_by_category (each spend area's credits and share of the window's spend, largest first) and totals fixed_credits_spent/flexible_credits_spent over the whole window, whatever --group-by is. groups_truncated says when --limit cut grouped rows, and managed_credits_spent_outside_groups how much they carried. Spent is reserved minus released in the window, so it differs from totals.managed_credits_captured (`billing usage`'s managed_credits_used) by holds still open (reserved_credits) and holds settling across the window edge. byok_usage_events counts only nonzero rows unless --type byok. 0 credits; reserves nothing. Due plan grants or credit expiry may apply.")
11448
11557
  .option("--days <n>", "Lookback window in days. Defaults to 365.")
11449
11558
  .option("--from <iso>", "Only include ledger events at or after this ISO timestamp.")
11450
11559
  .option("--to <iso>", "Only include ledger events at or before this ISO timestamp.")
@@ -11816,8 +11925,8 @@ Examples:
11816
11925
  .command("admin")
11817
11926
  .description("Staff-only commands.")
11818
11927
  .addCommand(new Command("kpis")
11819
- .description("Show company acquisition with self-reported signup-channel attribution, signup-to-CLI/MCP activation, matured seven-day trial conversion, positive-paid MRR/ARR, and paid churn separated from trial loss. OXYGEN staff only; workspace admin role alone does not grant access.")
11820
- .addOption(new Option("--range <range>", "Range for acquisition, actor-and-surface-tracked activation, seven-day trial cohorts, and churn; current MRR/ARR and scheduled cancellations remain point-in-time.")
11928
+ .description("Show the company conversion funnel (visitors → signups → agent connected → CLI/MCP activation → paid, with step rates), self-reported signup-channel attribution, new and positive-paid MRR/ARR, and paid churn separated from trial loss. OXYGEN staff only; workspace admin role alone does not grant access.")
11929
+ .addOption(new Option("--range <range>", "Range for acquisition, the funnel's signup cohort, new revenue, and churn; current MRR/ARR and scheduled cancellations remain point-in-time.")
11821
11930
  .choices(["7d", "30d", "90d"])
11822
11931
  .default("30d"))
11823
11932
  .option("--json", "Print a JSON envelope.")
@@ -11853,6 +11962,49 @@ Examples:
11853
11962
  return requestOxygen(`/api/cli/admin/costs${suffix}`);
11854
11963
  });
11855
11964
  }))
11965
+ .addCommand(new Command("price-book")
11966
+ .description("The runtime prices charging reads from the control DB: recurring charges, plan allowances and scalars, each with its committed default and whether staff may edit it. An edit reaches every instance within 60 seconds. Staff only.")
11967
+ .addCommand(new Command("list")
11968
+ .description("List every pricing-book row with its stored value, what charging bills now, the committed default, and who reads it. Read-only.")
11969
+ .option("--json", "Print a JSON envelope.")
11970
+ .action(async (options) => {
11971
+ await handleAsyncAction("admin price-book list", options, () => requestOxygen("/api/cli/admin/costs?section=pricing_book"));
11972
+ }))
11973
+ .addCommand(new Command("set")
11974
+ .description("Set one editable pricing-book field. A charge takes --field credits or active; a plan takes monthly_credits, weekly_credits_limit or rollover_cap (value none for no limit); a scalar takes value. Rows charging does not read from the book are refused.")
11975
+ .requiredOption("--table <table>", "charge, plan, or scalar.")
11976
+ .requiredOption("--key <key>", "The row key, e.g. addon.egress_dedicated_ip, oxygen_499, markup.copilot_inference.")
11977
+ .requiredOption("--field <field>", "credits | active (charge); monthly_credits | weekly_credits_limit | rollover_cap (plan); value (scalar).")
11978
+ .requiredOption("--value <value>", "A non-negative number, true/false for active, or none for no plan limit.")
11979
+ .option("--json", "Print a JSON envelope.")
11980
+ .action(async (options) => {
11981
+ await handleAsyncAction("admin price-book set", options, () => requestOxygen("/api/cli/admin/costs", {
11982
+ method: "POST",
11983
+ body: {
11984
+ price_table: options.table,
11985
+ price_key: options.key,
11986
+ price_field: options.field,
11987
+ value: readPriceBookValue(options.value),
11988
+ },
11989
+ }));
11990
+ }))
11991
+ .addCommand(new Command("restore")
11992
+ .description("Put a pricing-book row back to its committed default. For a plan, --field restores one allowance field; without it all three are restored.")
11993
+ .requiredOption("--table <table>", "charge, plan, or scalar.")
11994
+ .requiredOption("--key <key>", "The row key.")
11995
+ .option("--field <field>", "Plan only: monthly_credits, weekly_credits_limit or rollover_cap.")
11996
+ .option("--json", "Print a JSON envelope.")
11997
+ .action(async (options) => {
11998
+ await handleAsyncAction("admin price-book restore", options, () => requestOxygen("/api/cli/admin/costs", {
11999
+ method: "POST",
12000
+ body: {
12001
+ price_table: options.table,
12002
+ price_key: options.key,
12003
+ ...(options.field ? { price_field: options.field } : {}),
12004
+ restore: true,
12005
+ },
12006
+ }));
12007
+ })))
11856
12008
  .addCommand(new Command("primary-providers")
11857
12009
  .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.")
11858
12010
  .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.")
@@ -12478,7 +12630,7 @@ Examples:
12478
12630
  .description("Start an attended Workspace Copilot session on Oxygen Auto — Oxygen picks the model, keeps one conversation on one model, and runs its own errands on a cheaper one. Starting is free; the next send starts inference. No inference budget is required; inference bills at 5x actual model cost under a 3,000-credit internal platform safety backstop. Legacy budget and tier flags remain accepted for older clients but do not authorize or cap spend. Paid or external actions require separate approval.")
12479
12631
  .option("--budget-credits <number>", "Legacy compatibility only: accepted and clamped, but does not authorize or cap attended Copilot inference.")
12480
12632
  .option("--tier <auto|low|medium|high>", "Legacy. Sessions run on Oxygen Auto; Oxygen picks the model. The three older tiers are still accepted so existing scripts keep working.")
12481
- .option("--model <id>", "Pin a specific priced model id instead of letting Oxygen choose.")
12633
+ .option("--model <id>", "Pin a specific priced model id instead of letting Oxygen choose. An id Oxygen does not price is ignored and the session runs on Oxygen Auto.")
12482
12634
  .option("--per-turn-ceiling <number>", "Legacy compatibility only: accepted and clamped, but does not cap an attended turn.")
12483
12635
  .option("--journey <slug>", "Optional journey slug to seed the session's goal and context.")
12484
12636
  .option("--title <text>", "Optional human title for the session.")
@@ -12669,8 +12821,8 @@ Examples:
12669
12821
  .command("runs")
12670
12822
  .description("Workspace run ledger and provenance. `runs list --table <table>` is one table's full run history: imports, row writes and column runs.")
12671
12823
  .addCommand(new Command("list")
12672
- .description("List recent tenant runs, newest first.")
12673
- .option("--table <table>", "Only runs of this table (id, slug or table URL), e.g. to check whether a column run started.")
12824
+ .description("List workspace runs, newest first; --table gives one table's full run history.")
12825
+ .option("--table <table>", "One table's full run history (id, slug or table URL): imports, row writes, column runs and Function calls.")
12674
12826
  .option("--limit <n>", "Maximum runs to return. Defaults to 50.")
12675
12827
  .option("--json", "Print a JSON envelope.")
12676
12828
  .action(async (options) => {
@@ -13065,7 +13217,7 @@ Examples:
13065
13217
  .option("--return <mode>", "Legacy response shape: raw, compact, or summary. Defaults to raw.")
13066
13218
  .option("--return-mode <mode>", "Response shape: raw, compact, or summary. Prefer summary for large search responses.")
13067
13219
  .option("--oxygen-cursor <cursor>", "Short Oxygen cursor returned as oxygen_next_cursor by a previous tool run.")
13068
- .option("--max-credits <n>", "Credit ceiling for live paid tools; not needed for no-bill tools, and 0 is accepted for no-bill operations.")
13220
+ .option("--max-credits <n>", "Credit ceiling for live paid tools; not needed for no-bill tools. 0 runs no-bill operations, which may call providers, and reuses a managed lookup's cached result for the exact same input for free (the dry run's preview.cache says whether one exists). A paid cache miss at 0 calls and charges nothing.")
13069
13221
  .option("--approved", "Required for live paid tools and external writes after inspecting dry-run output.")
13070
13222
  .option("--json", "Print a JSON envelope.")
13071
13223
  .action(async (toolId, options) => {
@@ -13198,7 +13350,7 @@ Examples:
13198
13350
  .option("--company-domain <domain>", "Company apex domain, e.g. acme.com.")
13199
13351
  .option("--company-name <name>", "Company name.")
13200
13352
  .option("--mode <mode>", "dry_run (default) previews the plan and spends nothing; live spends credits and returns the answer.")
13201
- .option("--max-credits <credits>", "Spend ceiling. Required for --mode live. Run the free dry_run first: it returns estimate.recommended_max_credits, the ceiling that lets the WHOLE resolved waterfall run, plus estimate.min_credits and a per-leg breakdown. When the resolved chain runs the guessed-address pass (the name+domain and first/last+domain profiles, not the LinkedIn-URL one) the block also carries estimate.pattern_guess_verification_credits — the verification checks that pass bills before any named provider is dialled. A lower ceiling does NOT stop the run: each lane priced above what is left of it is refused on its own (spend_cap_too_low) and the waterfall advances, so a cheaper lane further down the chain can still run and bill.")
13353
+ .option("--max-credits <credits>", "Spend ceiling in Oxygen credits; a lane that runs on your own provider key bills your provider account and is outside it (cap that with `oxygen limits provider set`). Required for --mode live. Run the free dry_run first: it returns estimate.recommended_max_credits, the ceiling that lets the WHOLE resolved waterfall run, plus estimate.min_credits and a per-leg breakdown. When the resolved chain runs the guessed-address pass (the name+domain and first/last+domain profiles, not the LinkedIn-URL one) the block also carries estimate.pattern_guess_verification_credits — the verification checks that pass bills before any named provider is dialled. A lower ceiling does NOT stop the run: each lane priced above what is left of it is refused on its own (spend_cap_too_low) and the waterfall advances, so a cheaper lane further down the chain can still run and bill.")
13202
13354
  .option("--json", "Print a JSON envelope.")
13203
13355
  .action(async (options) => {
13204
13356
  await handleAsyncAction("find email", options, () => requestOxygen("/api/cli/find/run", { method: "POST", body: buildFindBody("email", options) }));
@@ -13211,7 +13363,7 @@ Examples:
13211
13363
  .option("--company-domain <domain>", "Company apex domain, e.g. acme.com.")
13212
13364
  .option("--company-name <name>", "Company name.")
13213
13365
  .option("--mode <mode>", "dry_run (default) previews the plan and spends nothing; live spends credits and returns the answer.")
13214
- .option("--max-credits <credits>", "Spend ceiling. Required for --mode live. Run the free dry_run first: it returns estimate.recommended_max_credits, the ceiling that lets the WHOLE resolved waterfall run, plus estimate.min_credits and a per-leg breakdown. A lower ceiling does NOT stop the run: each lane priced above what is left of it is refused on its own (spend_cap_too_low) and the waterfall advances, so a cheaper lane further down the chain can still run and bill.")
13366
+ .option("--max-credits <credits>", "Spend ceiling in Oxygen credits; a lane that runs on your own provider key bills your provider account and is outside it (cap that with `oxygen limits provider set`). Required for --mode live. Run the free dry_run first: it returns estimate.recommended_max_credits, the ceiling that lets the WHOLE resolved waterfall run, plus estimate.min_credits and a per-leg breakdown. A lower ceiling does NOT stop the run: each lane priced above what is left of it is refused on its own (spend_cap_too_low) and the waterfall advances, so a cheaper lane further down the chain can still run and bill.")
13215
13367
  .option("--json", "Print a JSON envelope.")
13216
13368
  .action(async (options) => {
13217
13369
  await handleAsyncAction("find personal-email", options, () => requestOxygen("/api/cli/find/run", { method: "POST", body: buildFindBody("personal_email", options) }));
@@ -13225,7 +13377,7 @@ Examples:
13225
13377
  .option("--company-name <name>", "Company name.")
13226
13378
  .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).")
13227
13379
  .option("--mode <mode>", "dry_run (default) previews the plan and spends nothing; live spends credits and returns the answer.")
13228
- .option("--max-credits <credits>", "Spend ceiling. Required for --mode live. Run the free dry_run first: it returns estimate.recommended_max_credits, the ceiling that lets the WHOLE resolved waterfall run, plus estimate.min_credits and a per-leg breakdown. A lower ceiling does NOT stop the run: each lane priced above what is left of it is refused on its own (spend_cap_too_low) and the waterfall advances, so a cheaper lane further down the chain can still run and bill.")
13380
+ .option("--max-credits <credits>", "Spend ceiling in Oxygen credits; a lane that runs on your own provider key bills your provider account and is outside it (cap that with `oxygen limits provider set`). Required for --mode live. Run the free dry_run first: it returns estimate.recommended_max_credits, the ceiling that lets the WHOLE resolved waterfall run, plus estimate.min_credits and a per-leg breakdown. A lower ceiling does NOT stop the run: each lane priced above what is left of it is refused on its own (spend_cap_too_low) and the waterfall advances, so a cheaper lane further down the chain can still run and bill.")
13229
13381
  .option("--json", "Print a JSON envelope.")
13230
13382
  .action(async (options) => {
13231
13383
  await handleAsyncAction("find phone", options, () => requestOxygen("/api/cli/find/run", { method: "POST", body: buildFindBody("phone", options) }));
@@ -13238,15 +13390,15 @@ Examples:
13238
13390
  .option("--company-name <name>", "Company name.")
13239
13391
  .option("--company-linkedin-url <url>", "Company LinkedIn URL.")
13240
13392
  .option("--mode <mode>", "dry_run (default) previews the plan and spends nothing; live spends credits and returns the answer.")
13241
- .option("--max-credits <credits>", "Spend ceiling. Required for --mode live. Run the free dry_run first: it returns estimate.recommended_max_credits, the ceiling that lets the WHOLE resolved waterfall run, plus estimate.min_credits and a per-leg breakdown. A lower ceiling does NOT stop the run: each lane priced above what is left of it is refused on its own (spend_cap_too_low) and the waterfall advances, so a cheaper lane further down the chain can still run and bill.")
13393
+ .option("--max-credits <credits>", "Spend ceiling in Oxygen credits; a lane that runs on your own provider key bills your provider account and is outside it (cap that with `oxygen limits provider set`). Required for --mode live. Run the free dry_run first: it returns estimate.recommended_max_credits, the ceiling that lets the WHOLE resolved waterfall run, plus estimate.min_credits and a per-leg breakdown. A lower ceiling does NOT stop the run: each lane priced above what is left of it is refused on its own (spend_cap_too_low) and the waterfall advances, so a cheaper lane further down the chain can still run and bill.")
13242
13394
  .option("--json", "Print a JSON envelope.")
13243
13395
  .action(async (options) => {
13244
13396
  await handleAsyncAction("find linkedin", options, () => requestOxygen("/api/cli/find/run", { method: "POST", body: buildFindBody("linkedin", options) }));
13245
13397
  }))
13246
13398
  .addCommand(new Command("person")
13247
- .description("Enrich one person from a LinkedIn URL, a LinkedIn public id, a Sales Navigator link, or an email / name + company: one managed profile lookup returns the profile block — first, last and full name, current company and its LinkedIn URL, job title, job start date, education and school, headline, bio, location and follower count — the same fields the person_enrich table preset extracts for free. An email or name is first resolved to the profile through the LinkedIn URL waterfall (priced as an identity prerequisite). A Sales Navigator link or member id resolves only on the workspace's own HarvestAPI key. dry_run previews the lookup and its price for free.")
13399
+ .description("Enrich one person from a LinkedIn URL, a LinkedIn public id or member id, a Sales Navigator link, or an email / name + company: one managed profile lookup returns the profile block — first, last and full name, current company and its LinkedIn URL, job title, job start date, education and school, headline, bio, location and follower count — the same fields the person_enrich table preset extracts for free. An email or name is first resolved to the profile through the LinkedIn URL waterfall (priced as an identity prerequisite). A member id uses managed enrichment. A Sales Navigator link resolves on the workspace's own HarvestAPI key. dry_run previews the lookup and its price for free.")
13248
13400
  .option("--linkedin-url <url>", "Person LinkedIn profile URL (linkedin.com/in/<handle>).")
13249
- .option("--linkedin-id <id>", "LinkedIn public identifier (the part after /in/), or an ACoAA/ACwAA member id (BYOK HarvestAPI only).")
13401
+ .option("--linkedin-id <id>", "LinkedIn public identifier (the part after /in/), or a member ID beginning ACo/ACw; both support managed enrichment. Preview checks input shape and price without resolving the ID.")
13250
13402
  .option("--sales-navigator-url <url>", "Sales Navigator lead or people link; resolved on the workspace's own HarvestAPI key.")
13251
13403
  .option("--email <email>", "Known email; resolved to the profile through the LinkedIn URL waterfall first.")
13252
13404
  .option("--full-name <name>", "Person full name; with --company-domain or --company-name, resolved through the LinkedIn URL waterfall first.")
@@ -13256,7 +13408,7 @@ Examples:
13256
13408
  .option("--company-name <name>", "Company name.")
13257
13409
  .option("--include-raw", "Also return the provider's raw profile payload (profile_raw) for fields the profile block does not carry.")
13258
13410
  .option("--mode <mode>", "dry_run (default) previews the plan and spends nothing; live spends credits and returns the profile.")
13259
- .option("--max-credits <credits>", "Spend ceiling. Required for --mode live. Run the free dry_run first: estimate.recommended_max_credits funds the profile lookup plus, for an email or name input, every lane of the LinkedIn URL waterfall that resolves it.")
13411
+ .option("--max-credits <credits>", "Spend ceiling in Oxygen credits; a lane that runs on your own provider key bills your provider account and is outside it (cap that with `oxygen limits provider set`). Required for --mode live. Run the free dry_run first: estimate.recommended_max_credits funds the profile lookup plus, for an email or name input, every lane of the LinkedIn URL waterfall that resolves it.")
13260
13412
  .option("--json", "Print a JSON envelope.")
13261
13413
  .action(async (options) => {
13262
13414
  await handleAsyncAction("find person", options, () => requestOxygen("/api/cli/find/run", { method: "POST", body: buildFindBody("person", options) }));
@@ -13270,7 +13422,7 @@ Examples:
13270
13422
  .option("--list-fields", "Print the field catalog (category, description, provider count, per-row credits: free / fixed / ~estimated) and exit. Needs no identity; spends nothing.")
13271
13423
  .option("--check-technologies <names>", "Comma-separated technology names for the technology_check field, answered yes/no against the detected stack (e.g. hubspot,salesforce).")
13272
13424
  .option("--mode <mode>", "dry_run (default) previews the plan and spends nothing; live spends credits and returns the answer.")
13273
- .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). Run the free dry_run first: it returns estimate.recommended_max_credits, the ceiling that funds every lane in the plan, and estimate.min_credits, what the plan costs if each field is answered by its first lane.")
13425
+ .option("--max-credits <credits>", "Spend ceiling in Oxygen credits; a lane that runs on your own provider key bills your provider account and is outside it (cap that with `oxygen limits provider set`). Required for --mode live; 0 can still call providers on zero-credit lanes and reuse any priced lane this workspace already has a cached answer for (the other priced lanes are skipped as credit_ceiling_reached). Run the free dry_run first: it returns estimate.recommended_max_credits, the ceiling that funds every lane in the plan, and estimate.min_credits, what the plan costs if each field is answered by its first lane.")
13274
13426
  .option("--json", "Print a JSON envelope.")
13275
13427
  .action(async (options) => {
13276
13428
  await handleAsyncAction("find company", options, () => requestOxygen("/api/cli/find/run", { method: "POST", body: buildFindBody("company", options) }));
@@ -13537,17 +13689,21 @@ Examples:
13537
13689
  });
13538
13690
  })))
13539
13691
  .addCommand(new Command("list")
13540
- .description("List supported integrations and this org's connections.")
13692
+ .description("List supported integrations and this org's connections. Add --integration <id> to read one integration and its connection state.")
13693
+ .option("--integration <id>", "Return only this integration and its connections, such as hubspot.")
13541
13694
  .option("--json", "Print a JSON envelope.")
13542
13695
  .action(async (options) => {
13543
- await handleAsyncAction("integrations list", options, () => requestOxygen("/api/cli/integrations/composio/list"));
13696
+ await handleAsyncAction("integrations list", options, () => {
13697
+ const integration = readOption(options.integration)?.trim();
13698
+ return requestOxygen(`/api/cli/integrations/composio/list${integration ? `?integration=${encodeURIComponent(integration)}` : ""}`);
13699
+ });
13544
13700
  }))
13545
13701
  .addCommand(addContractFlags(new Command("connect")
13546
- .description("Connect an integration. For OAuth toolkits, running this command immediately creates a pending provider authorization and returns a redirect URL; account access starts only if you approve on the provider's page. This is not a price preview. On a repriced workspace, a connected X account reserves 1,000 credits ($10) monthly; inspect the current pricing schedule with `oxygen limits show --json` before connecting. API-key integrations accept --api-key. Run without credentials only for an API-key integration to get a 0-credit preview of its exact fields (mode: needs_api_key): no credential is submitted, nothing is stored, and no provider call is made. Use repeatable --credential name=value when a provider also requires a host, subdomain, or other named value. To keep a secret out of shell history and process arguments, open the preview's web_url and submit it in Connections; --api-key is for controlled noninteractive use. Credential submission creates or replaces the connection immediately and needs no separate --approved flag. LinkedIn and WhatsApp return a Unipile hosted-auth URL instead (`oxygen senders connect` / `oxygen whatsapp connect` are the canonical paths).")
13702
+ .description("Connect an integration. For OAuth toolkits, running this command immediately creates a pending provider authorization and returns a redirect URL; account access starts only if you approve on the provider's page. This is not a price preview. On a repriced workspace, a connected X account reserves 1,000 credits ($10) monthly; inspect current connection prices and first billing timing with `oxygen billing commitments --view connection-prices --json` before connecting. API-key integrations accept --api-key. Run without credentials only for an API-key integration to get a 0-credit preview of its exact fields (mode: needs_api_key): no credential is submitted, nothing is stored, and no provider call is made. Use repeatable --credential name=value when a provider also requires a host, subdomain, or other named value. To keep a secret out of shell history and process arguments, open the preview's web_url and submit it in Connections; --api-key is for controlled noninteractive use. Credential submission creates or replaces the connection immediately and needs no separate --approved flag. LinkedIn and WhatsApp return a hosted sign-in link instead (`oxygen senders connect` / `oxygen whatsapp connect` are the canonical paths).")
13547
13703
  .argument("<integration_id>", "Integration id, such as 'slack' or 'serpapi'.")
13548
13704
  .option("--api-key <value>", "API key for controlled noninteractive use. Literal values can appear in shell history and process arguments; prefer the preview web_url for manual entry.")
13549
13705
  .option("--credential <name=value>", "Named provider credential field. Repeat for multi-field integrations (for example, PostHog: --credential subdomain=us).", collectRepeatable, [])
13550
- .option("--country <code>", "ISO 3166-1 alpha-2 country where the LinkedIn account owner normally signs in (required for `connect linkedin`; picks the egress proxy).")
13706
+ .option("--country <code>", "ISO 3166-1 alpha-2 country where the LinkedIn account owner normally signs in (required for `connect linkedin`; picks the egress proxy from the 56 countries OXYGEN has LinkedIn proxies in).")
13551
13707
  .option("--pairing-phone-number <e164>", "WhatsApp only: pair by code sent to this E.164 number instead of showing a QR.")
13552
13708
  .addOption(new Option("--acknowledge-risk", "Deprecated: LinkedIn connect no longer needs it. Still accepted so older scripts keep running.").hideHelp())
13553
13709
  .option("--return-to <path>", "In-product destination after OAuth approval.")
@@ -13594,7 +13750,7 @@ Examples:
13594
13750
  }));
13595
13751
  }))
13596
13752
  .addCommand(new Command("disconnect")
13597
- .description("Disconnect an integration. LinkedIn and WhatsApp remove the Unipile connected account; pass --connection-id when more than one is connected.")
13753
+ .description("Disconnect an integration. LinkedIn and WhatsApp remove the connected account; pass --connection-id when more than one is connected.")
13598
13754
  .argument("<integration_id>", "Integration id, such as 'slack'.")
13599
13755
  .option("--connection-id <id>", "Disconnect a specific connected account when multiple exist.")
13600
13756
  .option("--json", "Print a JSON envelope.")
@@ -13705,12 +13861,12 @@ Examples:
13705
13861
  await handleAsyncAction("senders checkpoints", options, () => requestOxygen("/api/cli/senders/checkpoints"));
13706
13862
  }))
13707
13863
  .addCommand(new Command("connect")
13708
- .description("Get a Unipile hosted-auth URL to connect a new LinkedIn account (or reconnect with --reconnect). This command creates a pending provider authorization; it does not preview a price. Each connected LinkedIn account has one monthly charge, never two: an existing dollar seat until it ends at renewal, or a monthly credit reservation after seats retire. Once the workspace is repriced that reservation is 3,400 credits ($34) monthly; a connect the balance cannot cover fails with insufficient_credits. Invites, messages and other LinkedIn actions cost 0 credits on top. `oxygen billing seats --json` reports the charge that applies to this workspace now, and `oxygen billing estimate --linkedin-accounts <n> --json` prices n accounts against your plan. A workspace linked to another organization's plan is billed at that organization. New accounts require --country (the owner's normal LinkedIn login country) and default to syncing only conversations OXYGEN starts; use --inbox-scope all to opt into the full LinkedIn inbox. Use --cookie-auth and --custom-proxy to expose those inputs inside Unipile's hosted wizard; their secrets never pass through OXYGEN. Use --count for bulk onboarding; --count above 1 needs --owner-consent (each owner connects their own account). Links are shareable and valid for 10 minutes.")
13864
+ .description("Get a hosted sign-in link to connect a new LinkedIn account (or reconnect with --reconnect). This command creates a pending provider authorization. Inspect prices first with `oxygen billing commitments --view connection-prices --json`, which creates no authorization. A covering dollar seat and a credit reservation are alternative billing rails. Read the seat coverage and actual commitment before relying on the next monthly billing date, especially on reconnect. Once the workspace is repriced that reservation is 3,400 credits ($34) monthly; with the credit gate active, a connect the balance cannot cover fails with insufficient_credits. Invites, messages and other LinkedIn actions cost 0 credits on top. `oxygen billing seats --json` shows held seats and next-connection eligibility; paying-seat coverage may remain unknown. Inspect `oxygen billing commitments --json` for an account’s actual reservation, and `oxygen billing estimate --linkedin-accounts <n> --json` prices n accounts against your plan. A workspace linked to another organization's plan is billed at that organization. New accounts require --country (the owner's normal LinkedIn login country) and default to syncing only conversations OXYGEN starts; use --inbox-scope all to opt into the full LinkedIn inbox. Use --cookie-auth and --custom-proxy to expose those inputs in the hosted sign-in wizard; their secrets never pass through OXYGEN. Use --count for bulk onboarding; --count above 1 needs --owner-consent (each owner connects their own account). Links are shareable and valid for 10 minutes.")
13709
13865
  .option("--reconnect <connection_id>", "Reconnect an existing connection instead of creating a new one. Accepts a connection id.")
13710
- .option("--country <code>", "Required for new accounts: ISO 3166-1 alpha-2 code for the account owner's normal LinkedIn login country (for example DE or US).")
13711
- .option("--sales-nav", "Request Classic + Sales Navigator access during Unipile hosted authentication.")
13712
- .option("--cookie-auth", "Offer LinkedIn cookie authentication in Unipile's hosted wizard. The account owner enters li_at/li_a there; OXYGEN never receives them.")
13713
- .option("--custom-proxy", "Let the account owner enter a custom proxy in Unipile's hosted wizard. OXYGEN never receives the proxy credentials.")
13866
+ .option("--country <code>", "Required for new accounts: ISO 3166-1 alpha-2 code for the account owner's normal LinkedIn login country (for example DE or US); one of the 56 countries OXYGEN has LinkedIn proxies in.")
13867
+ .option("--sales-nav", "Request Classic + Sales Navigator access during hosted sign-in.")
13868
+ .option("--cookie-auth", "Offer LinkedIn cookie authentication in the hosted sign-in wizard. The account owner enters li_at/li_a there; OXYGEN never receives them.")
13869
+ .option("--custom-proxy", "Let the account owner enter a custom proxy in the hosted sign-in wizard. OXYGEN never receives the proxy credentials.")
13714
13870
  .option("--count <n>", "Mint N hosted-auth links in one call for bulk onboarding (1-25, default 1). Every link uses the same --country; use separate calls for different login countries. Ignored when reconnecting.")
13715
13871
  .option("--share-link", "Return a shareable connection link for one account; its owner completes authentication.")
13716
13872
  .option("--inbox-scope <scope>", "LinkedIn inbox privacy for new accounts: oxygen_initiated (default) or all. Reconnect preserves the existing account setting.")
@@ -13762,6 +13918,17 @@ Examples:
13762
13918
  body: { scope: options.scope },
13763
13919
  }));
13764
13920
  })))
13921
+ .addCommand(new Command("proxy-country")
13922
+ .description("Move a LinkedIn sender's automatic proxy to another country without reconnecting. The account picks a proxy there at once when one is free; otherwise its sequences stay paused and each `senders sync` asks again. Zero credits.")
13923
+ .argument("<id>", "Sender account id, connection id, or Unipile account id.")
13924
+ .requiredOption("--country <code>", "ISO 3166-1 alpha-2 country where the account owner logs in; one of the 56 countries OXYGEN has LinkedIn proxies in (others are refused with the list).")
13925
+ .option("--json", "Print a JSON envelope.")
13926
+ .action(async (id, options) => {
13927
+ await handleAsyncAction("senders proxy-country", options, () => requestOxygen(`/api/cli/senders/${encodeURIComponent(id)}/proxy-country`, {
13928
+ method: "PATCH",
13929
+ body: { country: options.country.trim().toUpperCase() },
13930
+ }));
13931
+ }))
13765
13932
  .addCommand(new Command("get")
13766
13933
  .description("Get one LinkedIn sender account with status, limits, daily-reset timezone, and usage. <id> accepts a sender account id, connection id, or Unipile account id.")
13767
13934
  .argument("<id>", "Sender account id, connection id, or Unipile account id.")
@@ -13781,7 +13948,7 @@ Examples:
13781
13948
  }));
13782
13949
  }))
13783
13950
  .addCommand(new Command("health")
13784
- .description("Show a one-call health snapshot for a LinkedIn sender: status, the latest error reason, any open security checkpoint, today's usage, the warm-up ramp, and when the daily quota resets. Read-only — no provider call, no credits. <id> accepts a sender account id, connection id, or Unipile account id.")
13951
+ .description("Show a one-call health snapshot for a LinkedIn sender: status, the latest error reason, any open security checkpoint, today's usage, the warm-up ramp, when the daily quota resets, and inbox sync (last sync, latest failure, back-off). Read-only — no provider call, no credits. <id> accepts a sender account id, connection id, or Unipile account id.")
13785
13952
  .argument("<id>", "Sender account id, connection id, or Unipile account id.")
13786
13953
  .option("--json", "Print a JSON envelope.")
13787
13954
  .action(async (id, options) => {
@@ -13820,7 +13987,7 @@ Examples:
13820
13987
  });
13821
13988
  }))
13822
13989
  .addCommand(new Command("disconnect")
13823
- .description("Disconnect a LinkedIn sender account so it stops sending and frees its sending seat for another account. Works on an account that is already disconnected. <id> accepts a sender account id, connection id, or Unipile account id.")
13990
+ .description("Disconnect a LinkedIn sender account so it stops sending. Works on an account that is already disconnected. <id> accepts a sender account id, connection id, or Unipile account id.")
13824
13991
  .argument("<id>", "Sender account id, connection id, or Unipile account id.")
13825
13992
  .option("--json", "Print a JSON envelope.")
13826
13993
  .action(async (id, options) => {
@@ -14445,7 +14612,7 @@ Examples:
14445
14612
  program.addCommand(new Command("linkedin")
14446
14613
  .description("Put your own LinkedIn network in a table and keep it current. `linkedin network setup` captures everyone you are connected to and everyone who follows you into one self-refreshing 'LinkedIn Network' table (free, read-only on your account, paced so the account is never at risk); `linkedin network status` reports the pace and `linkedin network pause` switches it off. `linkedin invitations setup` does the same for open connection requests — received and sent — in one self-updating table. Also home to `linkedin intent` (engagement capture) and `linkedin ingestion status` (every LinkedIn read drip and each account's remaining read budget).")
14447
14614
  .addCommand(new Command("network")
14448
- .description("Capture everyone already in your LinkedIn network \u2014 the people you are connected to and the people who follow you \u2014 into one self-refreshing workspace table you can filter, enrich and enroll from. READ-ONLY on your LinkedIn account: it lists your own connections and followers and nothing else. It sends no messages, no connection requests, and does not visit anyone's profile. Runs as a slow background drip on its OWN per-account read budget, so it cannot slow down or starve anything else the account does: about 2,000 people a day while it first walks your network (40 reads of up to 50 people), then a light check a few times a day for new arrivals. Only inside each account's own 07:00-22:00 working hours, spaced minutes apart, never in a burst; a newly armed account eases in over its first two weeks. LinkedIn does not say how big a network is until the walk has been through it, so a large network takes days \u2014 `status` reports the rate, not a completion date it cannot know. Free \u2014 no Oxygen credits; it uses the LinkedIn seat you already pay for.")
14615
+ .description("Capture everyone already in your LinkedIn network \u2014 the people you are connected to and the people who follow you \u2014 into one self-refreshing workspace table you can filter, enrich and enroll from. READ-ONLY on your LinkedIn account: it lists your own connections and followers and nothing else. It sends no messages, no connection requests, and does not visit anyone's profile. Runs as a slow background drip on its OWN per-account read budget, so it cannot slow down or starve anything else the account does: about 2,000 people a day while it first walks your network (40 reads of up to 50 people), then a light check a few times a day for new arrivals. Only inside each account's own 07:00-22:00 working hours, spaced minutes apart, never in a burst; a newly armed account eases in over its first two weeks. LinkedIn does not say how big a network is until the walk has been through it, so a large network takes days \u2014 `status` reports the rate, not a completion date it cannot know. Free \u2014 no Oxygen credits for this read; it uses your connected LinkedIn account, whose seat or credit reservation has its own terms.")
14449
14616
  .addCommand(new Command("setup")
14450
14617
  .description("Arm (or re-arm) network capture. Creates or reuses one merged 'LinkedIn Network' table and starts a connections drip and a followers drip per account, both writing into that one table deduped on LinkedIn member id \u2014 somebody who is both shows as 'both' in the Relationship column. This is a STANDING permission: capture keeps running until you switch it off with `linkedin network pause`, and a LinkedIn account you connect later is armed automatically. A newly connected account eases into full read speed over its first two weeks. Nothing is sent and no credits are charged. The response names each account it armed (`senders[].display_name`) \u2014 check it is yours before leaving it running.")
14451
14618
  .option("--account <ref>", "Account to capture (sender id, connection id, or Unipile account id). Omit to capture every connected LinkedIn account.")
@@ -14654,7 +14821,7 @@ Examples:
14654
14821
  });
14655
14822
  }))));
14656
14823
  program.addCommand(new Command("whatsapp")
14657
- .description("Manage the org's connected WhatsApp accounts for Sequencer: list, connect, sync, inspect, disconnect, tune daily limits, and warm-send into an existing conversation. Cold outbound runs through a WhatsApp sequence (sequences create --channels whatsapp).")
14824
+ .description("Manage the org's connected WhatsApp accounts for Sequencer: accounts (list), connect, sync, inspect, disconnect, tune daily limits, and warm-send into an existing conversation. Cold outbound runs through a WhatsApp sequence (sequences create --channels whatsapp).")
14658
14825
  .addCommand(addContractFlags(new Command("accounts")
14659
14826
  .description("Read stored WhatsApp account health. Disconnected accounts are hidden by default; --include-disconnected includes retained accounts, --status selects one status. Presence is not send readiness. Use summary and exact account-id/value for long public metadata."), WHATSAPP_ACCOUNTS_READ_CONTRACT, WHATSAPP_ACCOUNT_FLAGS)
14660
14827
  .option("--json", "Print a JSON envelope.")
@@ -14665,7 +14832,7 @@ Examples:
14665
14832
  });
14666
14833
  }))
14667
14834
  .addCommand(new Command("connect")
14668
- .description("Get a Unipile hosted-auth URL to connect a new WhatsApp account by scanning a QR in WhatsApp → Linked Devices (or reconnect with --reconnect). Newly connected accounts start a mandatory warm-up ramp.")
14835
+ .description("Get a hosted sign-in link to connect a new WhatsApp account by scanning a QR in WhatsApp → Linked Devices (or reconnect with --reconnect). Running this command creates a pending provider authorization. Inspect the current monthly price and first billing timing with `oxygen billing commitments --view connection-prices --json` before connecting. Newly connected accounts start a mandatory warm-up ramp.")
14669
14836
  .option("--reconnect <connection_id>", "Reconnect an existing connection id instead of linking a new account.")
14670
14837
  .option("--pairing-phone-number <phone>", "Offer phone-number pairing instead of QR where supported; use your international number.")
14671
14838
  .option("--json", "Print a JSON envelope.")
@@ -14703,7 +14870,7 @@ Examples:
14703
14870
  });
14704
14871
  }))
14705
14872
  .addCommand(new Command("disconnect")
14706
- .description("Disconnect a WhatsApp account so it stops sending and frees its sending seat for another account. Works on an account that is already disconnected. <id> accepts an account id, connection id, or Unipile account id.")
14873
+ .description("Disconnect a WhatsApp account so it stops sending. Works on an account that is already disconnected. <id> accepts an account id, connection id, or Unipile account id.")
14707
14874
  .argument("<id>", "WhatsApp account id, connection id, or Unipile account id.")
14708
14875
  .option("--json", "Print a JSON envelope.")
14709
14876
  .action(async (id, options) => {
@@ -14718,14 +14885,9 @@ Examples:
14718
14885
  .action(async (id, options) => {
14719
14886
  await handleAsyncAction("whatsapp limits get", options, () => requestOxygen(`/api/cli/whatsapp/accounts/${encodeURIComponent(id)}/limits`));
14720
14887
  }))
14721
- .addCommand(new Command("set")
14888
+ .addCommand(addWhatsAppLimitFlags(new Command("set")
14722
14889
  .description("Adjust per-account WhatsApp daily limits and the daily-reset timezone. Values are clamped to safe maximums; the warm-up ceiling still applies to direct and Sequence traffic. <id> accepts an account id, connection id, or Unipile account id.")
14723
- .argument("<id>", "WhatsApp account id, connection id, or Unipile account id.")
14724
- .option("--messages-per-day <n>", "Daily WhatsApp messages cap across direct and Sequence traffic (subject to the warm-up ceiling).")
14725
- .option("--total-actions-per-day <n>", "Daily cap across all WhatsApp action types.")
14726
- .option("--messages-reads-per-day <n>", "Daily cap on chat and message-history reads.")
14727
- .option("--min-spacing-seconds <n>", "Minimum seconds between actions.")
14728
- .option("--spacing-jitter-seconds <n>", "Random jitter seconds added to action spacing.")
14890
+ .argument("<id>", "WhatsApp account id, connection id, or Unipile account id."))
14729
14891
  .option("--working-days <days>", "Allowed weekdays, e.g. mon-fri or 1,3,5.")
14730
14892
  .option("--working-hours-start <HH:MM>", "Local start of the allowed sending window.")
14731
14893
  .option("--working-hours-end <HH:MM>", "Local end of the allowed sending window.")
@@ -14900,7 +15062,8 @@ Examples:
14900
15062
  .option("--domain <domains>", "Email only: comma-separated counterpart domains to include.")
14901
15063
  .option("--exclude-domain <domains>", "Email only: comma-separated counterpart domains to exclude.")
14902
15064
  .option("--mailbox-id <ids>", "Email only: comma-separated mailbox ids.")
14903
- .option("--sender-profile <ids>", "Comma-separated sender profile ids (people, from `senders profiles list`): only conversations on their own inboxes and LinkedIn/WhatsApp accounts. Merged stream only; narrow channels with --channels.")
15065
+ .option("--sender-profile-id <ids>", "Comma-separated sender profile ids (people; `unibox metadata` and `senders profiles list` list them): only conversations on their own inboxes and LinkedIn/WhatsApp accounts. The same `sender_profile_id` key a saved view's --filters takes. Merged stream only; narrow channels with --channels.")
15066
+ .addOption(new Option("--sender-profile <ids>", "Alias of --sender-profile-id.").hideHelp())
14904
15067
  .option("--search <text>", "Fuzzy search (typos, word order, prefixes) over names, addresses, subjects and the text of every message in EVERY non-warmup conversation: all channels unless --channel narrows, archived and sent-only threads included. --segment, --unanswered and the Primary tab's negative-tier exclusion are ignored while set; explicit facets such as --status still narrow. For structured filters use --status or --tag, not a text search.")
14905
15068
  .option("--include-archived", "Include archived conversations.")
14906
15069
  .option("--limit <n>", "Maximum conversations to return (1-200). Defaults to 50.")
@@ -14938,6 +15101,7 @@ Examples:
14938
15101
  ["excludeDomain", "exclude_domain"],
14939
15102
  ["mailboxId", "mailbox_id"],
14940
15103
  ["senderProfile", "sender_profile_id"],
15104
+ ["senderProfileId", "sender_profile_id"],
14941
15105
  ["cursor", "cursor"],
14942
15106
  ]) {
14943
15107
  const value = readOption(options[flag]);
@@ -14961,7 +15125,7 @@ Examples:
14961
15125
  });
14962
15126
  }))
14963
15127
  .addCommand(new Command("get")
14964
- .description("Get one conversation with its full message thread. <conversation> accepts a conversation id, Unipile chat id, or (email) Zapbox thread id, and is resolved on any channel by default (email first, then LinkedIn/WhatsApp). Opening a WhatsApp thread may fetch provider history and merge provider-confirmed phone/contact identities in the local mirror. This uses no Oxygen credits and sends no message; old conversation references remain usable. For stored-only inspection, use messages query.")
15128
+ .description("Get one conversation with its full message thread. <conversation> accepts a conversation id, Unipile chat id, or (email) Zapbox thread id, and is resolved on any channel by default (email first, then LinkedIn/WhatsApp). Opening a LinkedIn or WhatsApp thread may fetch provider history and update the local mirror. WhatsApp also merges provider-confirmed phone/contact identities. This uses no Oxygen credits and sends no message; old conversation references remain usable. For stored-only inspection, use messages query.")
14965
15129
  .argument("<conversation>", "Conversation id, Unipile chat id, or Zapbox thread id.")
14966
15130
  .option("--channel <channel>", "Inbox channel: all (default, resolves the id on any channel), email, linkedin, or whatsapp.")
14967
15131
  .option("--message-limit <n>", "Maximum messages to return (1-500). Defaults to 100.")
@@ -15095,7 +15259,7 @@ Examples:
15095
15259
  }));
15096
15260
  }))
15097
15261
  .addCommand(new Command("mark-read")
15098
- .description("Mark a conversation and all its messages as read.")
15262
+ .description("Mark a conversation and all its messages as read (LinkedIn by default; use --channel email or whatsapp for those channels).")
15099
15263
  .argument("<conversation>", "Conversation id, Unipile chat id, or (email) Zapbox thread id.")
15100
15264
  .option("--channel <channel>", "Inbox channel: linkedin (default), whatsapp, or email.")
15101
15265
  .option("--json", "Print a JSON envelope.")
@@ -15120,7 +15284,8 @@ Examples:
15120
15284
  .option("--exclude-domain <domains>", "Email only: comma-separated counterpart domains to exclude.")
15121
15285
  .option("--mailbox-id <ids>", "Email only: comma-separated mailbox ids.")
15122
15286
  .option("--sender-account-id <ids>", "DMs only: comma-separated OXYGEN sender UUIDs from `oxygen senders list --json` (not LinkedIn member ids).")
15123
- .option("--sender-profile <ids>", "Comma-separated sender profile ids (people, from `senders profiles list`): only their own inboxes and LinkedIn/WhatsApp accounts, as in `inbox list --sender-profile`.")
15287
+ .option("--sender-profile-id <ids>", "Comma-separated sender profile ids (people; `unibox metadata` lists them): only their own inboxes and LinkedIn/WhatsApp accounts, as in `inbox list --sender-profile-id`.")
15288
+ .addOption(new Option("--sender-profile <ids>", "Alias of --sender-profile-id.").hideHelp())
15124
15289
  .option("--since <iso>", "Only conversations whose last message is on/after this ISO date/timestamp. Excludes conversations without a last-message date. Cross-channel.")
15125
15290
  .option("--until <iso>", "Only conversations whose last message is on/before this ISO date/timestamp. Excludes conversations without a last-message date. Cross-channel.")
15126
15291
  .option("--search <text>", "Fuzzy search (typos, word order, prefixes) over names, addresses, subjects and the text of every message in EVERY non-warmup conversation: all channels unless --channel narrows, archived and sent-only threads included. --segment, --unanswered and the Primary tab's negative-tier exclusion are ignored while set; explicit facets such as --status still narrow.")
@@ -15151,6 +15316,7 @@ Examples:
15151
15316
  ["mailboxId", "mailbox_id"],
15152
15317
  ["senderAccountId", "sender_account_id"],
15153
15318
  ["senderProfile", "sender_profile_id"],
15319
+ ["senderProfileId", "sender_profile_id"],
15154
15320
  ["since", "since"],
15155
15321
  ["until", "until"],
15156
15322
  ["search", "search"],
@@ -15169,6 +15335,7 @@ Examples:
15169
15335
  params.set("needs_reply", "true");
15170
15336
  return requestOxygen(`/api/cli/inbox/read-all?${params.toString()}`, {
15171
15337
  method: "POST",
15338
+ previewOnly: !options.yes,
15172
15339
  // Approval rides in the body: a bodyless POST reads as
15173
15340
  // approved=false server-side, i.e. a preview that mutates
15174
15341
  // nothing. --yes (not --approved) because this spends no
@@ -15331,6 +15498,7 @@ Examples:
15331
15498
  .filter(Boolean);
15332
15499
  return requestOxygen("/api/cli/inbox/bulk", {
15333
15500
  method: "POST",
15501
+ previewOnly: !options.yes,
15334
15502
  body: {
15335
15503
  action,
15336
15504
  channel,
@@ -15352,7 +15520,7 @@ Examples:
15352
15520
  .addCommand(new Command("create")
15353
15521
  .description("Save a view for the team.")
15354
15522
  .requiredOption("--name <name>", "View name, unique in the workspace (up to 80 characters).")
15355
- .option("--filters <url-or-query>", "A Unibox URL or its query string, e.g. 'sender_profile_id=<id>&channels=linkedin&status=interested' (the `inbox list` params). Omit for every conversation.")
15523
+ .option("--filters <url-or-query>", "A Unibox URL or its query string, e.g. 'sender_profile_id=<id>&channels=linkedin&status=interested'. Keys are the `inbox list` filters (sender_profile_id is --sender-profile-id); a view without segment opens on the Primary tab, segment=all on every conversation. Omit for every conversation.")
15356
15524
  .option("--default", "Also make it your own default view.")
15357
15525
  .option("--json", "Print a JSON envelope.")
15358
15526
  .action(async (options) => {
@@ -15369,7 +15537,7 @@ Examples:
15369
15537
  .description("Rename, re-filter or reorder a view, or set it as (or stop using it as) your own default.")
15370
15538
  .argument("<view>", "View id (from `inbox views list`).")
15371
15539
  .option("--name <name>", "New name.")
15372
- .option("--filters <url-or-query>", "Replace the view's filters: a Unibox URL or its query string.")
15540
+ .option("--filters <url-or-query>", "Replace the view's filters: a Unibox URL or its query string (the `inbox list` filter keys; without segment the view opens on the Primary tab).")
15373
15541
  .option("--position <n>", "Zero-based order in the team's list.")
15374
15542
  .option("--default", "Make it your own default view.")
15375
15543
  .option("--no-default", "Stop using it as your default view (you open on the built-in view).")
@@ -15948,7 +16116,7 @@ Examples:
15948
16116
  });
15949
16117
  })))
15950
16118
  .addCommand(addContractFlags(new Command("analytics")
15951
- .description("Show selected-window sequencer analytics with persisted status kept separate from operational state, fleet marked-active versus operationally-live counts, current open work, sent/terminal-failed/deferral totals, failure classes, and action-kind breakdowns. Also includes per-sequence funnels and native email attribution under analytics.emailAttribution: byMailbox, byDomain, and explicit unattributed facts. With --sequence, analytics.senderPerformance groups channel-conditional counts and rates by sending person; analytics.messagePerformance reports each copy-bearing deterministic step variant, target/actual distribution, contacted recipients, and type-specific accepted/reply/positive outcomes (absent for note-less connect-only campaigns). Message-level reply outcomes require preserved step/variant provenance, so older unattributed replies remain in campaign totals without being guessed into a message row. Provider-owned campaigns appear only after their telemetry is normalized into Oxygen's native Sequence ledgers. Also reports whether the reply → CRM automation is armed, with a link to the workflow.")
16119
+ .description("Show selected-window sequencer analytics with persisted status kept separate from operational state, fleet marked-active versus operationally-live counts, current open work, sent/terminal-failed/deferral totals, failure classes, and action-kind breakdowns. Also includes per-sequence funnels and native email attribution under analytics.emailAttribution: byMailbox, byDomain, and explicit unattributed facts. With --sequence, analytics.senderPerformance groups channel-conditional counts and rates by sending person; analytics.messagePerformance reports each copy-bearing deterministic step variant, target/actual distribution, contacted recipients, and type-specific accepted/reply/positive outcomes (absent for note-less connect-only campaigns). Message-level reply outcomes require preserved step/variant provenance, so older unattributed replies remain in campaign totals without being guessed into a message row. Provider-owned campaigns appear only after their telemetry is normalized into Oxygen's native Sequence ledgers. Also reports whether the reply → CRM automation is armed, with a link to the workflow. Omit --projection for all sections in one read; --projection value --path /analytics selects all analytics sections. value_json is escaped JSON text, paged losslessly with value_cursor.")
15952
16120
  .option("--sender <id>", "Filter to one sender from `oxygen senders list` or `oxygen senders profiles list`, or a key from this output's senders list. Repeatable.", collectRepeatable, [])
15953
16121
  .option("--json", "Print a JSON envelope."), SEQUENCE_ANALYTICS_READ_CONTRACT, {
15954
16122
  range: { valueName: "<preset>", parse: readOption },
@@ -16224,7 +16392,7 @@ Examples:
16224
16392
  });
16225
16393
  }))
16226
16394
  .addCommand(new Command("get")
16227
- .description("Get a sequence's definition, senders, status, and credit usage. --readiness adds the launch check — whether it CAN go live, why not, and the mailbox pool it would send from — without touching it; `sequences start <sequence>` without --approved shows who sends each step (sender_journey).")
16395
+ .description("Get a sequence's definition, senders, status, and credit usage. --readiness adds the launch check — whether it CAN go live, why not, and the mailbox pool it would send from — without touching it; `sequences preview <sequence>` (a read; 0 credits) shows who sends each step (sender_journey).")
16228
16396
  .argument("<sequence>", "Sequence id or slug.")
16229
16397
  .option("--readiness", "Add launch_readiness: blockers (e.g. a missing send cap), warnings, and the resolved sending mailboxes. Read-only — sends nothing, changes nothing, spends nothing. Skips the LinkedIn rendered-copy scan, which stays in `sequences start` without --approved.")
16230
16398
  .option("--json", "Print a JSON envelope.")
@@ -16232,7 +16400,7 @@ Examples:
16232
16400
  await handleAsyncAction("sequences get", options, () => requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}${options.readiness ? "?include=readiness" : ""}`));
16233
16401
  }))
16234
16402
  .addCommand(new Command("enroll")
16235
- .description("Enroll leads into a sequence. Enrolling spends no credits. An active sequence contacts the new leads on its next dispatch tick; a draft or paused one holds them until `sequences start`. --from-table enrolls every not-yet-enrolled row of the sequence's bound source table (up to 500 per run; the response's from_table.has_more says whether to run again) — for a LinkedIn sequence the profile is read from the bound table's URL column, where a full /in/ URL and a bare public handle carrying a hyphen or digit (ada-lovelace) both work, so no pre-resolved provider ids are needed; each handle Oxygen canonicalized is echoed in linkedin_handles_canonicalized (value + url) to check before start, and every row that still cannot be resolved is named in unresolved_linkedin_lead_details (row, value, reason) rather than only counted in unresolved_linkedin_leads. --leads-file enrolls an explicit JSON list { leads: [...] }: for an email/WhatsApp sequence a lead identified only by an email (row_values.email) or phone (row_values.phone) is auto-filed as a row in the sequence's leads table (auto-creating and binding one if there is none), deduped by email/phone, so no table has to exist first (the table is returned as leads_table with a web_url); a LinkedIn lead needs a lead_provider_id, a lead_profile_url (a /in/ URL or a bare public handle with a hyphen or digit), or a table_row_id whose row carries one of those. A CRM-only lead needs a stable table_row_id or lead_provider_id in addition to mapped row_values; a HubSpot contact id used in crm_task associations identifies the destination record, not the Oxygen enrollment. When the sequence is bound to a source table, a lead's table_row_id auto-snapshots that row's columns (incl. AI/tool outputs) into row_values for {{column}} copy — explicit row_values win. Idempotent per table row (and per email/phone for auto-filed leads). WhatsApp-only campaigns also enroll each normalized number once across all source rows and enrollment statuses; mixed campaigns keep other channels and skip duplicate WhatsApp actions. Duplicate protection defaults to this campaign; --whatsapp-duplicate-scope on create/update can additionally block identical copy across all campaigns. The org do-not-contact list is always enforced; --exclude-contacted and --suppress-list add further opt-in skips (reported under skipped_by_reason). Leads already owned by a sender account (from an earlier real send) are routed back to that same account; a lead owned by a sender NOT on this sequence is skipped (bound_to_other_sender) unless --ignore-sender-bindings. This enrolls into an OXYGEN Sequence; to put the same rows into a campaign you already run in Smartlead, Instantly, lemlist or HeyReach, use `sequences push-external`.")
16403
+ .description("Enroll leads into a sequence. Enrolling spends no credits. An active sequence contacts the new leads on its next dispatch tick; a draft or paused one holds them until `sequences start`. --from-table enrolls every not-yet-enrolled row of the sequence's bound source table (up to 500 per run; the response's from_table.has_more says whether to run again) — for a LinkedIn sequence the profile is read from the bound table's URL column, where a full /in/ URL and a bare public handle carrying a hyphen or digit (ada-lovelace) both work, so no pre-resolved provider ids are needed; each handle Oxygen canonicalized is echoed in linkedin_handles_canonicalized (value + url) to check before start, and every row that still cannot be resolved is named in unresolved_linkedin_lead_details (row, value, reason) rather than only counted in unresolved_linkedin_leads. In a LinkedIn + email sequence a row with no usable profile but a recipient email is still enrolled for the email steps (its LinkedIn steps are skipped as no_linkedin_profile, a connection wait passes at once and a connection branch takes its not-connected path) and is listed in linkedin_unavailable_lead_details instead; unresolved_linkedin_leads names only rows that were not enrolled. --leads-file enrolls an explicit JSON list { leads: [...] }: for an email/WhatsApp sequence a lead identified only by an email (row_values.email) or phone (row_values.phone) is auto-filed as a row in the sequence's leads table (auto-creating and binding one if there is none), deduped by email/phone, so no table has to exist first (the table is returned as leads_table with a web_url); a LinkedIn + email sequence auto-files an email-only lead the same way; a LinkedIn lead needs a lead_provider_id, a lead_profile_url (a /in/ URL or a bare public handle with a hyphen or digit), or a table_row_id whose row carries one of those. A CRM-only lead needs a stable table_row_id or lead_provider_id in addition to mapped row_values; a HubSpot contact id used in crm_task associations identifies the destination record, not the Oxygen enrollment. When the sequence is bound to a source table, a lead's table_row_id auto-snapshots that row's columns (incl. AI/tool outputs) into row_values for {{column}} copy — explicit row_values win. Idempotent per table row (and per email/phone for auto-filed leads). WhatsApp-only campaigns also enroll each normalized number once across all source rows and enrollment statuses; mixed campaigns keep other channels and skip duplicate WhatsApp actions. Duplicate protection defaults to this campaign; --whatsapp-duplicate-scope on create/update can additionally block identical copy across all campaigns. The org do-not-contact list is always enforced; --exclude-contacted and --suppress-list add further opt-in skips (reported under skipped_by_reason). Leads already owned by a sender account (from an earlier real send) are routed back to that same account; a lead owned by a sender NOT on this sequence is skipped (bound_to_other_sender) unless --ignore-sender-bindings. This enrolls into an OXYGEN Sequence; to put the same rows into a campaign you already run in Smartlead, Instantly, lemlist or HeyReach, use `sequences push-external`. esp.pool_scope identifies the active workspace inventory used for routing and strict_deferred_leads estimates; these estimates omit sequence sender restrictions, readiness and daily limits. A zero estimate does not guarantee readiness. Review sequences preview before launch.")
16236
16404
  .argument("<sequence>", "Sequence id or slug.")
16237
16405
  .option("--leads-file <path>", "Path to a JSON file: { \"leads\": [{ row_values: { email }, lead_name }] } for cold email; { lead_provider_id, lead_name, table_row_id, row_values } for LinkedIn/existing rows; CRM-only leads require table_row_id or a stable lead_provider_id alongside mapped row_values. A CRM association object id is not the enrollment identity. Exactly one of --leads-file or --from-table.")
16238
16406
  .option("--from-table", "Enroll every not-yet-enrolled row of the sequence's bound source table (up to 500 per run; re-run to continue). Exactly one of --leads-file or --from-table.")
@@ -16323,7 +16491,7 @@ Examples:
16323
16491
  }, formatSequencePushExternal);
16324
16492
  }))
16325
16493
  .addCommand(new Command("start")
16326
- .description("Preview or start a sequence. Without --approved this is the launch check and changes nothing: it returns launch_readiness (blockers such as a missing send cap or the WhatsApp cold-initiate opt-in, plus warnings), who sends each step (sender_journey: each step's From, its candidate inboxes and how a lead's inbox is picked; no leads needed), scans pending LinkedIn copy, and returns exact rendered recipient samples, character counts/limits, copy blockers, per-kind scope, sender/mailbox capacity, CRM readiness, and safety caps. Live start requires --approved; --max-credits is optional. Email/WhatsApp/CRM-task journeys additionally require --max-live-sends because their external actions cost 0 Oxygen credits. --dry-run is not this preview: it starts the sequence in simulated mode (it leaves draft), with no send, provider campaign, CRM task, or credits. The first live start also switches on the standing reply → CRM automation for the workspace.")
16494
+ .description("Preview or start a sequence. Without --approved this is the launch check and changes nothing: it returns launch_readiness (blockers and warnings), sender_journey (each step's From, candidate inboxes and assignment; no leads needed), rendered pending-lead LinkedIn/WhatsApp copy, character counts/limits, copy blockers, per-kind scope, sender/mailbox capacity, CRM readiness and safety caps. Email copy is not rendered; inspect its saved templates and unsubscribe settings with sequences get. Live start requires --approved; --max-credits is optional. Email/WhatsApp/CRM-task journeys additionally require --max-live-sends because their external actions cost 0 Oxygen credits. --dry-run starts the sequence in simulated mode (it leaves draft), with no send, provider campaign, CRM task or credits. Live start enables the standing reply → CRM automation only if it has never been configured. Planned counts include only leads each step's channel can reach; skipped_actions_by_reason and channel_coverage show the rest.")
16327
16495
  .argument("<sequence>", "Sequence id or slug.")
16328
16496
  .option("--approved", "Approve and activate live. Without this flag, returns a preview only, including copy_preview rendered samples and blockers.")
16329
16497
  .option("--max-credits <n>", "Optional positive credit ceiling. Omit for the free launch preview. Live start uses the saved ceiling when omitted; an unset ceiling means unbounded credits, not recipient approval.")
@@ -16344,6 +16512,15 @@ Examples:
16344
16512
  },
16345
16513
  });
16346
16514
  });
16515
+ }))
16516
+ .addCommand(new Command("preview")
16517
+ .description("The free launch check, as a read: the same preview sequences start returns without --approved. It returns launch_readiness (blockers and warnings), sender_journey (who sends each step and how an inbox is picked), copy_preview (rendered pending-lead LinkedIn/WhatsApp copy, character counts and scan), caps and estimated credits. Email copy is not rendered; inspect its saved templates and unsubscribe settings with sequences get. Changes nothing and costs 0 credits; it never starts, resumes or dry-runs the sequence. Start with sequences start <sequence> --approved after review. Planned counts include only leads each step's channel can reach; skipped_actions_by_reason, channel_coverage and copy_preview.skipped_leads show the rest.")
16518
+ .argument("<sequence>", "Sequence id or slug.")
16519
+ .option("--json", "Print a JSON envelope.")
16520
+ .action(async (sequence, options) => {
16521
+ await handleAsyncAction("sequences preview", options, () =>
16522
+ // The start route's preview branch: no approved, no dry_run, no ceilings.
16523
+ requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/start`, { method: "POST", body: {} }));
16347
16524
  }))
16348
16525
  .addCommand(new Command("signal")
16349
16526
  .description("Record an external GTM signal (e.g. linkedin_connected, email_replied, company_raised_funds, job_change, web_visit, intent) onto a running sequence's enrollment(s) to drive signal-triggered branch/wait control. Target an enrollment by --enrollment or --lead (at least one is required); the server validates the signal name.")
@@ -16375,7 +16552,7 @@ Examples:
16375
16552
  }));
16376
16553
  }))
16377
16554
  .addCommand(new Command("resume")
16378
- .description("Resume a paused sequence.")
16555
+ .description("Resume a paused sequence. A campaign paused at its live send cap pauses again at once: raise the cap and resume in one step with `sequences start <sequence> --max-live-sends <n>` (preview, then --approved).")
16379
16556
  .argument("<sequence>", "Sequence id or slug.")
16380
16557
  .option("--json", "Print a JSON envelope.")
16381
16558
  .action(async (sequence, options) => {
@@ -16392,10 +16569,15 @@ Examples:
16392
16569
  await handleAsyncAction("sequences delete", options, () => requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}${options.approved ? "?approved=true" : ""}`, { method: "DELETE" }));
16393
16570
  }))
16394
16571
  .addCommand(new Command("enrollments")
16395
- .description("List per-lead enrollment state plus the latest action status, error, attempts, and retry time. missing_source_row is true for a lead whose source-table row was deleted: still enrolled and contacted (remove with `contacts-remove --missing-source-rows`); null when the campaign has no live source table to check against, or the check failed.")
16572
+ .description("List per-lead enrollment state plus the latest action status, error, attempts, and retry time. --view compact answers 'when is each lead's next message due' per lead: lead, status, invite (accepted, pending, not_invited), accepted_at, current_step and next_due. next_due.summary says it in words, at_local/gives_up_at_local give the time in the campaign's timezone (its schedule's, else UTC) and at/gives_up_at in UTC. Reasons: step_scheduled (planned send time; held_by names what holds it, e.g. send_cap_reached), planner_due (when the planner next looks at the lead), not_started (begins when the campaign is started or resumed), nothing_scheduled (enrolled, no step will run), waiting_for_connection / waiting_for_signal (no send time; acceptance makes the next step due at once; gives_up_at is when the wait ends, and on_timeout says whether the lead then leaves or moves on), finished. A failed lead carries failure: the cause in words (cause_recorded false when OXYGEN never recorded one) and provider_contact (made; none = the step never started; nothing_sent; unknown). missing_source_row is true for a lead whose source-table row was deleted: still enrolled and contacted (remove with `contacts-remove --missing-source-rows`); null when the campaign has no live source table to check against, or the check failed. channels says which of the sequence's channels reach each lead: usable, and unavailable with a reason (no_linkedin_profile = enrolled for email only, its LinkedIn steps are skipped; no_recipient_email; no_phone_number).")
16396
16573
  .argument("<sequence>", "Sequence id or slug.")
16397
- .option("--status <status>", "Filter by enrollment status (active, waiting_connection, replied, completed, stopped, failed).")
16574
+ .option("--status <status>", "Filter by enrollment status (pending, active, waiting_connection, waiting_signal, replied, completed, stopped, failed, excluded). pending = enrolled, not started (next_due not_started); waiting_connection = invite sent, not accepted yet. Status is the lead's progress, not its invite: use --invite accepted for the leads that accepted.")
16575
+ .option("--step <n>", "Only leads currently on this step (0-based index, as in the sequence's steps).")
16576
+ .option("--invite <state>", "Only leads in this LinkedIn invite state: accepted (invited and connected), pending (invited, not accepted yet) or not_invited (no invite: already connected before, suppressed or email-only).")
16577
+ .option("--view <view>", "full (default, every enrollment field) or compact (one short row per lead with next_due in words and local time).")
16578
+ .option("--order <order>", "created (default) or due: soonest next_due first, then waiting leads by gives_up_at, then leads with nothing due.")
16398
16579
  .option("--limit <n>", "Maximum enrollments to return (1-500).")
16580
+ .option("--offset <n>", "Skip this many enrollments; pass next_offset from the previous page to continue.")
16399
16581
  .option("--json", "Print a JSON envelope.")
16400
16582
  .action(async (sequence, options) => {
16401
16583
  await handleAsyncAction("sequences enrollments", options, () => {
@@ -16403,9 +16585,24 @@ Examples:
16403
16585
  const status = readOption(options.status);
16404
16586
  if (status)
16405
16587
  params.set("status", status);
16588
+ const step = readOption(options.step);
16589
+ if (step)
16590
+ params.set("step", step);
16591
+ const invite = readOption(options.invite);
16592
+ if (invite)
16593
+ params.set("invite", invite);
16594
+ const view = readOption(options.view);
16595
+ if (view)
16596
+ params.set("view", view);
16597
+ const order = readOption(options.order);
16598
+ if (order)
16599
+ params.set("order", order);
16406
16600
  const limit = readOption(options.limit);
16407
16601
  if (limit)
16408
16602
  params.set("limit", limit);
16603
+ const offset = readOption(options.offset);
16604
+ if (offset)
16605
+ params.set("offset", offset);
16409
16606
  const suffix = params.toString();
16410
16607
  return requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/enrollments${suffix ? `?${suffix}` : ""}`);
16411
16608
  });
@@ -16581,7 +16778,7 @@ Examples:
16581
16778
  });
16582
16779
  }))
16583
16780
  .addCommand(new Command("reconcile")
16584
- .description("Safely reconcile one ambiguous LinkedIn invite/message from provider reads. Preview-only unless --approved; never resends.")
16781
+ .description("Safely reconcile one ambiguous LinkedIn invite/message from provider reads. Zero credits. Preview-only unless --approved; never resends.")
16585
16782
  .argument("<sequence>", "Sequence id or slug. The sequence must be paused.")
16586
16783
  .requiredOption("--action <id>", "Failed/canceled live action UUID to reconcile.")
16587
16784
  .option("--approved", "Apply an exact provider match to local state. No provider write or resend occurs.")
@@ -17494,55 +17691,21 @@ Examples:
17494
17691
  });
17495
17692
  });
17496
17693
  })))
17497
- .addCommand(new Command("domains")
17498
- .description("List the org's whole-domain email blocks, newest first. Filter by --reason (manual | complaint | policy) or --search (case-insensitive substring of the domain).")
17499
- .option("--reason <reason>", "Filter by reason: manual, complaint, policy.")
17500
- .option("--search <text>", "Case-insensitive substring match on the domain.")
17501
- .option("--limit <n>", "Maximum domain blocks to return (1-500; default 100).")
17502
- .option("--offset <n>", "Pagination offset (0-based).")
17694
+ .addCommand(addSuppressionListFlags(new Command("domains")
17695
+ .description("List the org's whole-domain email blocks, newest first. Filter by --reason (manual | complaint | policy) or --search (case-insensitive substring of the domain)."), "domains")
17503
17696
  .option("--json", "Print a JSON envelope.")
17504
17697
  .action(async (options) => {
17505
17698
  await handleAsyncAction("suppressions domains", options, () => {
17506
- const params = new URLSearchParams();
17507
- const reason = readOption(options.reason);
17508
- if (reason)
17509
- params.set("reason", reason);
17510
- const search = readOption(options.search);
17511
- if (search)
17512
- params.set("search", search);
17513
- const limit = readOption(options.limit);
17514
- if (limit)
17515
- params.set("limit", limit);
17516
- const offset = readOption(options.offset);
17517
- if (offset)
17518
- params.set("offset", offset);
17519
- const suffix = params.toString();
17699
+ const suffix = suppressionListQuery(options, "domains");
17520
17700
  return requestOxygen(`/api/cli/suppressions/domains${suffix ? `?${suffix}` : ""}`);
17521
17701
  });
17522
17702
  }))
17523
- .addCommand(new Command("addresses")
17524
- .description("List the org's blocked email ADDRESSES, newest first (bounces, unsubscribes, and `suppressions import` land here). Filter by --reason (hard_bounce | complaint | unsubscribe | manual) or --search (case-insensitive substring of the address). Rows carry a CRM person name when one matches.")
17525
- .option("--reason <reason>", "Filter by reason: hard_bounce, complaint, unsubscribe, manual.")
17526
- .option("--search <text>", "Case-insensitive substring match on the address.")
17527
- .option("--limit <n>", "Maximum addresses to return (1-500; default 100).")
17528
- .option("--offset <n>", "Pagination offset (0-based).")
17703
+ .addCommand(addSuppressionListFlags(new Command("addresses")
17704
+ .description("List the org's blocked email ADDRESSES, newest first (bounces, unsubscribes, and `suppressions import` land here). Filter by --reason (hard_bounce | complaint | unsubscribe | manual) or --search (case-insensitive substring of the address). Rows carry a CRM person name when one matches."), "addresses")
17529
17705
  .option("--json", "Print a JSON envelope.")
17530
17706
  .action(async (options) => {
17531
17707
  await handleAsyncAction("suppressions addresses", options, () => {
17532
- const params = new URLSearchParams();
17533
- const reason = readOption(options.reason);
17534
- if (reason)
17535
- params.set("reason", reason);
17536
- const search = readOption(options.search);
17537
- if (search)
17538
- params.set("search", search);
17539
- const limit = readOption(options.limit);
17540
- if (limit)
17541
- params.set("limit", limit);
17542
- const offset = readOption(options.offset);
17543
- if (offset)
17544
- params.set("offset", offset);
17545
- const suffix = params.toString();
17708
+ const suffix = suppressionListQuery(options, "addresses");
17546
17709
  return requestOxygen(`/api/cli/suppressions/addresses${suffix ? `?${suffix}` : ""}`);
17547
17710
  });
17548
17711
  }))
@@ -18262,7 +18425,7 @@ Examples:
18262
18425
  : requestOxygen(`/api/cli/mailboxes/sync?${new URLSearchParams({ mailbox }).toString()}`));
18263
18426
  }))
18264
18427
  .addCommand(new Command("connect-oauth")
18265
- .description("Connect Gmail/Microsoft 365 with --vendor composio to import all mail into Unibox and business activity into CRM. Each connected own mailbox costs 100 credits/month including history, sync and analysis; previews cost 0, with no extra execution fee. Managed OAuth requires provider rollout readiness and exact registered addresses, max 10. Use mailboxes sync for progress and pause/resume/disconnect. Composio uses individual owner consent only. Native --vendor oxygen retains its existing polling; full history/CRM requires managed reconnection. Quote any mailbox count with billing estimate --mailboxes <n> --json. Warm-up retains its existing eligibility and pricing. Preview by default; pass --approved only after reviewing exact candidates. --vendor oxygen requires an exact address list and never discovers a domain directory. For --vendor oxygen only, Microsoft supports --authorization-mode tenant (one administrator approval, up to 500 exact selected addresses, recommended for dedicated sending tenants) or individual (one account sign-in per mailbox, max 10 per run, recommended for ordinary company/personal mailboxes). For Microsoft's built-in roles, tenant mode requires a Global Administrator or Privileged Role Administrator because it requests the Microsoft Graph application Mail.Send and Mail.Read permissions; Application Administrator and Cloud Application Administrator cannot consent to Microsoft Graph application roles. Microsoft's application grant is tenant-wide at the provider; OXYGEN—not Microsoft—enforces the selected-address boundary at verification and send time unless the tenant separately configures Exchange Application RBAC. For non-managed Microsoft mailboxes, native-send approval does not authorize OXYGEN Warm-up: every mailbox still needs its own `mailboxes warmup microsoft` Outlook consent before standalone enrollment. Connecting authorization does not change whether a mailbox is active or disabled. Google uses individual OAuth. Source tokens, passwords, authenticator seeds, and one-time codes never transfer. Managed authorization paths remain available through their vendor options.")
18428
+ .description("Connect Gmail/Microsoft 365 with --vendor composio to import all mail into Unibox and business activity into CRM. The listed recurring rate is 100 credits per mailbox per month including history, sync and analysis; previews cost 0, with no extra execution fee. Before consent, inspect current monthly connection prices and first-debit timing with billing commitments --view connection-prices --json. Quote a mailbox count with billing estimate --mailboxes <n> --json; the managed preview shows the listed rate; an unavailable managed connection directs you to OXYGEN support. Managed OAuth requires provider rollout readiness and exact registered addresses, max 10. Use mailboxes sync for progress and pause/resume/disconnect. Composio uses individual owner consent only. Native --vendor oxygen retains its existing polling; full history/CRM requires managed reconnection. Warm-up retains its existing eligibility and pricing. Preview by default; pass --approved only after reviewing exact candidates. --vendor oxygen requires an exact address list and never discovers a domain directory. For --vendor oxygen only, Microsoft supports --authorization-mode tenant (one administrator approval, up to 500 exact selected addresses, recommended for dedicated sending tenants) or individual (one account sign-in per mailbox, max 10 per run, recommended for ordinary company/personal mailboxes). For Microsoft's built-in roles, tenant mode requires a Global Administrator or Privileged Role Administrator because it requests the Microsoft Graph application Mail.Send and Mail.Read permissions; Application Administrator and Cloud Application Administrator cannot consent to Microsoft Graph application roles. Microsoft's application grant is tenant-wide at the provider; OXYGEN—not Microsoft—enforces the selected-address boundary at verification and send time unless the tenant separately configures Exchange Application RBAC. For non-managed Microsoft mailboxes, native-send approval does not authorize OXYGEN Warm-up: every mailbox still needs its own `mailboxes warmup microsoft` Outlook consent before standalone enrollment. Connecting authorization does not change whether a mailbox is active or disabled. Google uses individual OAuth. Source tokens, passwords, authenticator seeds, and one-time codes never transfer. Managed authorization paths remain available through their vendor options.")
18266
18429
  .addHelpText("after", "\nDocs: https://oxygen-agent.com/docs/providers/mailboxes\n")
18267
18430
  .option("--provider <provider>", "Mailbox provider: google or microsoft.")
18268
18431
  .option("--vendor <vendor>", "Authorization path: composio for customer-owned Gmail/Microsoft 365 with managed consent, history and CRM; oxygen for native/tenant consent; zapmail (default) or inboxkit for provisioned inboxes.")
@@ -18455,7 +18618,7 @@ Examples:
18455
18618
  .option("--approved", "Execute the PRICED enrollment. Without it the response is a priced preview and nothing is enrolled or billed.")
18456
18619
  .option("--plan <hash>", "Hash from the fresh preview (required with --approved).")
18457
18620
  .option("--max-credits <n>", "Hard first-cycle credit cap from the preview (required with --approved).")
18458
- .option("--ramp-weeks <n>", "Auto-unenroll after this many weeks (the ramp window) so billing stops by itself.")
18621
+ .option("--ramp-weeks <n>", "Weeks the warm-up volume climb is spread over (default 4). A paid enrollment completes its ramp after 28 days with a provider-confirmed warm-up send, then keeps warming at 10 a day until you disable it.")
18459
18622
  .option("--dry-run", "Simulate without calling the provider (mailboxes marked pending).")
18460
18623
  .option("--json", "Print a JSON envelope.")
18461
18624
  .action(async (options) => {
@@ -18492,6 +18655,29 @@ Examples:
18492
18655
  },
18493
18656
  });
18494
18657
  });
18658
+ }))
18659
+ .addCommand(new Command("google")
18660
+ .description("Authorize one to ten connected Google inboxes for OXYGEN Warm-up. Separate from native sending: no passwords or OXYGEN tokens transfer. Preview costs 0 credits and creates no link. Approve its hash with --max-credits 0, open each consent_url as that exact inbox, then poll --status. Google shows the warm-up provider's consent application. Warmup stays off until a separately approved warmup enable; its fresh preview shows the price, including 0 extra credits when included in the mailbox connection.")
18661
+ .requiredOption("--mailboxes <list>", "Exact Google inbox ids or addresses, comma-separated; maximum ten.")
18662
+ .option("--approved", "Create the previewed authorization links.")
18663
+ .option("--plan <hash>", "Fresh preview hash; required with approval.")
18664
+ .option("--max-credits <n>", "Exactly 0; required with approval.")
18665
+ .option("--status", "Verify completed Google authorizations; never starts warmup.")
18666
+ .option("--json", "Print a JSON envelope.")
18667
+ .action(async (options) => {
18668
+ await handleAsyncAction("mailboxes warmup google", options, () => {
18669
+ const mailboxes = readCsvOption(options.mailboxes);
18670
+ const planHash = readOption(options.plan);
18671
+ const maxCredits = options.maxCredits === undefined ? undefined : readNonNegativeNumber(options.maxCredits);
18672
+ if (options.approved && options.status)
18673
+ throw new Error("Status and approval cannot be combined.");
18674
+ if (options.approved && (!planHash || maxCredits !== 0))
18675
+ throw new Error("Approval requires a fresh --plan and --max-credits 0.");
18676
+ return requestOxygen("/api/cli/mailboxes/warmup/google", { method: "POST", body: {
18677
+ mailboxes, ...(options.approved ? { approved: true } : {}), ...(options.status ? { status: true } : {}),
18678
+ ...(planHash ? { plan_hash: planHash } : {}), ...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
18679
+ } });
18680
+ });
18495
18681
  }))
18496
18682
  .addCommand(new Command("microsoft")
18497
18683
  .description("NON-INBOXKIT FALLBACK ONLY: authorize eligible Microsoft/Outlook mailboxes for OXYGEN Warm-up with ONE browser consent PER MAILBOX, never a password or app password. Do not run this for managed InboxKit mailboxes: fresh Microsoft/Azure orders activate native InboxKit warm-up exports automatically when the approved order includes warmup; opted-out or later separate managed enrollment uses standalone `warmup enable`. For the non-InboxKit fallback there is no tenant-admin shortcut: a 40-inbox Microsoft pool means 40 separate authorizations, each opened by whoever can sign in to that mailbox. The preview names the exact fallback mailboxes and outstanding links at 0 credits. Approve that fresh hash with --max-credits 0, open every returned consent_url, then re-run with --status. Warmup itself stays OFF until standalone `warmup enable`.")
@@ -18758,6 +18944,7 @@ Examples:
18758
18944
  .addOption(new Option("--provider <provider>", "Health provider: emailguard, zapmail, or sendkit. Omit to auto-resolve EmailGuard/Zapmail. SendKit is never auto-selected and requires the configured dev canary sender.").choices(["emailguard", "zapmail", "sendkit"]))
18759
18945
  .option("--subject <subject>", "Seed-message subject. Required with --provider sendkit; Zapmail ignores it beyond labeling the test.")
18760
18946
  .option("--body <text>", "Plain-text seed-message body. Required with --provider sendkit; ignored by Zapmail.")
18947
+ .option("--allow-paused-probe", "Opt in to one diagnostic probe while preserving campaign pause; repeat at both create and send approvals. Never permits disabled senders.")
18761
18948
  .option("--approved", "Actually run the test (otherwise a preview is returned).")
18762
18949
  .option("--max-credits <n>", "Credit cap the caller accepts (must be >= credits_required for managed runs).")
18763
18950
  .option("--plan <hash>", "plan_hash from a fresh create preview (required with --approved).")
@@ -18777,6 +18964,7 @@ Examples:
18777
18964
  ? { body_text: readOption(options.body) }
18778
18965
  : {}),
18779
18966
  ...(options.approved ? { approved: true } : {}),
18967
+ ...(options.allowPausedProbe ? { allow_paused_probe: true } : {}),
18780
18968
  ...(readOption(options.maxCredits)
18781
18969
  ? {
18782
18970
  max_credits: Number(readOption(options.maxCredits)),
@@ -18791,6 +18979,7 @@ Examples:
18791
18979
  .addCommand(new Command("send")
18792
18980
  .description("Preview or send the exact EmailGuard seed message for a provider-created test. Preview returns sender, all recipients, exact content, and plan_hash. Live send requires --approved --plan <hash>, costs 0 additional credits, and sends one message only.")
18793
18981
  .argument("<id>", "Oxygen placement-test id returned by `placement-test run`.")
18982
+ .option("--allow-paused-probe", "Opt in to one diagnostic probe while preserving campaign pause; repeat at both create and send approvals. Never permits disabled senders.")
18794
18983
  .option("--approved", "Send the exact previewed seed message (otherwise preview only).")
18795
18984
  .option("--plan <hash>", "plan_hash from a fresh send preview (required with --approved).")
18796
18985
  .option("--json", "Print a JSON envelope.")
@@ -18799,6 +18988,7 @@ Examples:
18799
18988
  method: "POST",
18800
18989
  body: {
18801
18990
  ...(options.approved ? { approved: true } : {}),
18991
+ ...(options.allowPausedProbe ? { allow_paused_probe: true } : {}),
18802
18992
  ...(readOption(options.plan)
18803
18993
  ? { plan_hash: readOption(options.plan) }
18804
18994
  : {}),
@@ -18825,11 +19015,11 @@ Examples:
18825
19015
  program.addCommand(new Command("domains")
18826
19016
  .description("Cold-email domains: sync zones from your own Cloudflare account (BYOK), inspect age/warmup/DNS health, check availability and pricing, buy domains, and archive or delete domains with their inboxes (OXYGEN-bought domains included). Your own Cloudflare account bills purchases to its payment method; domains registered by OXYGEN cost credits at the vendor price. Preview `domains buy` before approval.")
18827
19017
  .addCommand(new Command("list")
18828
- .description("List the org's cached Cloudflare domains with cold-email metadata (age, mailboxes, warmup, sending volume, DNS health). Each row's `dns` object explains the status (ok, issues, not checked, check failed, partial check, no zone) with a reason and fix path; `managed_domains` lists InboxKit-managed bundle domains (vendor-registered + vendor-DNS'd — manage via `managed-inboxes`), each carrying `internal_billing_status` (ok|past_due) and `last_billed_cycle_key` — a domain the vendor keeps live can still be one OXYGEN could not renew. Reads the cache only — run `domains sync` to refresh.")
19018
+ .description("List the org's cached Cloudflare domains with cold-email metadata (age, mailboxes, warmup, sending volume, DNS health). Each row's `dns` object explains the status (ok, issues, not checked, check failed, partial check, no zone) with a reason and fix path; `managed_domains` lists InboxKit-managed bundle domains (vendor-registered + vendor-DNS'd — manage via `managed-inboxes`), each carrying `internal_billing_status` (ok|past_due) and `last_billed_cycle_key` — a domain the vendor keeps live can still be one OXYGEN could not renew. summary.by_dns_state gives all DNS-state counts in one read. volume.outbound_30d counts outbound email messages in the synced inbox ledger, including mail outside Sequences. Reads the cache only — run `domains sync` to refresh.")
18829
19019
  .option("--status <status>", "Filter by zone status: unknown, initializing, pending, active, moved, or deleted.")
18830
19020
  .option("--registrar <registrar>", "Filter by registrar name.")
18831
19021
  .option("--q <text>", "Filter by domain substring.")
18832
- .option("--dns <state>", "Filter by the derived DNS state: ok, issues, not_checked, check_failed, check_incomplete, or no_zone.")
19022
+ .option("--dns <state>", "Filter by one derived DNS state: ok, issues, not_checked, check_failed, check_incomplete, or no_zone; summary.by_dns_state gives all state counts.")
18833
19023
  .option("--expiring", "Only non-deleted domains expiring within 60 days.")
18834
19024
  .option("--has-mailboxes <bool>", "true → only domains with sending mailboxes; false → only domains without.")
18835
19025
  .option("--include-archived", "Include archived domains (hidden by default).")
@@ -19240,6 +19430,14 @@ Examples:
19240
19430
  .action(async (callable, options) => {
19241
19431
  await handleAsyncAction("callables disable", options, () => requestOxygen("/api/cli/callables/status", { method: "POST", body: { callable, status: "disabled" } }));
19242
19432
  }));
19433
+ const workflowRunReadbackHelp = `
19434
+ Stored run values:
19435
+ oxygen workflows runs --run-id <run_id> --projection value --path /runs/0/output --json
19436
+ Canonical graph output is a steps map keyed by node id.
19437
+ Read the observed cost receipt with oxygen workflows run <run_id> --json.
19438
+ For large values, follow next_value_cursor with --value-cursor and the same selection.
19439
+ Append value_json fragments in offset order until next_value_cursor is null, then parse the combined JSON.
19440
+ `;
19243
19441
  program
19244
19442
  .command("workflows")
19245
19443
  .description("Durable workflow automation commands.")
@@ -19269,8 +19467,10 @@ without --draft publishes the revision; calling or enabling it remains a separat
19269
19467
  execution/authorization step.
19270
19468
 
19271
19469
  Run completion:
19272
- Calls enqueue asynchronously. Follow the returned run_id with:
19470
+ Calls without --preview enqueue asynchronously. Use the returned data.run.id as <run_id>:
19273
19471
  oxygen workflows tail <run_id>
19472
+ Preview calls return data.preview and create no run.
19473
+ ${workflowRunReadbackHelp}
19274
19474
  `)
19275
19475
  .addCommand(new Command("templates")
19276
19476
  .description("Deprecated aliases for `oxygen blueprints` — these route through the canonical blueprint surface (built-in seed templates plus this workspace's saved blueprints).")
@@ -19501,7 +19701,7 @@ Run completion:
19501
19701
  });
19502
19702
  }))
19503
19703
  .addCommand(new Command("get")
19504
- .description("Get one workflow automation, including any cron slowdown to your plan's minimum interval.")
19704
+ .description("Get one workflow automation, including any cron slowdown to your plan's minimum interval and `draftRevision` when a saved revision is newer than the published one. The top-level `status` is the live state; `manifest.workflow.status` is what the revision was authored with and does not change on enable or disable.")
19505
19705
  .argument("<workflow>", "Workflow id, slug, or name.")
19506
19706
  .option("--include-bundle", "Include durable recipe bundles in JSON output.")
19507
19707
  .option("--json", "Print a JSON envelope.")
@@ -19509,6 +19709,7 @@ Run completion:
19509
19709
  await handleAsyncAction("workflows get", options, async () => {
19510
19710
  const data = await requestOxygen("/api/cli/workflows/get", {
19511
19711
  method: "POST",
19712
+ readOnly: true,
19512
19713
  body: { workflow },
19513
19714
  });
19514
19715
  if (!options.json) {
@@ -19551,12 +19752,12 @@ Run completion:
19551
19752
  }), options));
19552
19753
  }))
19553
19754
  .addCommand(new Command("call")
19554
- .description("Run a workflow — or preview it first. `--preview` shows every step that leaves Oxygen, the accounts it would use, and what one run costs, without running anything. Enqueues asynchronously without simulating the trigger; follow the returned run_id with `oxygen workflows tail <run_id>`.")
19755
+ .description("Run a workflow — or preview it first. `--preview` returns data.preview without creating a run: it shows every step that leaves Oxygen, the accounts it would use, and what one run costs. Calls without --preview enqueue asynchronously without simulating the trigger; use the returned data.run.id as <run_id> in `oxygen workflows tail <run_id>`.")
19555
19756
  .argument("[workflow]", "Workflow id, slug, or name.")
19556
19757
  .option("--workflow <workflow>", "Workflow id, slug, or name.")
19557
19758
  .option("--workflow-id <workflow_id>", "Workflow id or slug.")
19558
19759
  .option("--workflow-name <workflow_name>", "Workflow name.")
19559
- .option("--input-json <json>", "Workflow input object. Defaults to {}.")
19760
+ .option("--input-json <json>", "Workflow input object in every mode, including smoke-test. Defaults to {}.")
19560
19761
  .option("--mode <mode>", "Execution mode. smoke-test/dry-run: 0 credits, no paid provider calls or external writes; internal reads still execute. live: may spend/write and requires approval + cap. Not needed with --preview, which runs nothing.")
19561
19762
  .option("--idempotency-key <key>", "Optional idempotency key.")
19562
19763
  .option("--max-credits <n>", "Required credit ceiling for live calls.")
@@ -19567,7 +19768,8 @@ Run completion:
19567
19768
  .option("--preview", "Show what a live run of this exact version would do — every step that leaves Oxygen, the accounts it would use, and the minimum number of billable steps and credits it would cost — without running anything. AI and enrichment steps bill per row and can cost more than that floor. Needs no --mode: it always describes a live run.")
19568
19769
  .option("--include-bundle", "Include durable recipe bundles in JSON output.")
19569
19770
  .option("--json", "Print a JSON envelope.")
19570
- .addHelpText("after", "\nSafety: smoke_test and dry_run share one boundary: 0 credits, no paid provider calls, no external writes. Oxygen internal reads use current workspace data, and oxygen.http_json_request may make a real outbound GET. Every mode creates an inspectable Workflow run record.\n\n--node scopes a test to one step. Oxygen plans the slice on the server from the exact saved version and records it on the run, so the run shows precisely which nodes were allowed to execute; you never submit a node list. Selecting the trigger, a disabled node, or a node unreachable from the trigger is refused with the reason. Only canonical graph workflows support it.\n")
19771
+ .addHelpText("after", "\nSafety: smoke_test and dry_run share one boundary: 0 credits, no paid provider calls, no external writes. Oxygen internal reads use current workspace data, and oxygen.http_json_request may make a real outbound GET. Every execution mode creates an inspectable Workflow run record; --preview creates none.\n\n--node scopes a test to one step. Oxygen plans the slice on the server from the exact saved version and records it on the run, so the run shows precisely which nodes were allowed to execute; you never submit a node list. Selecting the trigger, a disabled node, or a node unreachable from the trigger is refused with the reason. Only canonical graph workflows support it.\n")
19772
+ .addHelpText("after", workflowRunReadbackHelp)
19571
19773
  .action(async (workflowArg, options) => {
19572
19774
  const maxCredits = readPositiveNumber(options.maxCredits);
19573
19775
  const revisionVersion = readPositiveNumber(options.revision);
@@ -19581,15 +19783,18 @@ Run completion:
19581
19783
  if (!options.preview && !readOption(options.mode)) {
19582
19784
  throw new OxygenError("invalid_request", "--mode is required. Use smoke-test, dry-run, or live. To see what a live run would do without running it, use --preview — it runs nothing and needs no mode.", { exitCode: 2 });
19583
19785
  }
19786
+ const idempotencyKey = readOption(options.idempotencyKey);
19584
19787
  return prepareWorkflowCliOutput(await requestOxygen("/api/cli/workflows/call", {
19585
19788
  method: "POST",
19789
+ ...(options.preview ? { previewOnly: true } : {}),
19790
+ ...(idempotencyKey ? { idempotencyKey } : {}),
19586
19791
  body: {
19587
19792
  workflow: readOption(workflowArg) ?? readOption(options.workflow),
19588
19793
  ...(readOption(options.workflowId) ? { workflow_id: readOption(options.workflowId) } : {}),
19589
19794
  ...(readOption(options.workflowName) ? { workflow_name: readOption(options.workflowName) } : {}),
19590
19795
  input: options.inputJson ? parseJsonObject(options.inputJson) : {},
19591
19796
  ...(readOption(options.mode) ? { mode: readOption(options.mode) } : {}),
19592
- ...(readOption(options.idempotencyKey) ? { idempotency_key: readOption(options.idempotencyKey) } : {}),
19797
+ ...(idempotencyKey ? { idempotency_key: idempotencyKey } : {}),
19593
19798
  ...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
19594
19799
  ...(options.approved ? { approved: true } : {}),
19595
19800
  ...(revisionVersion !== undefined ? { revision_version: revisionVersion } : {}),
@@ -19601,7 +19806,7 @@ Run completion:
19601
19806
  });
19602
19807
  }))
19603
19808
  .addCommand(new Command("webhooks")
19604
- .description("Workflow webhook trigger utilities. A trigger's inbound URL and the deliveries a minute it accepts (`rate_limit`) are in `oxygen workflows get <workflow>`.")
19809
+ .description("Workflow webhook trigger utilities. A trigger's inbound URL and the deliveries a minute it accepts (`rate_limit`) are in `oxygen workflows get <workflow>`. Senders authenticate by sending the trigger's shared secret in the `x-oxygen-workflow-secret` header; a missing or wrong secret is recorded as a `rejected` delivery that starts no run and costs nothing.")
19605
19810
  .addCommand(new Command("rotate")
19606
19811
  .description("Replace a workflow webhook's shared secret. Previews by default; the old sender secret stops working immediately after approval.")
19607
19812
  .argument("<workflow>", "Webhook workflow id, slug, or name.")
@@ -19609,6 +19814,7 @@ Run completion:
19609
19814
  .option("--json", "Print a JSON envelope.")
19610
19815
  .action((workflow, options) => handleAsyncAction("workflows webhooks rotate", options, () => requestOxygen("/api/cli/workflows/webhooks/rotate", {
19611
19816
  method: "POST",
19817
+ previewOnly: !options.approved,
19612
19818
  body: {
19613
19819
  workflow,
19614
19820
  ...(options.approved ? { approved: true } : {}),
@@ -19627,9 +19833,9 @@ Run completion:
19627
19833
  },
19628
19834
  }))))
19629
19835
  .addCommand(new Command("deliveries")
19630
- .description("List inbound workflow webhook deliveries: what arrived, whether it verified, and the run it started. Replays never append here; only a verified outcome=ran delivery can be replayed.")
19836
+ .description("List inbound workflow webhook deliveries: what arrived, whether it verified, and the run it started. Stored headers never include `x-oxygen-workflow-secret`, so its absence does not show whether a rejected sender's secret was missing or wrong. Replays never append here; only a verified outcome=ran delivery can be replayed.")
19631
19837
  .argument("[trigger_id]", "Optional webhook trigger name, such as lead-created.")
19632
- .option("--workflow-id <id>", "Filter to deliveries that started a run of this workflow.")
19838
+ .option("--workflow-id <id>", "Filter to this workflow's deliveries, including rejected and blocked ones that started no run.")
19633
19839
  .option("--outcome <outcome>", "Filter by rejected (sender authentication failed), blocked (authenticated, no run), or ran.")
19634
19840
  .option("--limit <n>", "Maximum deliveries to return. Defaults to 50.")
19635
19841
  .option("--json", "Print a JSON envelope.")
@@ -19835,6 +20041,7 @@ Full trigger schema: oxygen workflows schema --subject trigger --json
19835
20041
  .action(async (runId, options) => {
19836
20042
  await handleAsyncAction("workflows run", options, async () => prepareWorkflowCliOutput(await requestOxygen("/api/cli/workflows/run", {
19837
20043
  method: "POST",
20044
+ readOnly: true,
19838
20045
  body: { run_id: runId },
19839
20046
  }), options));
19840
20047
  }))
@@ -19850,6 +20057,7 @@ Full trigger schema: oxygen workflows schema --subject trigger --json
19850
20057
  const maxCredits = readPositiveNumber(options.maxCredits);
19851
20058
  return prepareWorkflowCliOutput(await requestOxygen("/api/cli/workflows/retry", {
19852
20059
  method: "POST",
20060
+ previewOnly: !options.approved,
19853
20061
  body: {
19854
20062
  run_id: runId,
19855
20063
  ...(options.approved ? { approved: true } : {}),
@@ -19860,11 +20068,12 @@ Full trigger schema: oxygen workflows schema --subject trigger --json
19860
20068
  }))
19861
20069
  .addCommand(new Command("tail")
19862
20070
  .description("Poll until a workflow finishes or parks in waiting/awaiting_approval; clock waits resume automatically.")
19863
- .argument("<run_id>", "Workflow run UUID.")
20071
+ .argument("<run_id>", "Workflow run UUID returned as data.run.id by workflows call; distinct from data.run.runId.")
19864
20072
  .option("--timeout-seconds <n>", "Maximum time to wait. Defaults to 600.")
19865
20073
  .option("--interval-seconds <n>", "Polling interval. Defaults to 2.")
19866
20074
  .option("--include-bundle", "Include durable recipe bundles in JSON output.")
19867
20075
  .option("--json", "Print a JSON envelope.")
20076
+ .addHelpText("after", workflowRunReadbackHelp)
19868
20077
  .action(async (runId, options) => {
19869
20078
  await handleAsyncAction("workflows tail", options, async () => prepareWorkflowCliOutput(await waitForWorkflowRun(runId, options), options));
19870
20079
  }))
@@ -19877,6 +20086,7 @@ Full trigger schema: oxygen workflows schema --subject trigger --json
19877
20086
  .action(async (runId, options) => {
19878
20087
  await handleAsyncAction("workflows cancel", options, () => requestOxygen("/api/cli/workflows/cancel", {
19879
20088
  method: "POST",
20089
+ ...(options.preview ? { previewOnly: true } : {}),
19880
20090
  body: {
19881
20091
  run_id: runId,
19882
20092
  ...(options.preview ? { preview: true } : {}),
@@ -20016,6 +20226,7 @@ Full trigger schema: oxygen workflows schema --subject trigger --json
20016
20226
  const single = workflows.length === 1;
20017
20227
  const data = await requestOxygen("/api/cli/workflows/delete", {
20018
20228
  method: "POST",
20229
+ previewOnly: !options.yes,
20019
20230
  body: {
20020
20231
  ...(single ? { workflow: workflows[0] } : { workflows }),
20021
20232
  ...(options.yes ? { approved: true } : {}),
@@ -20171,6 +20382,7 @@ Full trigger schema: oxygen workflows schema --subject trigger --json
20171
20382
  registerFunctionsCommands(program, handleAsyncAction);
20172
20383
  registerUgcCommands(program, handleAsyncAction);
20173
20384
  registerKnowledgeRepositoryCommands(program, handleAsyncAction);
20385
+ registerAdminLeadMagnetCommands(program, handleAsyncAction);
20174
20386
  registerConnectorsCommands(program, handleAsyncAction);
20175
20387
  registerSettingsCommands(program, handleAsyncAction);
20176
20388
  registerCrmSupportFlags(program);
@@ -20748,6 +20960,7 @@ function waitForWorkflowRun(runId, options) {
20748
20960
  fetchRun: async () => {
20749
20961
  latestEnvelope = await requestOxygen("/api/cli/workflows/run", {
20750
20962
  method: "POST",
20963
+ readOnly: true,
20751
20964
  body: { run_id: runId },
20752
20965
  });
20753
20966
  return isRecord(latestEnvelope.run) ? latestEnvelope.run : latestEnvelope;
@@ -21056,8 +21269,17 @@ function waitForCopilotTurn(options) {
21056
21269
  isTerminal: isTerminalCopilotTurnStatus,
21057
21270
  shapeTerminal: () => latestData,
21058
21271
  timeoutCode: "copilot_send_timeout",
21059
- timeoutMessage: "Timed out waiting for the copilot turn to finish.",
21272
+ // Only the CLI stops waiting. The turn keeps running on the server and keeps
21273
+ // billing until it finishes or is interrupted, so the timeout says so and names
21274
+ // the commands that read and stop it.
21275
+ timeoutMessage: "Stopped waiting for the copilot turn. It is still running and keeps spending credits until it finishes or you interrupt it.",
21060
21276
  timeoutDetailIdKey: "turn_id",
21277
+ timeoutExtraDetails: () => ({
21278
+ session_id: options.sessionId,
21279
+ turn_still_running: true,
21280
+ next_step: `oxygen copilot get ${options.sessionId}`,
21281
+ interrupt_command: `oxygen copilot interrupt ${options.sessionId} ${options.turnId}`,
21282
+ }),
21061
21283
  });
21062
21284
  }
21063
21285
  function workflowTemplateActionBody(templateId, options) {
@@ -26639,11 +26861,23 @@ function formatCrmObjects(data) {
26639
26861
  // System attributes are bookkeeping, not fields anyone chose.
26640
26862
  const fieldCount = attributes.filter((attribute) => attribute.attributeKind !== "system").length;
26641
26863
  const primary = identities.find((identity) => identity.isPrimary === true) ?? identities[0];
26864
+ // A compact projection carries no attributes or identities (and an older
26865
+ // server no standardKey): print what it does carry, never "custom, 0
26866
+ // fields, can duplicate" for companies/people/deals.
26867
+ const compact = !Array.isArray(object.attributes);
26868
+ const kind = object.standardKey ? "standard" : object.standardKey === null || !compact ? "custom" : "—";
26869
+ let identity = describeCrmIdentity(primary, attributes);
26870
+ if (!Array.isArray(object.identities) && Object.hasOwn(object, "primaryIdentitySlug")) {
26871
+ identity = typeof object.primaryIdentitySlug === "string" ? object.primaryIdentitySlug : "nothing — can duplicate";
26872
+ }
26873
+ else if (compact && !Array.isArray(object.identities)) {
26874
+ identity = "—";
26875
+ }
26642
26876
  return [
26643
26877
  String(object.pluralName || object.displayName || object.slug || "—"),
26644
- object.standardKey ? "standard" : "custom",
26645
- String(fieldCount),
26646
- describeCrmIdentity(primary, attributes),
26878
+ kind,
26879
+ compact ? "—" : String(fieldCount),
26880
+ identity,
26647
26881
  String(object.slug ?? "—"),
26648
26882
  ];
26649
26883
  });
@@ -29412,8 +29646,10 @@ async function pushOneMirrorPage(dir, state, candidate) {
29412
29646
  slug,
29413
29647
  kind,
29414
29648
  status: "proposal_created",
29415
- ...(typeof details.proposalId === "string" ? { proposal_id: details.proposalId } : {}),
29416
- ...(typeof details.proposalSlug === "string" ? { proposal_slug: details.proposalSlug } : {}),
29649
+ // The API sends snake_case details; camelCase never matched, so push hid the proposal.
29650
+ ...(typeof details.proposal_id === "string" ? { proposal_id: details.proposal_id } : {}),
29651
+ ...(typeof details.proposal_slug === "string" ? { proposal_slug: details.proposal_slug } : {}),
29652
+ ...(typeof details.proposal_web_url === "string" ? { proposal_web_url: details.proposal_web_url } : {}),
29417
29653
  reason: typeof details.reason === "string" ? details.reason : "sensitive_page",
29418
29654
  };
29419
29655
  }