@oxygen-agent/cli 1.142.4 → 1.152.15

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.
package/dist/index.js CHANGED
@@ -45,6 +45,10 @@ const OXYGEN_WORDMARK = [
45
45
  const LARGE_IMPORT_BACKGROUND_ROW_THRESHOLD = 500;
46
46
  const TABLE_ACTION_RUN_WAIT_DEFAULT_TIMEOUT_SECONDS = 600;
47
47
  const TABLE_ACTION_RUN_WAIT_DEFAULT_INTERVAL_SECONDS = 5;
48
+ // Single-row paid runs are auto-backgrounded server-side; the CLI waits this
49
+ // long for the cell to finish before handing back the queued run envelope.
50
+ const SINGLE_ROW_COLUMN_RUN_WAIT_DEFAULT_TIMEOUT_SECONDS = 90;
51
+ const SINGLE_ROW_COLUMN_RUN_WAIT_DEFAULT_INTERVAL_SECONDS = 2;
48
52
  const TABLE_INGESTION_WAIT_DEFAULT_TIMEOUT_SECONDS = 600;
49
53
  const TABLE_INGESTION_WAIT_DEFAULT_INTERVAL_SECONDS = 5;
50
54
  const WORKFLOW_TAIL_DEFAULT_TIMEOUT_SECONDS = 600;
@@ -74,9 +78,28 @@ async function handleAsyncAction(command, options, action) {
74
78
  catch (error) {
75
79
  const failure = toFailure(command, error);
76
80
  writeJson(failure);
81
+ writeMaxCreditsHint(error);
77
82
  process.exitCode = error instanceof OxygenError ? error.exitCode : 1;
78
83
  }
79
84
  }
85
+ // Paid multi-row column runs require an explicit --max-credits spend cap; the
86
+ // server rejects them with max_credits_required plus a recommended cap. Surface
87
+ // that as a one-line stderr hint so users don't have to dig the value out of the
88
+ // JSON envelope (stderr keeps --json stdout machine-clean).
89
+ function writeMaxCreditsHint(error) {
90
+ if (!(error instanceof OxygenError) || error.code !== "max_credits_required")
91
+ return;
92
+ const recommended = readRecommendedMaxCredits(error.details);
93
+ if (recommended === null)
94
+ return;
95
+ process.stderr.write(`hint: re-run with --max-credits ${recommended} to approve the spend cap\n`);
96
+ }
97
+ function readRecommendedMaxCredits(details) {
98
+ if (!details || typeof details !== "object" || Array.isArray(details))
99
+ return null;
100
+ const value = details.recommended_max_credits;
101
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
102
+ }
80
103
  function parseJsonObject(value) {
81
104
  let parsed;
82
105
  try {
@@ -479,6 +502,32 @@ export function createProgram() {
479
502
  },
480
503
  });
481
504
  });
505
+ }))
506
+ .addCommand(new Command("backfill-upsert-indexes")
507
+ .description("Create missing upsert-key secondary indexes on customer tables. Staff only; defaults to dry-run.")
508
+ .option("--dry-run", "Report the index backfill plan without creating indexes. This is the default.")
509
+ .option("--apply", "Create the missing indexes (CONCURRENTLY). Requires --confirm.")
510
+ .option("--confirm", "Confirm --apply for index creation.")
511
+ .option("--org <id>", "Limit the backfill to a single organization id.")
512
+ .option("--limit <n>", "Maximum ready tenants to inspect in this batch. Defaults to 25; hard cap is 200.")
513
+ .option("--json", "Print a JSON envelope.")
514
+ .action(async (options) => {
515
+ await handleAsyncAction("db backfill-upsert-indexes", options, () => {
516
+ if (options.apply && !options.confirm) {
517
+ throw new OxygenError("confirmation_required", "Refusing to create upsert-key indexes without --confirm.", { exitCode: 1 });
518
+ }
519
+ const limit = readPositiveInt(options.limit);
520
+ return requestOxygen("/api/cli/db/backfill-upsert-indexes", {
521
+ method: "POST",
522
+ body: {
523
+ apply: Boolean(options.apply),
524
+ dry_run: !options.apply,
525
+ confirm: Boolean(options.confirm),
526
+ ...(options.org ? { organization_id: options.org } : {}),
527
+ ...(limit !== undefined ? { limit } : {}),
528
+ },
529
+ });
530
+ });
482
531
  }))
483
532
  .addCommand(new Command("cost-policy")
484
533
  .description("Show tenant database cost controls and reconciliation status.")
@@ -2017,28 +2066,31 @@ export function createProgram() {
2017
2066
  }));
2018
2067
  program
2019
2068
  .command("search")
2020
- .description("Agent-operable company search, web search, and scraping jobs.")
2069
+ .description("Agent-operable people, signal, web, scrape, and local-business search jobs. For company search use 'oxygen companies search'.")
2021
2070
  .addCommand(new Command("plan")
2022
2071
  .description("Plan an Oxygen search or scrape route before running provider jobs.")
2023
2072
  .requiredOption("--goal <file|text>", "Search/scrape goal text, or a local file path containing the goal.")
2024
- .option("--kind <kind>", "Route kind: company_search, people_search, signal_search, web_search, web_scrape, local_business_search, or source_specific_scrape.")
2073
+ .option("--kind <kind>", "Route kind: people_search, signal_search, web_search, web_scrape, local_business_search, or source_specific_scrape. For company search use 'oxygen companies search plan'.")
2025
2074
  .option("--target-count <n>", "Optional target row count.")
2026
2075
  .option("--geography <text>", "Optional geography hint.")
2027
2076
  .option("--known-urls <urls>", "Comma-separated known URLs for scrape routes.")
2028
2077
  .option("--provider-hints <providers>", "Comma-separated provider hints.")
2029
2078
  .option("--json", "Print a JSON envelope.")
2030
2079
  .action(async (options) => {
2031
- await handleAsyncAction("search plan", options, () => requestOxygen("/api/cli/search/plan", {
2032
- method: "POST",
2033
- body: {
2034
- goal: readFileIfPresent(options.goal),
2035
- ...(readOption(options.kind) ? { kind: readOption(options.kind) } : {}),
2036
- ...(readPositiveInt(options.targetCount) ? { target_count: readPositiveInt(options.targetCount) } : {}),
2037
- ...(readOption(options.geography) ? { geography: readOption(options.geography) } : {}),
2038
- ...(readOption(options.knownUrls) ? { known_urls: readCsvOption(options.knownUrls) } : {}),
2039
- ...(readOption(options.providerHints) ? { provider_hints: readCsvOption(options.providerHints) } : {}),
2040
- },
2041
- }));
2080
+ await handleAsyncAction("search plan", options, () => {
2081
+ assertNotCompanySearchKind(options.kind);
2082
+ return requestOxygen("/api/cli/search/plan", {
2083
+ method: "POST",
2084
+ body: {
2085
+ goal: readFileIfPresent(options.goal),
2086
+ ...(readOption(options.kind) ? { kind: readOption(options.kind) } : {}),
2087
+ ...(readPositiveInt(options.targetCount) ? { target_count: readPositiveInt(options.targetCount) } : {}),
2088
+ ...(readOption(options.geography) ? { geography: readOption(options.geography) } : {}),
2089
+ ...(readOption(options.knownUrls) ? { known_urls: readCsvOption(options.knownUrls) } : {}),
2090
+ ...(readOption(options.providerHints) ? { provider_hints: readCsvOption(options.providerHints) } : {}),
2091
+ },
2092
+ });
2093
+ });
2042
2094
  }))
2043
2095
  .addCommand(new Command("run")
2044
2096
  .description("Preview or enqueue a durable table-backed search/scrape job from a search plan.")
@@ -2142,6 +2194,19 @@ export function createProgram() {
2142
2194
  .requiredOption("--prompt <text-or-file>", "Company-search prompt, or a path to a prompt file.")
2143
2195
  .option("--target-count <n>", "Desired company count for routing and estimates.")
2144
2196
  .option("--source-intent <intent>", "Override detected intent: sizing, structured, technology, hiring, local, known_source, concept, web, url, or fallback.")
2197
+ .option("--filters-json <json-or-file>", "CompanySearchFilters JSON inline or a @file/path; wins over individual flags per top-level filter path.")
2198
+ .option("--industries <csv>", "Comma-separated industries to include.")
2199
+ .option("--exclude-industries <csv>", "Comma-separated industries to exclude.")
2200
+ .option("--keywords <csv>", "Comma-separated keywords to include.")
2201
+ .option("--exclude-keywords <csv>", "Comma-separated keywords to exclude.")
2202
+ .option("--countries <csv>", "Comma-separated ISO 3166-1 alpha-2 country codes.")
2203
+ .option("--employees <range>", "Employee count range: 20-200, 500+, or -50.")
2204
+ .option("--funding-stages <csv>", "Comma-separated funding stages: pre_seed, seed, series_a, ...")
2205
+ .option("--technologies <csv>", "Comma-separated technologies to include.")
2206
+ .option("--revenue <range>", "Annual revenue (USD) range: 1000000-20000000, 1000000+, or -5000000.")
2207
+ .option("--founded <range>", "Founded year range: 2015-2024, 2020+, or -2010.")
2208
+ .option("--lookalike <csv>", "Comma-separated lookalike company domains.")
2209
+ .option("--estimate", "Run a free count probe for an estimated match count.")
2145
2210
  .option("--materialize-preview", "Create a preview table with route rows.")
2146
2211
  .option("--json", "Print a JSON envelope.")
2147
2212
  .action(async (options) => {
@@ -2163,6 +2228,19 @@ export function createProgram() {
2163
2228
  .option("--max-credits <n>", "Required credit ceiling for live runs.")
2164
2229
  .option("--target-count <n>", "Desired company count when planning from --prompt.")
2165
2230
  .option("--source-intent <intent>", "Override detected intent when planning from --prompt.")
2231
+ .option("--filters-json <json-or-file>", "CompanySearchFilters JSON inline or a @file/path when planning from --prompt; wins over individual flags per top-level filter path.")
2232
+ .option("--industries <csv>", "Comma-separated industries to include when planning from --prompt.")
2233
+ .option("--exclude-industries <csv>", "Comma-separated industries to exclude when planning from --prompt.")
2234
+ .option("--keywords <csv>", "Comma-separated keywords to include when planning from --prompt.")
2235
+ .option("--exclude-keywords <csv>", "Comma-separated keywords to exclude when planning from --prompt.")
2236
+ .option("--countries <csv>", "Comma-separated ISO 3166-1 alpha-2 country codes when planning from --prompt.")
2237
+ .option("--employees <range>", "Employee count range when planning from --prompt: 20-200, 500+, or -50.")
2238
+ .option("--funding-stages <csv>", "Comma-separated funding stages when planning from --prompt.")
2239
+ .option("--technologies <csv>", "Comma-separated technologies to include when planning from --prompt.")
2240
+ .option("--revenue <range>", "Annual revenue (USD) range when planning from --prompt.")
2241
+ .option("--founded <range>", "Founded year range when planning from --prompt.")
2242
+ .option("--lookalike <csv>", "Comma-separated lookalike company domains when planning from --prompt.")
2243
+ .option("--estimate", "Run a free count probe when planning from --prompt.")
2166
2244
  .option("--approved", "Required for live runs after inspecting dry-run output.")
2167
2245
  .option("--json", "Print a JSON envelope.")
2168
2246
  .action(async (options) => {
@@ -2416,7 +2494,11 @@ export function createProgram() {
2416
2494
  .description("Redacted operation event commands for the current organization.")
2417
2495
  .addCommand(new Command("events")
2418
2496
  .description("List recent redacted operation events and failures.")
2419
- .option("--status <status>", "Filter by completed, queued, completed_with_errors, or failed.")
2497
+ // Keep in sync with OBSERVABILITY_STATUS_FILTERS in
2498
+ // apps/web/src/lib/observability.ts and the MCP tool enum in
2499
+ // packages/mcp-server/src/tools/observability.ts. The API rejects any
2500
+ // other value with invalid_request so a typo fails loudly.
2501
+ .option("--status <status>", "Filter by success, error, completed, failed, completed_with_errors, queued, skipped, or blocked.")
2420
2502
  .option("--trace-id <trace_id>", "Filter by trace id.")
2421
2503
  .option("--run-id <run_id>", "Filter by workspace run id.")
2422
2504
  .option("--limit <n>", "Maximum events to return. Defaults to 50.")
@@ -3010,6 +3092,348 @@ export function createProgram() {
3010
3092
  });
3011
3093
  });
3012
3094
  }));
3095
+ program
3096
+ .command("linkedin")
3097
+ .description("LinkedIn sender account management for the native LinkedIn sequencer (accounts, limits, usage).")
3098
+ .addCommand(new Command("accounts")
3099
+ .description("Manage the org's connected LinkedIn sender accounts: list, connect, sync, inspect, disconnect, and tune rate limits.")
3100
+ .addCommand(new Command("list")
3101
+ .description("List connected LinkedIn sender accounts with health status, rate limits, and today's usage.")
3102
+ .option("--status <status>", "Filter by sender status: active, paused, disconnected, restricted, or credentials_required.")
3103
+ .option("--no-usage", "Skip today's per-account usage counts for a faster, lighter response.")
3104
+ .option("--json", "Print a JSON envelope.")
3105
+ .action(async (options) => {
3106
+ await handleAsyncAction("linkedin accounts list", options, () => {
3107
+ const params = new URLSearchParams();
3108
+ if (options.usage !== false)
3109
+ params.set("include_usage", "true");
3110
+ const status = readOption(options.status);
3111
+ if (status)
3112
+ params.set("status", status);
3113
+ const suffix = params.toString();
3114
+ return requestOxygen(`/api/cli/linkedin/accounts${suffix ? `?${suffix}` : ""}`);
3115
+ });
3116
+ }))
3117
+ .addCommand(new Command("connect")
3118
+ .description("Get a Unipile hosted-auth URL to connect a new LinkedIn account (or reconnect with --reconnect). Open the URL in a browser to complete authentication.")
3119
+ .option("--reconnect <connection_id>", "Reconnect an existing connection instead of creating a new one. Accepts a connection id.")
3120
+ .option("--json", "Print a JSON envelope.")
3121
+ .action(async (options) => {
3122
+ await handleAsyncAction("linkedin accounts connect", options, () => {
3123
+ const reconnect = readOption(options.reconnect);
3124
+ return requestOxygen("/api/cli/linkedin/accounts/connect", {
3125
+ method: "POST",
3126
+ body: {
3127
+ ...(reconnect ? { reconnect_connection_id: reconnect } : {}),
3128
+ },
3129
+ });
3130
+ });
3131
+ }))
3132
+ .addCommand(new Command("get")
3133
+ .description("Get one LinkedIn sender account with status, limits, working hours, and usage. <id> accepts a sender account id, connection id, or Unipile account id.")
3134
+ .argument("<id>", "Sender account id, connection id, or Unipile account id.")
3135
+ .option("--json", "Print a JSON envelope.")
3136
+ .action(async (id, options) => {
3137
+ await handleAsyncAction("linkedin accounts get", options, async () => requestOxygen(`/api/cli/linkedin/accounts/${encodeURIComponent(id)}`));
3138
+ }))
3139
+ .addCommand(new Command("sync")
3140
+ .description("Refresh LinkedIn account state (status, profile) from Unipile. Pass --connection-id to sync one account, or omit it to sync all.")
3141
+ .option("--connection-id <id>", "Sync a specific connection id. Defaults to syncing all connected accounts.")
3142
+ .option("--json", "Print a JSON envelope.")
3143
+ .action(async (options) => {
3144
+ await handleAsyncAction("linkedin accounts sync", options, () => {
3145
+ const connectionId = readOption(options.connectionId);
3146
+ return requestOxygen("/api/cli/linkedin/accounts/sync", {
3147
+ method: "POST",
3148
+ body: {
3149
+ ...(connectionId ? { connection_id: connectionId } : {}),
3150
+ },
3151
+ });
3152
+ });
3153
+ }))
3154
+ .addCommand(new Command("disconnect")
3155
+ .description("Disconnect a LinkedIn sender account so it stops sending. <id> accepts a sender account id, connection id, or Unipile account id.")
3156
+ .argument("<id>", "Sender account id, connection id, or Unipile account id.")
3157
+ .option("--json", "Print a JSON envelope.")
3158
+ .action(async (id, options) => {
3159
+ await handleAsyncAction("linkedin accounts disconnect", options, async () => requestOxygen(`/api/cli/linkedin/accounts/${encodeURIComponent(id)}/disconnect`, {
3160
+ method: "POST",
3161
+ }));
3162
+ }))
3163
+ .addCommand(new Command("limits")
3164
+ .description("View and adjust per-account daily action limits and working hours.")
3165
+ .option("--json", "Print a JSON envelope.")
3166
+ .addCommand(new Command("get")
3167
+ .description("Show current limits, overrides, working hours, defaults, and safe maximums for an account. <id> accepts a sender account id, connection id, or Unipile account id.")
3168
+ .argument("<id>", "Sender account id, connection id, or Unipile account id.")
3169
+ .option("--json", "Print a JSON envelope.")
3170
+ .action(async (id, options) => {
3171
+ await handleAsyncAction("linkedin accounts limits get", options, async () => requestOxygen(`/api/cli/linkedin/accounts/${encodeURIComponent(id)}/limits`));
3172
+ }))
3173
+ .addCommand(new Command("set")
3174
+ .description("Adjust per-account daily action limits and working hours. Values are clamped to safe maximums (e.g. max 80 invites/day). <id> accepts a sender account id, connection id, or Unipile account id.")
3175
+ .argument("<id>", "Sender account id, connection id, or Unipile account id.")
3176
+ .option("--invites-per-day <n>", "Daily LinkedIn connection invites cap.")
3177
+ .option("--invites-per-week <n>", "Weekly LinkedIn connection invites cap.")
3178
+ .option("--messages-per-day <n>", "Daily direct messages cap.")
3179
+ .option("--inmails-per-day <n>", "Daily InMail cap.")
3180
+ .option("--profile-views-per-day <n>", "Daily profile views cap.")
3181
+ .option("--follows-per-day <n>", "Daily follows cap.")
3182
+ .option("--likes-per-day <n>", "Daily likes cap.")
3183
+ .option("--total-actions-per-day <n>", "Daily cap across all send/action types.")
3184
+ .option("--relations-reads-per-day <n>", "Daily cap on relations/connections list reads (scrape protection).")
3185
+ .option("--messages-reads-per-day <n>", "Daily cap on chat and message-history reads.")
3186
+ .option("--searches-per-day <n>", "Daily cap on LinkedIn search executions.")
3187
+ .option("--api-reads-per-day <n>", "Daily cap on all other LinkedIn API reads.")
3188
+ .option("--total-reads-per-day <n>", "Daily cap across all read types.")
3189
+ .option("--min-spacing-seconds <n>", "Minimum seconds between actions.")
3190
+ .option("--spacing-jitter-seconds <n>", "Random jitter seconds added to action spacing.")
3191
+ .option("--timezone <tz>", "IANA timezone for working hours, e.g. America/New_York.")
3192
+ .option("--working-days <days>", "Comma-separated ISO weekdays the account sends, e.g. 1,2,3,4,5 (1=Mon..7=Sun).")
3193
+ .option("--working-start <HH:MM>", "Working hours start time, e.g. 09:00.")
3194
+ .option("--working-end <HH:MM>", "Working hours end time, e.g. 17:00.")
3195
+ .option("--json", "Print a JSON envelope.")
3196
+ .action(async (id, options) => {
3197
+ await handleAsyncAction("linkedin accounts limits set", options, () => {
3198
+ const body = buildLinkedinLimitsBody(options);
3199
+ return requestOxygen(`/api/cli/linkedin/accounts/${encodeURIComponent(id)}/limits`, {
3200
+ method: "PATCH",
3201
+ body,
3202
+ });
3203
+ });
3204
+ }))))
3205
+ .addCommand(new Command("inbox")
3206
+ .description("LinkedIn unified inbox (unibox): scan conversations across all sender accounts, read threads, and reply.")
3207
+ .addCommand(new Command("list")
3208
+ .description("List LinkedIn conversations across all connected accounts, newest first.")
3209
+ .option("--account <id>", "Filter to one sender account (sender id, connection id, or Unipile account id).")
3210
+ .option("--unread", "Only show conversations with unread messages.")
3211
+ .option("--search <text>", "Filter by attendee name or last-message text.")
3212
+ .option("--include-archived", "Include archived conversations.")
3213
+ .option("--limit <n>", "Maximum conversations to return (1-200). Defaults to 50.")
3214
+ .option("--json", "Print a JSON envelope.")
3215
+ .action(async (options) => {
3216
+ await handleAsyncAction("linkedin inbox list", options, () => {
3217
+ const params = new URLSearchParams();
3218
+ const account = readOption(options.account);
3219
+ if (account)
3220
+ params.set("account", account);
3221
+ if (options.unread)
3222
+ params.set("unread", "true");
3223
+ const search = readOption(options.search);
3224
+ if (search)
3225
+ params.set("search", search);
3226
+ if (options.includeArchived)
3227
+ params.set("include_archived", "true");
3228
+ const limit = readOption(options.limit);
3229
+ if (limit)
3230
+ params.set("limit", limit);
3231
+ const suffix = params.toString();
3232
+ return requestOxygen(`/api/cli/linkedin/inbox${suffix ? `?${suffix}` : ""}`);
3233
+ });
3234
+ }))
3235
+ .addCommand(new Command("get")
3236
+ .description("Get one conversation with its full message thread. <conversation> accepts a conversation id or Unipile chat id.")
3237
+ .argument("<conversation>", "Conversation id or Unipile chat id.")
3238
+ .option("--message-limit <n>", "Maximum messages to return (1-500). Defaults to 100.")
3239
+ .option("--json", "Print a JSON envelope.")
3240
+ .action(async (conversation, options) => {
3241
+ await handleAsyncAction("linkedin inbox get", options, () => {
3242
+ const params = new URLSearchParams();
3243
+ const messageLimit = readOption(options.messageLimit);
3244
+ if (messageLimit)
3245
+ params.set("message_limit", messageLimit);
3246
+ const suffix = params.toString();
3247
+ return requestOxygen(`/api/cli/linkedin/inbox/${encodeURIComponent(conversation)}${suffix ? `?${suffix}` : ""}`);
3248
+ });
3249
+ }))
3250
+ .addCommand(new Command("send")
3251
+ .description("Reply into a LinkedIn conversation. Sends a real LinkedIn message — requires --approved. Without it, returns a preview.")
3252
+ .argument("<conversation>", "Conversation id or Unipile chat id.")
3253
+ .requiredOption("--text <message>", "Reply text to send.")
3254
+ .option("--approved", "Approve and send the message. Without this flag, returns a preview only.")
3255
+ .option("--json", "Print a JSON envelope.")
3256
+ .action(async (conversation, options) => {
3257
+ await handleAsyncAction("linkedin inbox send", options, () => requestOxygen(`/api/cli/linkedin/inbox/${encodeURIComponent(conversation)}/send`, {
3258
+ method: "POST",
3259
+ body: {
3260
+ text: readOption(options.text),
3261
+ ...(options.approved ? { approved: true } : {}),
3262
+ },
3263
+ }));
3264
+ }))
3265
+ .addCommand(new Command("mark-read")
3266
+ .description("Mark a conversation and all its messages as read.")
3267
+ .argument("<conversation>", "Conversation id or Unipile chat id.")
3268
+ .option("--json", "Print a JSON envelope.")
3269
+ .action(async (conversation, options) => {
3270
+ await handleAsyncAction("linkedin inbox mark-read", options, () => requestOxygen(`/api/cli/linkedin/inbox/${encodeURIComponent(conversation)}/read`, {
3271
+ method: "POST",
3272
+ }));
3273
+ }))
3274
+ .addCommand(new Command("sync")
3275
+ .description("Force a backstop inbox sync from Unipile for all active sender accounts (pulls recent chats + messages into the unibox).")
3276
+ .option("--chat-limit <n>", "Maximum chats to sync per account. Defaults to 30.")
3277
+ .option("--message-limit <n>", "Maximum messages to sync per chat. Defaults to 20.")
3278
+ .option("--json", "Print a JSON envelope.")
3279
+ .action(async (options) => {
3280
+ await handleAsyncAction("linkedin inbox sync", options, () => {
3281
+ const body = {};
3282
+ const chatLimit = readOption(options.chatLimit);
3283
+ if (chatLimit)
3284
+ body.chat_limit = Number(chatLimit);
3285
+ const messageLimit = readOption(options.messageLimit);
3286
+ if (messageLimit)
3287
+ body.message_limit = Number(messageLimit);
3288
+ return requestOxygen("/api/cli/linkedin/inbox/sync", { method: "POST", body });
3289
+ });
3290
+ })))
3291
+ .addCommand(new Command("sequences")
3292
+ .description("LinkedIn outreach sequences: multi-step campaigns over a lead table dispatched across sender accounts with rate limits and reply-stop.")
3293
+ .addCommand(new Command("list")
3294
+ .description("List LinkedIn sequences with status and credit usage.")
3295
+ .option("--status <status>", "Filter by status: draft, active, paused, or archived.")
3296
+ .option("--json", "Print a JSON envelope.")
3297
+ .action(async (options) => {
3298
+ await handleAsyncAction("linkedin sequences list", options, () => {
3299
+ const params = new URLSearchParams();
3300
+ const status = readOption(options.status);
3301
+ if (status)
3302
+ params.set("status", status);
3303
+ const suffix = params.toString();
3304
+ return requestOxygen(`/api/cli/linkedin/sequences${suffix ? `?${suffix}` : ""}`);
3305
+ });
3306
+ }))
3307
+ .addCommand(new Command("create")
3308
+ .description("Create a draft sequence from a steps JSON file. Assign sender accounts with --senders.")
3309
+ .requiredOption("--name <name>", "Human-readable sequence name.")
3310
+ .requiredOption("--slug <slug>", "Unique slug for the sequence.")
3311
+ .requiredOption("--steps-file <path>", "Path to a JSON file: { \"steps\": [...] }.")
3312
+ .requiredOption("--senders <ids>", "Comma-separated sender account ids (or connection / Unipile ids).")
3313
+ .option("--table <id>", "Source table id whose rows supply {{column}} template values.")
3314
+ .option("--url-column <key>", "Column key holding each lead's LinkedIn URL/provider id.")
3315
+ .option("--max-credits <n>", "Credit cap for the sequence (also set when starting).")
3316
+ .option("--json", "Print a JSON envelope.")
3317
+ .action(async (options) => {
3318
+ await handleAsyncAction("linkedin sequences create", options, () => {
3319
+ const stepsPath = readOption(options.stepsFile);
3320
+ if (!stepsPath)
3321
+ throw new Error("--steps-file is required.");
3322
+ const raw = readFileSync(resolve(stepsPath), "utf8");
3323
+ const definition = JSON.parse(raw);
3324
+ const senders = (readOption(options.senders) ?? "").split(",").map((s) => s.trim()).filter(Boolean);
3325
+ const maxCredits = readPositiveNumber(options.maxCredits);
3326
+ return requestOxygen("/api/cli/linkedin/sequences", {
3327
+ method: "POST",
3328
+ body: {
3329
+ name: readOption(options.name),
3330
+ slug: readOption(options.slug),
3331
+ definition,
3332
+ senders,
3333
+ ...(readOption(options.table) ? { source_table_id: readOption(options.table) } : {}),
3334
+ ...(readOption(options.urlColumn) ? { linkedin_url_column_key: readOption(options.urlColumn) } : {}),
3335
+ ...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
3336
+ },
3337
+ });
3338
+ });
3339
+ }))
3340
+ .addCommand(new Command("get")
3341
+ .description("Get a sequence's definition, senders, status, and credit usage.")
3342
+ .argument("<sequence>", "Sequence id or slug.")
3343
+ .option("--json", "Print a JSON envelope.")
3344
+ .action(async (sequence, options) => {
3345
+ await handleAsyncAction("linkedin sequences get", options, () => requestOxygen(`/api/cli/linkedin/sequences/${encodeURIComponent(sequence)}`));
3346
+ }))
3347
+ .addCommand(new Command("enroll")
3348
+ .description("Enroll leads into a sequence from a JSON file of { leads: [...] }. Idempotent per table row.")
3349
+ .argument("<sequence>", "Sequence id or slug.")
3350
+ .requiredOption("--leads-file <path>", "Path to a JSON file: { \"leads\": [{ lead_provider_id, lead_name, row_values }] }.")
3351
+ .option("--json", "Print a JSON envelope.")
3352
+ .action(async (sequence, options) => {
3353
+ await handleAsyncAction("linkedin sequences enroll", options, () => {
3354
+ const leadsPath = readOption(options.leadsFile);
3355
+ if (!leadsPath)
3356
+ throw new Error("--leads-file is required.");
3357
+ const parsed = JSON.parse(readFileSync(resolve(leadsPath), "utf8"));
3358
+ return requestOxygen(`/api/cli/linkedin/sequences/${encodeURIComponent(sequence)}/enroll`, {
3359
+ method: "POST",
3360
+ body: { leads: parsed.leads ?? [] },
3361
+ });
3362
+ });
3363
+ }))
3364
+ .addCommand(new Command("start")
3365
+ .description("Start a sequence. Dispatches REAL LinkedIn actions — requires --approved and --max-credits. Without them, returns a preview. Use --dry-run to activate in simulated mode (no sends, no credits).")
3366
+ .argument("<sequence>", "Sequence id or slug.")
3367
+ .option("--approved", "Approve and activate live. Without this flag, returns a preview only.")
3368
+ .option("--max-credits <n>", "Credit cap (required with --approved).")
3369
+ .option("--dry-run", "Activate in dry-run mode: advance every step with simulated sends, no LinkedIn actions, no credits.")
3370
+ .option("--json", "Print a JSON envelope.")
3371
+ .action(async (sequence, options) => {
3372
+ await handleAsyncAction("linkedin sequences start", options, () => {
3373
+ const maxCredits = readPositiveNumber(options.maxCredits);
3374
+ return requestOxygen(`/api/cli/linkedin/sequences/${encodeURIComponent(sequence)}/start`, {
3375
+ method: "POST",
3376
+ body: {
3377
+ ...(options.dryRun ? { dry_run: true } : {}),
3378
+ ...(options.approved ? { approved: true } : {}),
3379
+ ...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
3380
+ },
3381
+ });
3382
+ });
3383
+ }))
3384
+ .addCommand(new Command("pause")
3385
+ .description("Pause an active sequence (stops new dispatches; enrollments resume on un-pause).")
3386
+ .argument("<sequence>", "Sequence id or slug.")
3387
+ .option("--json", "Print a JSON envelope.")
3388
+ .action(async (sequence, options) => {
3389
+ await handleAsyncAction("linkedin sequences pause", options, () => requestOxygen(`/api/cli/linkedin/sequences/${encodeURIComponent(sequence)}/status`, {
3390
+ method: "POST", body: { status: "paused" },
3391
+ }));
3392
+ }))
3393
+ .addCommand(new Command("resume")
3394
+ .description("Resume a paused sequence.")
3395
+ .argument("<sequence>", "Sequence id or slug.")
3396
+ .option("--json", "Print a JSON envelope.")
3397
+ .action(async (sequence, options) => {
3398
+ await handleAsyncAction("linkedin sequences resume", options, () => requestOxygen(`/api/cli/linkedin/sequences/${encodeURIComponent(sequence)}/status`, {
3399
+ method: "POST", body: { status: "active" },
3400
+ }));
3401
+ }))
3402
+ .addCommand(new Command("archive")
3403
+ .description("Archive a sequence (terminal; cannot be reactivated).")
3404
+ .argument("<sequence>", "Sequence id or slug.")
3405
+ .option("--json", "Print a JSON envelope.")
3406
+ .action(async (sequence, options) => {
3407
+ await handleAsyncAction("linkedin sequences archive", options, () => requestOxygen(`/api/cli/linkedin/sequences/${encodeURIComponent(sequence)}/status`, {
3408
+ method: "POST", body: { status: "archived" },
3409
+ }));
3410
+ }))
3411
+ .addCommand(new Command("enrollments")
3412
+ .description("List a sequence's per-lead enrollments and their state.")
3413
+ .argument("<sequence>", "Sequence id or slug.")
3414
+ .option("--status <status>", "Filter by enrollment status (active, waiting_connection, replied, completed, stopped, failed).")
3415
+ .option("--limit <n>", "Maximum enrollments to return (1-500).")
3416
+ .option("--json", "Print a JSON envelope.")
3417
+ .action(async (sequence, options) => {
3418
+ await handleAsyncAction("linkedin sequences enrollments", options, () => {
3419
+ const params = new URLSearchParams();
3420
+ const status = readOption(options.status);
3421
+ if (status)
3422
+ params.set("status", status);
3423
+ const limit = readOption(options.limit);
3424
+ if (limit)
3425
+ params.set("limit", limit);
3426
+ const suffix = params.toString();
3427
+ return requestOxygen(`/api/cli/linkedin/sequences/${encodeURIComponent(sequence)}/enrollments${suffix ? `?${suffix}` : ""}`);
3428
+ });
3429
+ }))
3430
+ .addCommand(new Command("stats")
3431
+ .description("Show the sequence funnel: enrolled, invites sent, connected, replied, with acceptance and reply rates.")
3432
+ .argument("<sequence>", "Sequence id or slug.")
3433
+ .option("--json", "Print a JSON envelope.")
3434
+ .action(async (sequence, options) => {
3435
+ await handleAsyncAction("linkedin sequences stats", options, () => requestOxygen(`/api/cli/linkedin/sequences/${encodeURIComponent(sequence)}/stats`));
3436
+ })));
3013
3437
  program
3014
3438
  .command("workflows")
3015
3439
  .description("Durable workflow automation commands.")
@@ -3849,21 +4273,94 @@ function tableRunsListPath(options) {
3849
4273
  }
3850
4274
  async function requestColumnsRun(body, table, options) {
3851
4275
  const traceId = randomUUID();
4276
+ let result;
3852
4277
  try {
3853
- return await requestOxygen("/api/cli/tables/columns/run", {
4278
+ result = await requestOxygen("/api/cli/tables/columns/run", {
3854
4279
  method: "POST",
3855
4280
  body,
3856
4281
  traceId,
3857
4282
  });
3858
4283
  }
3859
4284
  catch (error) {
3860
- if (!options.background || !isNetworkTimeoutError(error))
4285
+ // Paid runs are durable background runs server-side (even single-row since
4286
+ // v1.144.0), so a network timeout is always recoverable by locating the
4287
+ // created run via its trace id - not only when --background was passed.
4288
+ // Inline (formula) runs have no run to recover; recovery returns null and
4289
+ // the original error propagates.
4290
+ if (!isNetworkTimeoutError(error))
3861
4291
  throw error;
3862
4292
  const recovered = await recoverBackgroundColumnRun(table, traceId);
3863
4293
  if (!recovered)
3864
4294
  throw error;
3865
4295
  return recovered;
3866
4296
  }
4297
+ // "Run this cell" still resolves to the finished value in one command: when
4298
+ // the server auto-backgrounds a single-row paid run (caller did not pass
4299
+ // --background), wait for the created run and attach its item output.
4300
+ // Inline (formula) results carry no action_run_id and pass through as-is.
4301
+ if (!options.background && typeof body.row_id === "string" && isRecord(result)) {
4302
+ const actionRunId = readRecordString(result, "action_run_id");
4303
+ if (actionRunId)
4304
+ return resolveSingleRowColumnRun(result, actionRunId);
4305
+ }
4306
+ return result;
4307
+ }
4308
+ /**
4309
+ * Wait for an auto-backgrounded single-row column run to finish and return the
4310
+ * terminal run merged with its item output (the cell value). On wait timeout
4311
+ * the queued run envelope is returned unchanged, plus the follow-up command -
4312
+ * the run keeps executing server-side either way.
4313
+ */
4314
+ async function resolveSingleRowColumnRun(envelope, actionRunId) {
4315
+ const timeoutSeconds = readEnvPositiveInt("OXYGEN_COLUMN_RUN_WAIT_TIMEOUT_SECONDS")
4316
+ ?? SINGLE_ROW_COLUMN_RUN_WAIT_DEFAULT_TIMEOUT_SECONDS;
4317
+ const intervalSeconds = readEnvPositiveInt("OXYGEN_COLUMN_RUN_WAIT_INTERVAL_SECONDS")
4318
+ ?? SINGLE_ROW_COLUMN_RUN_WAIT_DEFAULT_INTERVAL_SECONDS;
4319
+ let waited;
4320
+ try {
4321
+ waited = await waitForTableActionRun(actionRunId, {
4322
+ timeoutSeconds: String(timeoutSeconds),
4323
+ intervalSeconds: String(intervalSeconds),
4324
+ });
4325
+ }
4326
+ catch (error) {
4327
+ if (error instanceof OxygenError && error.code === "table_action_run_wait_timeout") {
4328
+ return {
4329
+ ...envelope,
4330
+ auto_wait_timed_out: true,
4331
+ next_step: `oxygen table-runs wait ${actionRunId}`,
4332
+ };
4333
+ }
4334
+ throw error;
4335
+ }
4336
+ const finalRun = isRecord(waited.actionRun) ? waited.actionRun : envelope;
4337
+ const items = await listColumnRunItems(actionRunId);
4338
+ return {
4339
+ ...finalRun,
4340
+ ...(items ? { items } : {}),
4341
+ auto_waited: {
4342
+ polls: waited.polls,
4343
+ elapsed_ms: waited.elapsedMs,
4344
+ },
4345
+ };
4346
+ }
4347
+ async function listColumnRunItems(actionRunId) {
4348
+ try {
4349
+ const response = await requestOxygen(`/api/cli/table-action-runs/${encodeURIComponent(actionRunId)}/items?limit=5`);
4350
+ const items = Array.isArray(response.items) ? response.items.filter(isRecord) : [];
4351
+ return items.length > 0 ? items : null;
4352
+ }
4353
+ catch {
4354
+ // The terminal run state is the answer; item output is best-effort.
4355
+ return null;
4356
+ }
4357
+ }
4358
+ function readEnvPositiveInt(name) {
4359
+ const value = process.env[name]?.trim();
4360
+ if (!value)
4361
+ return undefined;
4362
+ const parsed = Number(value);
4363
+ return Number.isInteger(parsed) && parsed >= 1 ? parsed : undefined;
3867
4364
  }
3868
4365
  async function recoverBackgroundColumnRun(table, traceId) {
3869
4366
  for (let attempt = 0; attempt < 3; attempt += 1) {
@@ -3897,10 +4394,13 @@ function isNetworkTimeoutError(error) {
3897
4394
  }
3898
4395
  function readCompaniesSearchPlanBody(options) {
3899
4396
  const targetCount = readPositiveInt(options.targetCount);
4397
+ const filters = readCompanySearchFilters(options);
3900
4398
  return {
3901
4399
  prompt: readFileIfPresent(options.prompt),
3902
4400
  ...(targetCount !== undefined ? { target_count: targetCount } : {}),
3903
4401
  ...(options.sourceIntent ? { source_intent: options.sourceIntent } : {}),
4402
+ ...(filters ? { filters } : {}),
4403
+ ...(options.estimate ? { estimate: true } : {}),
3904
4404
  ...(options.materializePreview ? { materialize_preview: true } : {}),
3905
4405
  };
3906
4406
  }
@@ -3913,6 +4413,7 @@ function readCompaniesSearchRunBody(options) {
3913
4413
  const maxPages = readPositiveInt(options.maxPages);
3914
4414
  const maxCredits = readPositiveNumber(options.maxCredits);
3915
4415
  const targetCount = readPositiveInt(options.targetCount);
4416
+ const filters = prompt ? readCompanySearchFilters(options) : null;
3916
4417
  return {
3917
4418
  ...(prompt ? { prompt } : {}),
3918
4419
  ...(plan ? { plan } : {}),
@@ -3925,9 +4426,121 @@ function readCompaniesSearchRunBody(options) {
3925
4426
  ...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
3926
4427
  ...(targetCount !== undefined ? { target_count: targetCount } : {}),
3927
4428
  ...(options.sourceIntent ? { source_intent: options.sourceIntent } : {}),
4429
+ ...(filters ? { filters } : {}),
4430
+ ...(prompt && options.estimate ? { estimate: true } : {}),
3928
4431
  ...(options.approved ? { approved: true } : {}),
3929
4432
  };
3930
4433
  }
4434
+ // CompanySearchFilters mirror (structural — the server validates the shape). Builds a
4435
+ // filters object from individual --industries/--countries/--employees/etc. flags, then
4436
+ // lets --filters-json win per top-level filter path so an agent can pass a precise object
4437
+ // while still using convenience flags for the rest.
4438
+ function readCompanySearchFilters(options) {
4439
+ const filters = {};
4440
+ const industries = readIncludeExclude(options.industries, options.excludeIndustries);
4441
+ if (industries)
4442
+ filters.industries = industries;
4443
+ const keywords = readIncludeExclude(options.keywords, options.excludeKeywords);
4444
+ if (keywords)
4445
+ filters.keywords = keywords;
4446
+ const countries = readCsvOption(options.countries);
4447
+ if (countries.length > 0)
4448
+ filters.geo = { countries };
4449
+ const employeeCount = readRangeOption(options.employees, "--employees");
4450
+ if (employeeCount)
4451
+ filters.employee_count = employeeCount;
4452
+ const fundingStages = readCsvOption(options.fundingStages);
4453
+ if (fundingStages.length > 0)
4454
+ filters.funding = { stages: fundingStages };
4455
+ const technologies = readIncludeExclude(options.technologies, undefined);
4456
+ if (technologies)
4457
+ filters.technologies = technologies;
4458
+ const revenue = readRangeOption(options.revenue, "--revenue");
4459
+ if (revenue)
4460
+ filters.revenue_usd = revenue;
4461
+ const founded = readRangeOption(options.founded, "--founded");
4462
+ if (founded)
4463
+ filters.founded_year = founded;
4464
+ const lookalike = readCsvOption(options.lookalike);
4465
+ if (lookalike.length > 0)
4466
+ filters.lookalike_domains = lookalike;
4467
+ const explicit = options.filtersJson ? parseJsonObject(readFileIfPresent(options.filtersJson)) : null;
4468
+ if (explicit) {
4469
+ for (const [key, value] of Object.entries(explicit)) {
4470
+ filters[key] = value;
4471
+ }
4472
+ }
4473
+ return Object.keys(filters).length > 0 ? filters : null;
4474
+ }
4475
+ function readIncludeExclude(include, exclude) {
4476
+ const includeValues = readCsvOption(include);
4477
+ const excludeValues = readCsvOption(exclude);
4478
+ const result = {};
4479
+ if (includeValues.length > 0)
4480
+ result.include = includeValues;
4481
+ if (excludeValues.length > 0)
4482
+ result.exclude = excludeValues;
4483
+ return Object.keys(result).length > 0 ? result : null;
4484
+ }
4485
+ // Range flags accept "20-200" -> { min, max }, "500+" -> { min }, "-50" -> { max },
4486
+ // and a bare "200" -> { min, max } (exact value).
4487
+ function readRangeOption(value, flag) {
4488
+ const raw = readOption(value);
4489
+ if (!raw)
4490
+ return null;
4491
+ if (/^\d+\+$/.test(raw)) {
4492
+ return { min: readRangeNumber(raw.slice(0, -1), flag, raw) };
4493
+ }
4494
+ if (/^-\d+$/.test(raw)) {
4495
+ return { max: readRangeNumber(raw.slice(1), flag, raw) };
4496
+ }
4497
+ if (/^\d+-\d+$/.test(raw)) {
4498
+ const [minRaw, maxRaw] = raw.split("-");
4499
+ const min = readRangeNumber(minRaw, flag, raw);
4500
+ const max = readRangeNumber(maxRaw, flag, raw);
4501
+ if (min > max) {
4502
+ throw new OxygenError("invalid_range", `${flag} min must not exceed max.`, {
4503
+ details: { flag, value: raw },
4504
+ exitCode: 1,
4505
+ });
4506
+ }
4507
+ return { min, max };
4508
+ }
4509
+ if (/^\d+$/.test(raw)) {
4510
+ const bound = readRangeNumber(raw, flag, raw);
4511
+ return { min: bound, max: bound };
4512
+ }
4513
+ throw new OxygenError("invalid_range", `${flag} expects a range like 20-200, 500+, or -50.`, {
4514
+ details: { flag, value: raw },
4515
+ exitCode: 1,
4516
+ });
4517
+ }
4518
+ function readRangeNumber(value, flag, raw) {
4519
+ const parsed = Number(value);
4520
+ if (!Number.isInteger(parsed) || parsed < 0) {
4521
+ throw new OxygenError("invalid_range", `${flag} bounds must be non-negative integers.`, {
4522
+ details: { flag, value: raw },
4523
+ exitCode: 1,
4524
+ });
4525
+ }
4526
+ return parsed;
4527
+ }
4528
+ // Company search has moved to the dedicated companies-search surface. The CLI rejects
4529
+ // `oxygen search plan --kind company_search` client-side so a stale agent gets a typed,
4530
+ // self-correcting error instead of an opaque server 400. Message mirrors
4531
+ // COMPANY_SEARCH_MOVED_MESSAGE in @oxygen/tools (CLI does not import that package).
4532
+ const COMPANY_SEARCH_MOVED_MESSAGE = "Company search is handled by the dedicated companies-search surface. Use: oxygen companies search plan --prompt \"<goal>\" (CLI) or oxygen_companies_search_plan (MCP).";
4533
+ function assertNotCompanySearchKind(kind) {
4534
+ if (readOption(kind) === "company_search") {
4535
+ throw new OxygenError("use_companies_search", COMPANY_SEARCH_MOVED_MESSAGE, {
4536
+ details: {
4537
+ equivalent_cli: "oxygen companies search plan --prompt <goal>",
4538
+ equivalent_mcp: "oxygen_companies_search_plan",
4539
+ },
4540
+ exitCode: 1,
4541
+ });
4542
+ }
4543
+ }
3931
4544
  function readCompanySearchPlanJson(value) {
3932
4545
  const parsed = parseJsonObject(readFileIfPresent(value));
3933
4546
  const data = parsed.data;
@@ -6306,6 +6919,67 @@ table, options) {
6306
6919
  ...(options.onlyMissing ? { only_missing: true } : {}),
6307
6920
  };
6308
6921
  }
6922
+ function readWorkingDaysOption(value) {
6923
+ if (!readOption(value))
6924
+ return undefined;
6925
+ const days = readCsvOption(value).map((entry) => {
6926
+ const parsed = Number(entry);
6927
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > 7) {
6928
+ throw new OxygenError("invalid_request", "--working-days must be comma-separated ISO weekdays 1-7 (1=Mon..7=Sun).", {
6929
+ details: { value },
6930
+ exitCode: 1,
6931
+ });
6932
+ }
6933
+ return parsed;
6934
+ });
6935
+ return days;
6936
+ }
6937
+ function buildLinkedinLimitsBody(// skipcq: JS-R1005 -- CLI body builder maps per-account limit and working-hours flags.
6938
+ options) {
6939
+ const limits = {};
6940
+ const setLimit = (key, value) => {
6941
+ const parsed = readNonNegativeInt(value);
6942
+ if (parsed !== undefined)
6943
+ limits[key] = parsed;
6944
+ };
6945
+ setLimit("invites_per_day", options.invitesPerDay);
6946
+ setLimit("invites_per_week", options.invitesPerWeek);
6947
+ setLimit("messages_per_day", options.messagesPerDay);
6948
+ setLimit("inmails_per_day", options.inmailsPerDay);
6949
+ setLimit("profile_views_per_day", options.profileViewsPerDay);
6950
+ setLimit("follows_per_day", options.followsPerDay);
6951
+ setLimit("likes_per_day", options.likesPerDay);
6952
+ setLimit("total_actions_per_day", options.totalActionsPerDay);
6953
+ setLimit("relations_reads_per_day", options.relationsReadsPerDay);
6954
+ setLimit("messages_reads_per_day", options.messagesReadsPerDay);
6955
+ setLimit("searches_per_day", options.searchesPerDay);
6956
+ setLimit("api_reads_per_day", options.apiReadsPerDay);
6957
+ setLimit("total_reads_per_day", options.totalReadsPerDay);
6958
+ setLimit("min_action_spacing_seconds", options.minSpacingSeconds);
6959
+ setLimit("action_spacing_jitter_seconds", options.spacingJitterSeconds);
6960
+ const workingHours = {};
6961
+ const timezone = readOption(options.timezone);
6962
+ if (timezone)
6963
+ workingHours.timezone = timezone;
6964
+ const days = readWorkingDaysOption(options.workingDays);
6965
+ if (days !== undefined)
6966
+ workingHours.days = days;
6967
+ const start = readOption(options.workingStart);
6968
+ if (start)
6969
+ workingHours.start = start;
6970
+ const end = readOption(options.workingEnd);
6971
+ if (end)
6972
+ workingHours.end = end;
6973
+ const hasLimits = Object.keys(limits).length > 0;
6974
+ const hasWorkingHours = Object.keys(workingHours).length > 0;
6975
+ if (!hasLimits && !hasWorkingHours) {
6976
+ throw new OxygenError("invalid_request", "Pass at least one limit flag (e.g. --invites-per-day) or working-hours flag (e.g. --timezone, --working-days).", { exitCode: 1 });
6977
+ }
6978
+ return {
6979
+ ...(hasLimits ? { limits } : {}),
6980
+ ...(hasWorkingHours ? { working_hours: workingHours } : {}),
6981
+ };
6982
+ }
6309
6983
  function readPositiveInt(value) {
6310
6984
  const trimmed = value?.trim();
6311
6985
  if (!trimmed)