@oxygen-agent/cli 1.146.1 → 1.160.18

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
@@ -10,13 +10,14 @@ import { stdin as input, stdout as output } from "node:process";
10
10
  import { fileURLToPath, pathToFileURL } from "node:url";
11
11
  import { Command, Option } from "commander";
12
12
  import { formatCellForDisplay, OXYGEN_VERSION, OxygenError, success, toFailure } from "@oxygen/shared";
13
- import { inferRowsFileFormat, normalizeRowsForNewTable, normalizeRowsFormat, parseRowsFileBuffer, } from "@oxygen/shared/file-import";
13
+ import { inferImportColumnLabels, inferRowsFileFormat, normalizeImportColumnKey, normalizeRowsForNewTable, normalizeRowsFormat, parseRowsFileBuffer, } from "@oxygen/shared/file-import";
14
14
  import { assertRecipeBundleSafe, assertWorkflowManifest, buildRecipeManifest, compileWorkflowDefinition, isAnyWorkflowManifest, isRecipeManifest, isWorkflowDefinition, isWorkflowManifest, } from "@oxygen/workflows";
15
15
  import { isRecipeDefinition } from "@oxygen/recipe-sdk";
16
16
  import { createBrowserLoginSession, openBrowser } from "./browser-login.js";
17
17
  import { clearCredentials, defaultApiUrl, listCredentialProfiles, normalizeApiUrl, pickProfileNameForIdentity, pickProfileNameForUserSession, resolveActiveProfile, saveCredentials, switchCredentialProfile, updateActiveOrganizationForProfile, } from "./credentials.js";
18
18
  import { ensureFreshCliForApiUrl, requestOxygen } from "./http-client.js";
19
19
  import { runLocalCustomHttpColumn } from "./local-custom-http-column.js";
20
+ import { captureCurrentTranscript, collectFeedbackEnvironment, TranscriptCaptureError, } from "./transcript.js";
20
21
  import { addSessionOutput, addSessionStatus, getSessionUsage, startSession, updateSessionStep, } from "./session.js";
21
22
  import { doctorAgentSkills, installAgentSkills, listAgentSkills, runAutomaticSkillsInstall, } from "./skills.js";
22
23
  import { resolveCliBinaryName } from "./runtime.js";
@@ -426,6 +427,100 @@ export function createProgram() {
426
427
  },
427
428
  }));
428
429
  }));
430
+ program
431
+ .command("support")
432
+ .description("File and track Oxygen support tickets.")
433
+ .addCommand(new Command("file")
434
+ .description("File a support ticket. Use when you're stuck on an Oxygen operation.")
435
+ .requiredOption("--subject <subject>", "One-line summary of the problem.")
436
+ .option("--body <body>", "What you were doing, what happened, and what you tried.")
437
+ .option("--severity <severity>", "low | normal | high. Defaults to normal.")
438
+ .option("--category <category>", "Optional category label.")
439
+ .option("--operation <operation>", "The operation/tool that failed, e.g. oxygen_columns_run.")
440
+ .option("--error-code <code>", "The error envelope code you received.")
441
+ .option("--run-ids <ids>", "Comma-separated run ids to attach.")
442
+ .option("--table-ids <ids>", "Comma-separated table ids to attach.")
443
+ .option("--deep-links <urls>", "Comma-separated https://oxygen-agent.com/... links.")
444
+ .option("--json", "Print a JSON envelope.")
445
+ .action(async (options) => {
446
+ await handleAsyncAction("support ticket create", options, () => requestOxygen("/api/cli/support/tickets", {
447
+ method: "POST",
448
+ body: buildSupportTicketBody(options),
449
+ }));
450
+ }))
451
+ .addCommand(new Command("list")
452
+ .description("List support tickets for the active organization.")
453
+ .option("--status <status>", "Filter by status (open, triaging, waiting_on_user, resolved, closed).")
454
+ .option("--limit <n>", "Max tickets to return.")
455
+ .option("--json", "Print a JSON envelope.")
456
+ .action(async (options) => {
457
+ await handleAsyncAction("support tickets list", options, () => requestOxygen(withSupportListQuery("/api/cli/support/tickets", options)));
458
+ }))
459
+ .addCommand(new Command("get")
460
+ .description("Show one support ticket with its message thread.")
461
+ .argument("<ticketId>", "Ticket UUID.")
462
+ .option("--json", "Print a JSON envelope.")
463
+ .action(async (ticketId, options) => {
464
+ await handleAsyncAction("support ticket get", options, () => requestOxygen(`/api/cli/support/tickets/${encodeURIComponent(ticketId)}`));
465
+ }))
466
+ .addCommand(new Command("reply")
467
+ .description("Add a message to a support ticket. Reopens a resolved ticket.")
468
+ .argument("<ticketId>", "Ticket UUID.")
469
+ .requiredOption("--body <body>", "Your reply.")
470
+ .option("--json", "Print a JSON envelope.")
471
+ .action(async (ticketId, options) => {
472
+ await handleAsyncAction("support ticket reply", options, () => requestOxygen(`/api/cli/support/tickets/${encodeURIComponent(ticketId)}/messages`, {
473
+ method: "POST",
474
+ body: { body: readOption(options.body) },
475
+ }));
476
+ }))
477
+ .addCommand(new Command("ack")
478
+ .description("Mark a resolved ticket as seen so it leaves the unread count.")
479
+ .argument("<ticketId>", "Ticket UUID.")
480
+ .option("--json", "Print a JSON envelope.")
481
+ .action(async (ticketId, options) => {
482
+ await handleAsyncAction("support ticket ack", options, () => requestOxygen(`/api/cli/support/tickets/${encodeURIComponent(ticketId)}/ack`, {
483
+ method: "POST",
484
+ body: {},
485
+ }));
486
+ }))
487
+ .addCommand(new Command("admin")
488
+ .description("Staff-only support triage commands.")
489
+ .addCommand(new Command("list")
490
+ .description("List support tickets across all organizations (staff only).")
491
+ .option("--status <status>", "Filter by status.")
492
+ .option("--limit <n>", "Max tickets to return.")
493
+ .option("--json", "Print a JSON envelope.")
494
+ .action(async (options) => {
495
+ await handleAsyncAction("support admin list", options, () => requestOxygen(withSupportListQuery("/api/cli/admin/support/tickets", options)));
496
+ }))
497
+ .addCommand(new Command("resolve")
498
+ .description("Resolve a support ticket and notify the opener (staff only).")
499
+ .argument("<ticketId>", "Ticket UUID.")
500
+ .requiredOption("--resolution <text>", "Resolution message sent to the opener.")
501
+ .option("--json", "Print a JSON envelope.")
502
+ .action(async (ticketId, options) => {
503
+ await handleAsyncAction("support admin resolve", options, () => requestOxygen(`/api/cli/admin/support/tickets/${encodeURIComponent(ticketId)}/resolve`, {
504
+ method: "POST",
505
+ body: { resolution: readOption(options.resolution) },
506
+ }));
507
+ })));
508
+ program
509
+ .command("feedback")
510
+ .description("Send feedback or a bug report to the Oxygen team, including your current chat transcript and environment info. Files a tracked ticket; you're notified of the reply by email and on your next session.")
511
+ .option("-m, --message <message>", "Your feedback or bug report.")
512
+ .option("--severity <severity>", "low | normal | high. Defaults to normal.")
513
+ .option("--category <category>", "Optional category label. Defaults to 'feedback'.")
514
+ .option("--session-id <id>", "Attach a specific local session transcript by id. Defaults to the most recently active session.")
515
+ .option("--file <path>", "Attach a specific transcript file instead of auto-detecting the current session.")
516
+ .option("--no-transcript", "Send your note only, without attaching any chat transcript.")
517
+ .option("--json", "Print a JSON envelope.")
518
+ .action(async (options) => {
519
+ await handleAsyncAction("feedback send", options, () => requestOxygen("/api/cli/feedback", {
520
+ method: "POST",
521
+ body: buildFeedbackBody(options),
522
+ }));
523
+ });
429
524
  program
430
525
  .command("db")
431
526
  .description("Tenant database commands.")
@@ -2066,28 +2161,31 @@ export function createProgram() {
2066
2161
  }));
2067
2162
  program
2068
2163
  .command("search")
2069
- .description("Agent-operable company search, web search, and scraping jobs.")
2164
+ .description("Agent-operable people, signal, web, scrape, and local-business search jobs. For company search use 'oxygen companies search'.")
2070
2165
  .addCommand(new Command("plan")
2071
2166
  .description("Plan an Oxygen search or scrape route before running provider jobs.")
2072
2167
  .requiredOption("--goal <file|text>", "Search/scrape goal text, or a local file path containing the goal.")
2073
- .option("--kind <kind>", "Route kind: company_search, people_search, signal_search, web_search, web_scrape, local_business_search, or source_specific_scrape.")
2168
+ .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'.")
2074
2169
  .option("--target-count <n>", "Optional target row count.")
2075
2170
  .option("--geography <text>", "Optional geography hint.")
2076
2171
  .option("--known-urls <urls>", "Comma-separated known URLs for scrape routes.")
2077
2172
  .option("--provider-hints <providers>", "Comma-separated provider hints.")
2078
2173
  .option("--json", "Print a JSON envelope.")
2079
2174
  .action(async (options) => {
2080
- await handleAsyncAction("search plan", options, () => requestOxygen("/api/cli/search/plan", {
2081
- method: "POST",
2082
- body: {
2083
- goal: readFileIfPresent(options.goal),
2084
- ...(readOption(options.kind) ? { kind: readOption(options.kind) } : {}),
2085
- ...(readPositiveInt(options.targetCount) ? { target_count: readPositiveInt(options.targetCount) } : {}),
2086
- ...(readOption(options.geography) ? { geography: readOption(options.geography) } : {}),
2087
- ...(readOption(options.knownUrls) ? { known_urls: readCsvOption(options.knownUrls) } : {}),
2088
- ...(readOption(options.providerHints) ? { provider_hints: readCsvOption(options.providerHints) } : {}),
2089
- },
2090
- }));
2175
+ await handleAsyncAction("search plan", options, () => {
2176
+ assertNotCompanySearchKind(options.kind);
2177
+ return requestOxygen("/api/cli/search/plan", {
2178
+ method: "POST",
2179
+ body: {
2180
+ goal: readFileIfPresent(options.goal),
2181
+ ...(readOption(options.kind) ? { kind: readOption(options.kind) } : {}),
2182
+ ...(readPositiveInt(options.targetCount) ? { target_count: readPositiveInt(options.targetCount) } : {}),
2183
+ ...(readOption(options.geography) ? { geography: readOption(options.geography) } : {}),
2184
+ ...(readOption(options.knownUrls) ? { known_urls: readCsvOption(options.knownUrls) } : {}),
2185
+ ...(readOption(options.providerHints) ? { provider_hints: readCsvOption(options.providerHints) } : {}),
2186
+ },
2187
+ });
2188
+ });
2091
2189
  }))
2092
2190
  .addCommand(new Command("run")
2093
2191
  .description("Preview or enqueue a durable table-backed search/scrape job from a search plan.")
@@ -2191,6 +2289,19 @@ export function createProgram() {
2191
2289
  .requiredOption("--prompt <text-or-file>", "Company-search prompt, or a path to a prompt file.")
2192
2290
  .option("--target-count <n>", "Desired company count for routing and estimates.")
2193
2291
  .option("--source-intent <intent>", "Override detected intent: sizing, structured, technology, hiring, local, known_source, concept, web, url, or fallback.")
2292
+ .option("--filters-json <json-or-file>", "CompanySearchFilters JSON inline or a @file/path; wins over individual flags per top-level filter path.")
2293
+ .option("--industries <csv>", "Comma-separated industries to include.")
2294
+ .option("--exclude-industries <csv>", "Comma-separated industries to exclude.")
2295
+ .option("--keywords <csv>", "Comma-separated keywords to include.")
2296
+ .option("--exclude-keywords <csv>", "Comma-separated keywords to exclude.")
2297
+ .option("--countries <csv>", "Comma-separated ISO 3166-1 alpha-2 country codes.")
2298
+ .option("--employees <range>", "Employee count range: 20-200, 500+, or -50.")
2299
+ .option("--funding-stages <csv>", "Comma-separated funding stages: pre_seed, seed, series_a, ...")
2300
+ .option("--technologies <csv>", "Comma-separated technologies to include.")
2301
+ .option("--revenue <range>", "Annual revenue (USD) range: 1000000-20000000, 1000000+, or -5000000.")
2302
+ .option("--founded <range>", "Founded year range: 2015-2024, 2020+, or -2010.")
2303
+ .option("--lookalike <csv>", "Comma-separated lookalike company domains.")
2304
+ .option("--estimate", "Run a free count probe for an estimated match count.")
2194
2305
  .option("--materialize-preview", "Create a preview table with route rows.")
2195
2306
  .option("--json", "Print a JSON envelope.")
2196
2307
  .action(async (options) => {
@@ -2212,6 +2323,19 @@ export function createProgram() {
2212
2323
  .option("--max-credits <n>", "Required credit ceiling for live runs.")
2213
2324
  .option("--target-count <n>", "Desired company count when planning from --prompt.")
2214
2325
  .option("--source-intent <intent>", "Override detected intent when planning from --prompt.")
2326
+ .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.")
2327
+ .option("--industries <csv>", "Comma-separated industries to include when planning from --prompt.")
2328
+ .option("--exclude-industries <csv>", "Comma-separated industries to exclude when planning from --prompt.")
2329
+ .option("--keywords <csv>", "Comma-separated keywords to include when planning from --prompt.")
2330
+ .option("--exclude-keywords <csv>", "Comma-separated keywords to exclude when planning from --prompt.")
2331
+ .option("--countries <csv>", "Comma-separated ISO 3166-1 alpha-2 country codes when planning from --prompt.")
2332
+ .option("--employees <range>", "Employee count range when planning from --prompt: 20-200, 500+, or -50.")
2333
+ .option("--funding-stages <csv>", "Comma-separated funding stages when planning from --prompt.")
2334
+ .option("--technologies <csv>", "Comma-separated technologies to include when planning from --prompt.")
2335
+ .option("--revenue <range>", "Annual revenue (USD) range when planning from --prompt.")
2336
+ .option("--founded <range>", "Founded year range when planning from --prompt.")
2337
+ .option("--lookalike <csv>", "Comma-separated lookalike company domains when planning from --prompt.")
2338
+ .option("--estimate", "Run a free count probe when planning from --prompt.")
2215
2339
  .option("--approved", "Required for live runs after inspecting dry-run output.")
2216
2340
  .option("--json", "Print a JSON envelope.")
2217
2341
  .action(async (options) => {
@@ -2259,6 +2383,75 @@ export function createProgram() {
2259
2383
  body: readCompaniesEnrichBody(table, options),
2260
2384
  }));
2261
2385
  })));
2386
+ program
2387
+ .command("people")
2388
+ .description("People and contact prospecting workflows.")
2389
+ .addCommand(new Command("search")
2390
+ .description("Plan, dry-run, or queue provider-backed people/contact search.")
2391
+ .addCommand(new Command("plan")
2392
+ .description("Compile a people-search prompt and optional typed persona filters into ordered provider routes without provider calls.")
2393
+ .requiredOption("--prompt <text-or-file>", "People-search prompt, or a path to a prompt file.")
2394
+ .option("--target-count <n>", "Desired contact count for routing and estimates.")
2395
+ .option("--source-intent <intent>", "Override detected intent: persona_search, account_contacts, audience_sizing, profile_lookup, concept_persona, or fallback_broad.")
2396
+ .option("--filters-json <json-or-file>", "PeopleSearchFilters JSON inline or a @file/path; wins over individual flags per top-level filter path.")
2397
+ .option("--titles <csv>", "Comma-separated job titles to include.")
2398
+ .option("--adjacent-titles <csv>", "Comma-separated adjacent/looser titles to accept.")
2399
+ .option("--exclude-titles <csv>", "Comma-separated job titles to exclude.")
2400
+ .option("--seniorities <csv>", "Comma-separated seniority levels: C-Suite, VP, Director, Manager, Staff.")
2401
+ .option("--departments <csv>", "Comma-separated departments/functions: Sales, Marketing, Engineering, ...")
2402
+ .option("--countries <csv>", "Comma-separated ISO 3166-1 alpha-2 person country codes.")
2403
+ .option("--keywords <csv>", "Comma-separated free-text persona keywords.")
2404
+ .option("--company-domains <csv>", "Comma-separated company domains to scope contacts to.")
2405
+ .option("--company-names <csv>", "Comma-separated company names to scope contacts to.")
2406
+ .option("--employees <range>", "Company employee count range: 20-200, 500+, or -50.")
2407
+ .option("--require-email", "Only return people with a work email available (provider-dependent).")
2408
+ .option("--require-phone", "Only return people with a phone/mobile available (provider-dependent).")
2409
+ .option("--max-per-company <n>", "Cap on contacts per company for account-anchored searches.")
2410
+ .option("--estimate", "Run a free count probe for an estimated match count.")
2411
+ .option("--materialize-preview", "Create a preview table with route rows.")
2412
+ .option("--json", "Print a JSON envelope.")
2413
+ .action(async (options) => {
2414
+ await handleAsyncAction("people search plan", options, () => requestOxygen("/api/cli/people/search/plan", {
2415
+ method: "POST",
2416
+ body: readPeopleSearchPlanBody(options),
2417
+ }));
2418
+ }))
2419
+ .addCommand(new Command("run")
2420
+ .description("Return a dry-run request or queue a live people-search ingestion run. Upsert dedup is on linkedin_url.")
2421
+ .option("--prompt <text-or-file>", "People-search prompt, or a path to a prompt file.")
2422
+ .option("--plan-json <json-or-file>", "Plan JSON returned by people search plan, or a path to a JSON file.")
2423
+ .option("--route-id <id>", "Route id from the plan to execute.")
2424
+ .option("--tool-id <tool>", "Tool id from the plan to execute.")
2425
+ .option("--table <table>", "Existing table id or slug to receive rows. If omitted for live, Oxygen creates a table.")
2426
+ .option("--upsert-key <column>", "Column key used for live upsert. Must match the plan upsert key (linkedin_url).")
2427
+ .option("--mode <mode>", "dry_run or live. Defaults to dry_run.")
2428
+ .option("--max-pages <n>", "Maximum provider pages to ingest. Defaults to the route estimate.")
2429
+ .option("--max-credits <n>", "Required credit ceiling for live runs.")
2430
+ .option("--target-count <n>", "Desired contact count when planning from --prompt.")
2431
+ .option("--source-intent <intent>", "Override detected intent when planning from --prompt.")
2432
+ .option("--filters-json <json-or-file>", "PeopleSearchFilters JSON inline or a @file/path when planning from --prompt; wins over individual flags per top-level filter path.")
2433
+ .option("--titles <csv>", "Comma-separated job titles to include when planning from --prompt.")
2434
+ .option("--adjacent-titles <csv>", "Comma-separated adjacent titles when planning from --prompt.")
2435
+ .option("--exclude-titles <csv>", "Comma-separated job titles to exclude when planning from --prompt.")
2436
+ .option("--seniorities <csv>", "Comma-separated seniority levels when planning from --prompt.")
2437
+ .option("--departments <csv>", "Comma-separated departments/functions when planning from --prompt.")
2438
+ .option("--countries <csv>", "Comma-separated ISO 3166-1 alpha-2 person country codes when planning from --prompt.")
2439
+ .option("--keywords <csv>", "Comma-separated persona keywords when planning from --prompt.")
2440
+ .option("--company-domains <csv>", "Comma-separated company domains when planning from --prompt.")
2441
+ .option("--company-names <csv>", "Comma-separated company names when planning from --prompt.")
2442
+ .option("--employees <range>", "Company employee count range when planning from --prompt.")
2443
+ .option("--require-email", "Only return people with a work email available when planning from --prompt.")
2444
+ .option("--require-phone", "Only return people with a phone available when planning from --prompt.")
2445
+ .option("--max-per-company <n>", "Cap on contacts per company when planning from --prompt.")
2446
+ .option("--estimate", "Run a free count probe when planning from --prompt.")
2447
+ .option("--approved", "Required for live runs after inspecting dry-run output.")
2448
+ .option("--json", "Print a JSON envelope.")
2449
+ .action(async (options) => {
2450
+ await handleAsyncAction("people search run", options, () => requestOxygen("/api/cli/people/search/run", {
2451
+ method: "POST",
2452
+ body: readPeopleSearchRunBody(options),
2453
+ }));
2454
+ })));
2262
2455
  program
2263
2456
  .command("worker")
2264
2457
  .description("Background worker commands.")
@@ -2763,7 +2956,7 @@ export function createProgram() {
2763
2956
  .description("Run an executable Oxygen tool through the HTTPS API.")
2764
2957
  .argument("<tool_id>", "Tool id.")
2765
2958
  .requiredOption("--input-json <json>", "Tool input as a JSON object.")
2766
- .option("--mode <mode>", "Execution mode: live, dry-run, or dry_run. Mutating tools default to dry-run.")
2959
+ .option("--mode <mode>", "Execution mode: live or dry-run. Mutating tools default to dry-run.")
2767
2960
  .option("--credential-mode <mode>", "Credential mode: managed, user_api_key, or user_oauth.")
2768
2961
  .option("--org <organization_id>", "Optional organization id. Must match the stored CLI token.")
2769
2962
  .option("--org-id <organization_id>", "Optional organization id. Must match the stored CLI token.")
@@ -2812,7 +3005,8 @@ export function createProgram() {
2812
3005
  .option("--provider-order <providers>", "Comma-separated provider order. Overrides the default cost-aware waterfall profile.")
2813
3006
  .option("--email-waterfall-profile <profile>", "Work-email waterfall profile: auto, name_domain, linkedin_url, or first_last_domain. Defaults prefer BlitzAPI/RocketReach/Prospeo where inputs fit.")
2814
3007
  .option("--email-pattern-validation <mode>", "Work-email pattern pre-step: leadmagic_valid_only or disabled.")
2815
- .option("--verify-phone", "Validate found phone numbers with ClearoutPhone before returning them.")
3008
+ .option("--phone-waterfall-profile <profile>", "Phone waterfall profile: auto (input-aware), linkedin_url, email, or name_domain. Auto picks the cheapest cost-ordered provider set for each row's inputs (mobile match rate ~30-60%).")
3009
+ .option("--verify-phone", "Validate found phone numbers with ClearoutPhone (adds phone_line_type + phone_carrier; filter phone_line_type=mobile for mobile-only).")
2816
3010
  .option("--phone-verification-credential-mode <mode>", "ClearoutPhone credential mode for phone verification: managed or user_api_key.")
2817
3011
  .option("--limit <n>", "Rows to estimate. Defaults to 10.")
2818
3012
  .option("--all", "Estimate all rows.")
@@ -2845,7 +3039,8 @@ export function createProgram() {
2845
3039
  .option("--provider-order <providers>", "Comma-separated provider order. Overrides the default cost-aware waterfall profile.")
2846
3040
  .option("--email-waterfall-profile <profile>", "Work-email waterfall profile: auto, name_domain, linkedin_url, or first_last_domain. Defaults prefer BlitzAPI/RocketReach/Prospeo where inputs fit.")
2847
3041
  .option("--email-pattern-validation <mode>", "Work-email pattern pre-step: leadmagic_valid_only or disabled.")
2848
- .option("--verify-phone", "Validate found phone numbers with ClearoutPhone before returning them.")
3042
+ .option("--phone-waterfall-profile <profile>", "Phone waterfall profile: auto (input-aware), linkedin_url, email, or name_domain. Auto picks the cheapest cost-ordered provider set for each row's inputs (mobile match rate ~30-60%).")
3043
+ .option("--verify-phone", "Validate found phone numbers with ClearoutPhone (adds phone_line_type + phone_carrier; filter phone_line_type=mobile for mobile-only).")
2849
3044
  .option("--phone-verification-credential-mode <mode>", "ClearoutPhone credential mode for phone verification: managed or user_api_key.")
2850
3045
  .option("--limit <n>", "Rows to queue.")
2851
3046
  .option("--all", "Queue all rows.")
@@ -2990,20 +3185,20 @@ export function createProgram() {
2990
3185
  });
2991
3186
  })))
2992
3187
  .addCommand(new Command("list")
2993
- .description("List supported Composio integrations and this org's connections.")
3188
+ .description("List supported integrations and this org's connections.")
2994
3189
  .option("--json", "Print a JSON envelope.")
2995
3190
  .action(async (options) => {
2996
3191
  await handleAsyncAction("integrations list", options, async () => requestOxygen("/api/cli/integrations/composio/list"));
2997
3192
  }))
2998
3193
  .addCommand(new Command("connect")
2999
- .description("Connect a Composio integration. OAuth toolkits return a redirect URL; API-key toolkits accept --api-key.")
3194
+ .description("Connect an integration. OAuth toolkits return a redirect URL; API-key integrations accept --api-key.")
3000
3195
  .argument("<integration_id>", "Integration id, such as 'slack' or 'serpapi'.")
3001
3196
  .option("--api-key <value>", "API key for Composio API-key toolkits (e.g. SerpAPI, Resend).")
3002
3197
  .option("--json", "Print a JSON envelope.")
3003
3198
  .action(async (integrationId, options) => {
3004
3199
  await handleAsyncAction("integrations connect", options, async () => {
3005
3200
  const apiKey = readOption(options.apiKey)?.trim();
3006
- return requestOxygen("/api/cli/integrations/composio/start", {
3201
+ return requestOxygen("/api/cli/integrations/connect", {
3007
3202
  method: "POST",
3008
3203
  body: {
3009
3204
  integration_id: integrationId,
@@ -3013,13 +3208,13 @@ export function createProgram() {
3013
3208
  });
3014
3209
  }))
3015
3210
  .addCommand(new Command("disconnect")
3016
- .description("Disconnect a Composio integration.")
3211
+ .description("Disconnect an integration.")
3017
3212
  .argument("<integration_id>", "Integration id, such as 'slack'.")
3018
3213
  .option("--connection-id <id>", "Disconnect a specific connected account when multiple exist.")
3019
3214
  .option("--json", "Print a JSON envelope.")
3020
3215
  .action(async (integrationId, options) => {
3021
3216
  const connectionId = readOption(options.connectionId)?.trim();
3022
- await handleAsyncAction("integrations disconnect", options, () => requestOxygen("/api/cli/integrations/composio/disconnect", {
3217
+ await handleAsyncAction("integrations disconnect", options, () => requestOxygen("/api/cli/integrations/disconnect", {
3023
3218
  method: "POST",
3024
3219
  body: {
3025
3220
  integration_id: integrationId,
@@ -3063,18 +3258,15 @@ export function createProgram() {
3063
3258
  });
3064
3259
  });
3065
3260
  }));
3066
- program
3067
- .command("linkedin")
3068
- .description("LinkedIn sender account management for the native LinkedIn sequencer (accounts, limits, usage).")
3069
- .addCommand(new Command("accounts")
3070
- .description("Manage the org's connected LinkedIn sender accounts: list, connect, sync, inspect, disconnect, and tune rate limits.")
3261
+ program.addCommand(new Command("senders")
3262
+ .description("Manage the org's connected LinkedIn sender accounts for Sequencer: list, connect, sync, inspect, disconnect, and tune rate limits.")
3071
3263
  .addCommand(new Command("list")
3072
3264
  .description("List connected LinkedIn sender accounts with health status, rate limits, and today's usage.")
3073
3265
  .option("--status <status>", "Filter by sender status: active, paused, disconnected, restricted, or credentials_required.")
3074
3266
  .option("--no-usage", "Skip today's per-account usage counts for a faster, lighter response.")
3075
3267
  .option("--json", "Print a JSON envelope.")
3076
3268
  .action(async (options) => {
3077
- await handleAsyncAction("linkedin accounts list", options, () => {
3269
+ await handleAsyncAction("senders list", options, () => {
3078
3270
  const params = new URLSearchParams();
3079
3271
  if (options.usage !== false)
3080
3272
  params.set("include_usage", "true");
@@ -3082,7 +3274,7 @@ export function createProgram() {
3082
3274
  if (status)
3083
3275
  params.set("status", status);
3084
3276
  const suffix = params.toString();
3085
- return requestOxygen(`/api/cli/linkedin/accounts${suffix ? `?${suffix}` : ""}`);
3277
+ return requestOxygen(`/api/cli/senders${suffix ? `?${suffix}` : ""}`);
3086
3278
  });
3087
3279
  }))
3088
3280
  .addCommand(new Command("connect")
@@ -3090,9 +3282,9 @@ export function createProgram() {
3090
3282
  .option("--reconnect <connection_id>", "Reconnect an existing connection instead of creating a new one. Accepts a connection id.")
3091
3283
  .option("--json", "Print a JSON envelope.")
3092
3284
  .action(async (options) => {
3093
- await handleAsyncAction("linkedin accounts connect", options, () => {
3285
+ await handleAsyncAction("senders connect", options, () => {
3094
3286
  const reconnect = readOption(options.reconnect);
3095
- return requestOxygen("/api/cli/linkedin/accounts/connect", {
3287
+ return requestOxygen("/api/cli/senders/connect", {
3096
3288
  method: "POST",
3097
3289
  body: {
3098
3290
  ...(reconnect ? { reconnect_connection_id: reconnect } : {}),
@@ -3101,20 +3293,20 @@ export function createProgram() {
3101
3293
  });
3102
3294
  }))
3103
3295
  .addCommand(new Command("get")
3104
- .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.")
3296
+ .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.")
3105
3297
  .argument("<id>", "Sender account id, connection id, or Unipile account id.")
3106
3298
  .option("--json", "Print a JSON envelope.")
3107
3299
  .action(async (id, options) => {
3108
- await handleAsyncAction("linkedin accounts get", options, async () => requestOxygen(`/api/cli/linkedin/accounts/${encodeURIComponent(id)}`));
3300
+ await handleAsyncAction("senders get", options, async () => requestOxygen(`/api/cli/senders/${encodeURIComponent(id)}`));
3109
3301
  }))
3110
3302
  .addCommand(new Command("sync")
3111
3303
  .description("Refresh LinkedIn account state (status, profile) from Unipile. Pass --connection-id to sync one account, or omit it to sync all.")
3112
3304
  .option("--connection-id <id>", "Sync a specific connection id. Defaults to syncing all connected accounts.")
3113
3305
  .option("--json", "Print a JSON envelope.")
3114
3306
  .action(async (options) => {
3115
- await handleAsyncAction("linkedin accounts sync", options, () => {
3307
+ await handleAsyncAction("senders sync", options, () => {
3116
3308
  const connectionId = readOption(options.connectionId);
3117
- return requestOxygen("/api/cli/linkedin/accounts/sync", {
3309
+ return requestOxygen("/api/cli/senders/sync", {
3118
3310
  method: "POST",
3119
3311
  body: {
3120
3312
  ...(connectionId ? { connection_id: connectionId } : {}),
@@ -3127,22 +3319,22 @@ export function createProgram() {
3127
3319
  .argument("<id>", "Sender account id, connection id, or Unipile account id.")
3128
3320
  .option("--json", "Print a JSON envelope.")
3129
3321
  .action(async (id, options) => {
3130
- await handleAsyncAction("linkedin accounts disconnect", options, async () => requestOxygen(`/api/cli/linkedin/accounts/${encodeURIComponent(id)}/disconnect`, {
3322
+ await handleAsyncAction("senders disconnect", options, async () => requestOxygen(`/api/cli/senders/${encodeURIComponent(id)}/disconnect`, {
3131
3323
  method: "POST",
3132
3324
  }));
3133
3325
  }))
3134
3326
  .addCommand(new Command("limits")
3135
- .description("View and adjust per-account daily action limits and working hours.")
3327
+ .description("View and adjust per-account daily action limits and the daily-reset timezone.")
3136
3328
  .option("--json", "Print a JSON envelope.")
3137
3329
  .addCommand(new Command("get")
3138
- .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.")
3330
+ .description("Show current limits, overrides, daily-reset timezone, defaults, and safe maximums for an account. <id> accepts a sender account id, connection id, or Unipile account id.")
3139
3331
  .argument("<id>", "Sender account id, connection id, or Unipile account id.")
3140
3332
  .option("--json", "Print a JSON envelope.")
3141
3333
  .action(async (id, options) => {
3142
- await handleAsyncAction("linkedin accounts limits get", options, async () => requestOxygen(`/api/cli/linkedin/accounts/${encodeURIComponent(id)}/limits`));
3334
+ await handleAsyncAction("senders limits get", options, async () => requestOxygen(`/api/cli/senders/${encodeURIComponent(id)}/limits`));
3143
3335
  }))
3144
3336
  .addCommand(new Command("set")
3145
- .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.")
3337
+ .description("Adjust per-account daily action limits and the daily-reset timezone. Values are clamped to safe maximums (e.g. max 80 invites/day). Send windows (time of day) are set per sequence in the campaign schedule, not per account. <id> accepts a sender account id, connection id, or Unipile account id.")
3146
3338
  .argument("<id>", "Sender account id, connection id, or Unipile account id.")
3147
3339
  .option("--invites-per-day <n>", "Daily LinkedIn connection invites cap.")
3148
3340
  .option("--invites-per-week <n>", "Weekly LinkedIn connection invites cap.")
@@ -3151,24 +3343,26 @@ export function createProgram() {
3151
3343
  .option("--profile-views-per-day <n>", "Daily profile views cap.")
3152
3344
  .option("--follows-per-day <n>", "Daily follows cap.")
3153
3345
  .option("--likes-per-day <n>", "Daily likes cap.")
3154
- .option("--total-actions-per-day <n>", "Daily cap across all action types.")
3346
+ .option("--total-actions-per-day <n>", "Daily cap across all send/action types.")
3347
+ .option("--relations-reads-per-day <n>", "Daily cap on relations/connections list reads (scrape protection).")
3348
+ .option("--messages-reads-per-day <n>", "Daily cap on chat and message-history reads.")
3349
+ .option("--searches-per-day <n>", "Daily cap on LinkedIn search executions.")
3350
+ .option("--api-reads-per-day <n>", "Daily cap on all other LinkedIn API reads.")
3351
+ .option("--total-reads-per-day <n>", "Daily cap across all read types.")
3155
3352
  .option("--min-spacing-seconds <n>", "Minimum seconds between actions.")
3156
3353
  .option("--spacing-jitter-seconds <n>", "Random jitter seconds added to action spacing.")
3157
- .option("--timezone <tz>", "IANA timezone for working hours, e.g. America/New_York.")
3158
- .option("--working-days <days>", "Comma-separated ISO weekdays the account sends, e.g. 1,2,3,4,5 (1=Mon..7=Sun).")
3159
- .option("--working-start <HH:MM>", "Working hours start time, e.g. 09:00.")
3160
- .option("--working-end <HH:MM>", "Working hours end time, e.g. 17:00.")
3354
+ .option("--timezone <tz>", "IANA timezone the daily action counters reset in, e.g. America/New_York.")
3161
3355
  .option("--json", "Print a JSON envelope.")
3162
3356
  .action(async (id, options) => {
3163
- await handleAsyncAction("linkedin accounts limits set", options, () => {
3357
+ await handleAsyncAction("senders limits set", options, () => {
3164
3358
  const body = buildLinkedinLimitsBody(options);
3165
- return requestOxygen(`/api/cli/linkedin/accounts/${encodeURIComponent(id)}/limits`, {
3359
+ return requestOxygen(`/api/cli/senders/${encodeURIComponent(id)}/limits`, {
3166
3360
  method: "PATCH",
3167
3361
  body,
3168
3362
  });
3169
3363
  });
3170
- }))))
3171
- .addCommand(new Command("inbox")
3364
+ }))));
3365
+ program.addCommand(new Command("inbox")
3172
3366
  .description("LinkedIn unified inbox (unibox): scan conversations across all sender accounts, read threads, and reply.")
3173
3367
  .addCommand(new Command("list")
3174
3368
  .description("List LinkedIn conversations across all connected accounts, newest first.")
@@ -3179,7 +3373,7 @@ export function createProgram() {
3179
3373
  .option("--limit <n>", "Maximum conversations to return (1-200). Defaults to 50.")
3180
3374
  .option("--json", "Print a JSON envelope.")
3181
3375
  .action(async (options) => {
3182
- await handleAsyncAction("linkedin inbox list", options, () => {
3376
+ await handleAsyncAction("inbox list", options, () => {
3183
3377
  const params = new URLSearchParams();
3184
3378
  const account = readOption(options.account);
3185
3379
  if (account)
@@ -3195,7 +3389,7 @@ export function createProgram() {
3195
3389
  if (limit)
3196
3390
  params.set("limit", limit);
3197
3391
  const suffix = params.toString();
3198
- return requestOxygen(`/api/cli/linkedin/inbox${suffix ? `?${suffix}` : ""}`);
3392
+ return requestOxygen(`/api/cli/inbox${suffix ? `?${suffix}` : ""}`);
3199
3393
  });
3200
3394
  }))
3201
3395
  .addCommand(new Command("get")
@@ -3204,13 +3398,13 @@ export function createProgram() {
3204
3398
  .option("--message-limit <n>", "Maximum messages to return (1-500). Defaults to 100.")
3205
3399
  .option("--json", "Print a JSON envelope.")
3206
3400
  .action(async (conversation, options) => {
3207
- await handleAsyncAction("linkedin inbox get", options, () => {
3401
+ await handleAsyncAction("inbox get", options, () => {
3208
3402
  const params = new URLSearchParams();
3209
3403
  const messageLimit = readOption(options.messageLimit);
3210
3404
  if (messageLimit)
3211
3405
  params.set("message_limit", messageLimit);
3212
3406
  const suffix = params.toString();
3213
- return requestOxygen(`/api/cli/linkedin/inbox/${encodeURIComponent(conversation)}${suffix ? `?${suffix}` : ""}`);
3407
+ return requestOxygen(`/api/cli/inbox/${encodeURIComponent(conversation)}${suffix ? `?${suffix}` : ""}`);
3214
3408
  });
3215
3409
  }))
3216
3410
  .addCommand(new Command("send")
@@ -3220,7 +3414,7 @@ export function createProgram() {
3220
3414
  .option("--approved", "Approve and send the message. Without this flag, returns a preview only.")
3221
3415
  .option("--json", "Print a JSON envelope.")
3222
3416
  .action(async (conversation, options) => {
3223
- await handleAsyncAction("linkedin inbox send", options, () => requestOxygen(`/api/cli/linkedin/inbox/${encodeURIComponent(conversation)}/send`, {
3417
+ await handleAsyncAction("inbox send", options, () => requestOxygen(`/api/cli/inbox/${encodeURIComponent(conversation)}/send`, {
3224
3418
  method: "POST",
3225
3419
  body: {
3226
3420
  text: readOption(options.text),
@@ -3233,7 +3427,7 @@ export function createProgram() {
3233
3427
  .argument("<conversation>", "Conversation id or Unipile chat id.")
3234
3428
  .option("--json", "Print a JSON envelope.")
3235
3429
  .action(async (conversation, options) => {
3236
- await handleAsyncAction("linkedin inbox mark-read", options, () => requestOxygen(`/api/cli/linkedin/inbox/${encodeURIComponent(conversation)}/read`, {
3430
+ await handleAsyncAction("inbox mark-read", options, () => requestOxygen(`/api/cli/inbox/${encodeURIComponent(conversation)}/read`, {
3237
3431
  method: "POST",
3238
3432
  }));
3239
3433
  }))
@@ -3243,7 +3437,7 @@ export function createProgram() {
3243
3437
  .option("--message-limit <n>", "Maximum messages to sync per chat. Defaults to 20.")
3244
3438
  .option("--json", "Print a JSON envelope.")
3245
3439
  .action(async (options) => {
3246
- await handleAsyncAction("linkedin inbox sync", options, () => {
3440
+ await handleAsyncAction("inbox sync", options, () => {
3247
3441
  const body = {};
3248
3442
  const chatLimit = readOption(options.chatLimit);
3249
3443
  if (chatLimit)
@@ -3251,9 +3445,372 @@ export function createProgram() {
3251
3445
  const messageLimit = readOption(options.messageLimit);
3252
3446
  if (messageLimit)
3253
3447
  body.message_limit = Number(messageLimit);
3254
- return requestOxygen("/api/cli/linkedin/inbox/sync", { method: "POST", body });
3448
+ return requestOxygen("/api/cli/inbox/sync", { method: "POST", body });
3255
3449
  });
3256
3450
  })));
3451
+ program.addCommand(new Command("sequences")
3452
+ .description("Multichannel outreach sequences: one enrollment per lead spans LinkedIn + email over a journey. LinkedIn steps dispatch natively (rate-limited, credit-capped); email steps send natively or place/move/stop the lead in a bound Instantly campaign (BYOK). Cross-channel reply-stop is intrinsic. A LinkedIn-only sequence behaves exactly like the original sequencer.")
3453
+ .addCommand(new Command("list")
3454
+ .description("List sequences with status, channels, and credit usage.")
3455
+ .option("--status <status>", "Filter by status: draft, active, paused, or archived.")
3456
+ .option("--json", "Print a JSON envelope.")
3457
+ .action(async (options) => {
3458
+ await handleAsyncAction("sequences list", options, () => {
3459
+ const params = new URLSearchParams();
3460
+ const status = readOption(options.status);
3461
+ if (status)
3462
+ params.set("status", status);
3463
+ const suffix = params.toString();
3464
+ return requestOxygen(`/api/cli/sequences${suffix ? `?${suffix}` : ""}`);
3465
+ });
3466
+ }))
3467
+ .addCommand(new Command("analytics")
3468
+ .description("Show organization-level sequencer analytics plus per-sequence funnels.")
3469
+ .option("--range <range>", "Preset range: 7d, 14d, 28d, or 30d. Defaults to 14d.")
3470
+ .option("--from <date>", "Custom start date (YYYY-MM-DD).")
3471
+ .option("--to <date>", "Custom end date (YYYY-MM-DD).")
3472
+ .option("--sequence <id-or-slug>", "Limit analytics to one sequence.")
3473
+ .option("--json", "Print a JSON envelope.")
3474
+ .action(async (options) => {
3475
+ await handleAsyncAction("sequences analytics", options, () => {
3476
+ const params = new URLSearchParams();
3477
+ const range = readOption(options.range);
3478
+ const from = readOption(options.from);
3479
+ const to = readOption(options.to);
3480
+ const sequence = readOption(options.sequence);
3481
+ if (range)
3482
+ params.set("range", range);
3483
+ if (from)
3484
+ params.set("from", from);
3485
+ if (to)
3486
+ params.set("to", to);
3487
+ if (sequence)
3488
+ params.set("sequence", sequence);
3489
+ const qs = params.toString();
3490
+ return requestOxygen(`/api/cli/sequences/analytics${qs ? `?${qs}` : ""}`);
3491
+ });
3492
+ }))
3493
+ .addCommand(new Command("create")
3494
+ .description("Create a draft multichannel sequence from a steps JSON file. Assign LinkedIn senders with --senders (required when the journey has LinkedIn steps); bind an Instantly email track with --email-*.")
3495
+ .requiredOption("--name <name>", "Human-readable sequence name.")
3496
+ .requiredOption("--slug <slug>", "Unique slug for the sequence.")
3497
+ .requiredOption("--steps-file <path>", "Path to a JSON file: { \"steps\": [...] }. LinkedIn steps (invite | message | wait_for_connection | inmail), email steps (email_send | email_reply | email_enroll | email_move | email_stop), and control steps (wait | wait_for_signal | branch | stop), each with an `id`. A `branch` routes on signals (then/else) or the legacy connection_accepted/already_connected sugar (then_id/else_id).")
3498
+ .option("--channels <list>", "Comma-separated channels: linkedin,email. Defaults to the channels the journey touches.")
3499
+ .option("--senders <ids>", "Comma-separated LinkedIn sender account ids (or connection / Unipile ids). Required when the journey has LinkedIn steps.")
3500
+ .option("--table <id>", "Source table id whose rows supply {{column}} template values.")
3501
+ .option("--url-column <key>", "Column key holding each lead's LinkedIn URL/provider id.")
3502
+ .option("--email-provider <provider>", "Email provider for the email track. Only 'instantly' is supported.")
3503
+ .option("--email-connection <id>", "Instantly connection id for the email track. Defaults to the org's active Instantly connection.")
3504
+ .option("--email-definition-file <path>", "Path to a JSON file with the email content spec (subjects/bodies/delays/subsequences) compiled to an Instantly campaign on start.")
3505
+ .option("--max-credits <n>", "Credit cap for the LinkedIn track (also set when starting).")
3506
+ .option("--json", "Print a JSON envelope.")
3507
+ .action(async (options) => {
3508
+ await handleAsyncAction("sequences create", options, () => {
3509
+ const stepsPath = readOption(options.stepsFile);
3510
+ if (!stepsPath)
3511
+ throw new Error("--steps-file is required.");
3512
+ const raw = readFileSync(resolve(stepsPath), "utf8");
3513
+ const definition = JSON.parse(raw);
3514
+ const channels = readCsvOption(options.channels);
3515
+ const senders = readCsvOption(options.senders);
3516
+ const email = readCampaignEmailBinding(options);
3517
+ const maxCredits = readPositiveNumber(options.maxCredits);
3518
+ return requestOxygen("/api/cli/sequences", {
3519
+ method: "POST",
3520
+ body: {
3521
+ name: readOption(options.name),
3522
+ slug: readOption(options.slug),
3523
+ definition,
3524
+ ...(channels.length > 0 ? { channels } : {}),
3525
+ ...(senders.length > 0 ? { senders } : {}),
3526
+ ...(readOption(options.table) ? { source_table_id: readOption(options.table) } : {}),
3527
+ ...(readOption(options.urlColumn) ? { linkedin_url_column_key: readOption(options.urlColumn) } : {}),
3528
+ ...(email ? { email } : {}),
3529
+ ...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
3530
+ },
3531
+ });
3532
+ });
3533
+ }))
3534
+ .addCommand(new Command("update")
3535
+ .description("Update a DRAFT sequence's name, journey, channels, senders, email binding, or LinkedIn credit cap. The journey is re-validated; the email binding stays editable only while the sequence is a draft. --max-credits is locked once the sequence has been started (re-run `sequences start`). Pass only the fields you want to change.")
3536
+ .argument("<sequence>", "Sequence id or slug.")
3537
+ .option("--name <name>", "New human-readable sequence name.")
3538
+ .option("--steps-file <path>", "Path to a JSON file: { \"steps\": [...] } replacing the journey.")
3539
+ .option("--channels <list>", "Comma-separated channels: linkedin,email.")
3540
+ .option("--senders <ids>", "Comma-separated LinkedIn sender account ids (or connection / Unipile ids).")
3541
+ .option("--email-provider <provider>", "Email provider for the email track. Only 'instantly' is supported.")
3542
+ .option("--email-connection <id>", "Instantly connection id for the email track.")
3543
+ .option("--email-definition-file <path>", "Path to a JSON file with the email content spec (subjects/bodies/delays/subsequences).")
3544
+ .option("--clear-email", "Remove the email binding from the sequence (draft only).")
3545
+ .option("--max-credits <n>", "Credit cap for the LinkedIn track (draft only).")
3546
+ .option("--json", "Print a JSON envelope.")
3547
+ .action(async (sequence, options) => {
3548
+ await handleAsyncAction("sequences update", options, () => {
3549
+ const body = {};
3550
+ const name = readOption(options.name);
3551
+ if (name)
3552
+ body.name = name;
3553
+ const stepsPath = readOption(options.stepsFile);
3554
+ if (stepsPath) {
3555
+ body.definition = JSON.parse(readFileSync(resolve(stepsPath), "utf8"));
3556
+ }
3557
+ const channels = readCsvOption(options.channels);
3558
+ if (channels.length > 0)
3559
+ body.channels = channels;
3560
+ const senders = readCsvOption(options.senders);
3561
+ if (senders.length > 0)
3562
+ body.senders = senders;
3563
+ const maxCredits = readPositiveNumber(options.maxCredits);
3564
+ if (maxCredits !== undefined)
3565
+ body.max_credits = maxCredits;
3566
+ if (options.clearEmail) {
3567
+ body.email = null;
3568
+ }
3569
+ else {
3570
+ const email = readCampaignEmailBinding(options);
3571
+ if (email)
3572
+ body.email = email;
3573
+ }
3574
+ if (Object.keys(body).length === 0) {
3575
+ throw new Error("Provide at least one field to update (--name, --steps-file, --channels, --senders, --email-*, --clear-email, or --max-credits).");
3576
+ }
3577
+ return requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}`, {
3578
+ method: "PATCH",
3579
+ body,
3580
+ });
3581
+ });
3582
+ }))
3583
+ .addCommand(new Command("get")
3584
+ .description("Get a sequence's definition, senders, status, and credit usage.")
3585
+ .argument("<sequence>", "Sequence id or slug.")
3586
+ .option("--json", "Print a JSON envelope.")
3587
+ .action(async (sequence, options) => {
3588
+ await handleAsyncAction("sequences get", options, () => requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}`));
3589
+ }))
3590
+ .addCommand(new Command("enroll")
3591
+ .description("Enroll leads into a sequence from a JSON file of { leads: [...] }. When the sequence is bound to a source table, a lead's table_row_id auto-snapshots that row's columns (incl. AI/tool outputs) into row_values for {{column}} copy — explicit row_values win. Idempotent per table row.")
3592
+ .argument("<sequence>", "Sequence id or slug.")
3593
+ .requiredOption("--leads-file <path>", "Path to a JSON file: { \"leads\": [{ lead_provider_id, lead_name, table_row_id, row_values }] }.")
3594
+ .option("--json", "Print a JSON envelope.")
3595
+ .action(async (sequence, options) => {
3596
+ await handleAsyncAction("sequences enroll", options, () => {
3597
+ const leadsPath = readOption(options.leadsFile);
3598
+ if (!leadsPath)
3599
+ throw new Error("--leads-file is required.");
3600
+ const parsed = JSON.parse(readFileSync(resolve(leadsPath), "utf8"));
3601
+ return requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/enroll`, {
3602
+ method: "POST",
3603
+ body: { leads: parsed.leads ?? [] },
3604
+ });
3605
+ });
3606
+ }))
3607
+ .addCommand(new Command("start")
3608
+ .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).")
3609
+ .argument("<sequence>", "Sequence id or slug.")
3610
+ .option("--approved", "Approve and activate live. Without this flag, returns a preview only.")
3611
+ .option("--max-credits <n>", "Credit cap (required with --approved).")
3612
+ .option("--dry-run", "Activate in dry-run mode: advance every step with simulated sends, no LinkedIn actions, no credits.")
3613
+ .option("--json", "Print a JSON envelope.")
3614
+ .action(async (sequence, options) => {
3615
+ await handleAsyncAction("sequences start", options, () => {
3616
+ const maxCredits = readPositiveNumber(options.maxCredits);
3617
+ return requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/start`, {
3618
+ method: "POST",
3619
+ body: {
3620
+ ...(options.dryRun ? { dry_run: true } : {}),
3621
+ ...(options.approved ? { approved: true } : {}),
3622
+ ...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
3623
+ },
3624
+ });
3625
+ });
3626
+ }))
3627
+ .addCommand(new Command("pause")
3628
+ .description("Pause an active sequence (stops new dispatches; enrollments resume on un-pause).")
3629
+ .argument("<sequence>", "Sequence id or slug.")
3630
+ .option("--json", "Print a JSON envelope.")
3631
+ .action(async (sequence, options) => {
3632
+ await handleAsyncAction("sequences pause", options, () => requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/status`, {
3633
+ method: "POST", body: { status: "paused" },
3634
+ }));
3635
+ }))
3636
+ .addCommand(new Command("resume")
3637
+ .description("Resume a paused sequence.")
3638
+ .argument("<sequence>", "Sequence id or slug.")
3639
+ .option("--json", "Print a JSON envelope.")
3640
+ .action(async (sequence, options) => {
3641
+ await handleAsyncAction("sequences resume", options, () => requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/status`, {
3642
+ method: "POST", body: { status: "active" },
3643
+ }));
3644
+ }))
3645
+ .addCommand(new Command("archive")
3646
+ .description("Archive a sequence (terminal; cannot be reactivated).")
3647
+ .argument("<sequence>", "Sequence id or slug.")
3648
+ .option("--json", "Print a JSON envelope.")
3649
+ .action(async (sequence, options) => {
3650
+ await handleAsyncAction("sequences archive", options, () => requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/status`, {
3651
+ method: "POST", body: { status: "archived" },
3652
+ }));
3653
+ }))
3654
+ .addCommand(new Command("enrollments")
3655
+ .description("List a sequence's per-lead enrollments and their state.")
3656
+ .argument("<sequence>", "Sequence id or slug.")
3657
+ .option("--status <status>", "Filter by enrollment status (active, waiting_connection, replied, completed, stopped, failed).")
3658
+ .option("--limit <n>", "Maximum enrollments to return (1-500).")
3659
+ .option("--json", "Print a JSON envelope.")
3660
+ .action(async (sequence, options) => {
3661
+ await handleAsyncAction("sequences enrollments", options, () => {
3662
+ const params = new URLSearchParams();
3663
+ const status = readOption(options.status);
3664
+ if (status)
3665
+ params.set("status", status);
3666
+ const limit = readOption(options.limit);
3667
+ if (limit)
3668
+ params.set("limit", limit);
3669
+ const suffix = params.toString();
3670
+ return requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/enrollments${suffix ? `?${suffix}` : ""}`);
3671
+ });
3672
+ }))
3673
+ .addCommand(new Command("stats")
3674
+ .description("Show the sequence funnel: enrolled, invites sent, connected, replied, with acceptance and reply rates.")
3675
+ .argument("<sequence>", "Sequence id or slug.")
3676
+ .option("--json", "Print a JSON envelope.")
3677
+ .action(async (sequence, options) => {
3678
+ await handleAsyncAction("sequences stats", options, () => requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/stats`));
3679
+ })));
3680
+ program.addCommand(new Command("mailboxes")
3681
+ .description("Native email sending pool: register/refresh Google/Microsoft mailboxes a campaign rotates over, pause/disable inboxes, and delegate warmup to Instantly (BYOK — Instantly bills your account, 0 Oxygen credits).")
3682
+ .addCommand(new Command("list")
3683
+ .description("List the org's sending mailboxes with provider, status, warmup state, and a pool overview.")
3684
+ .option("--status <status>", "Filter by status: active, paused, or disabled.")
3685
+ .option("--json", "Print a JSON envelope.")
3686
+ .action(async (options) => {
3687
+ await handleAsyncAction("mailboxes list", options, () => {
3688
+ const params = new URLSearchParams();
3689
+ const status = readOption(options.status);
3690
+ if (status)
3691
+ params.set("status", status);
3692
+ const suffix = params.toString();
3693
+ return requestOxygen(`/api/cli/mailboxes${suffix ? `?${suffix}` : ""}`);
3694
+ });
3695
+ }))
3696
+ .addCommand(new Command("import")
3697
+ .description("Register (or refresh) sending mailboxes in bulk. Provide --file (a JSON file: { \"mailboxes\": [{ email_address, provider, workspace_external_id? }] }) or --from zapmail to pull the connected Zapmail workspace's mailbox list. Upsert is keyed by address, so re-importing never duplicates a mailbox.")
3698
+ .option("--file <path>", "Path to a JSON file: { \"mailboxes\": [{ email_address, provider, workspace_external_id? }] }.")
3699
+ .option("--from <source>", "Import source: 'zapmail' to pull the connected Zapmail workspace's mailboxes.")
3700
+ .option("--connection <id>", "Zapmail connection id (--from zapmail). Defaults to the org's active Zapmail connection.")
3701
+ .option("--json", "Print a JSON envelope.")
3702
+ .action(async (options) => {
3703
+ await handleAsyncAction("mailboxes import", options, () => {
3704
+ const from = readOption(options.from);
3705
+ const filePath = readOption(options.file);
3706
+ const connection = readOption(options.connection);
3707
+ if (from === "zapmail") {
3708
+ return requestOxygen("/api/cli/mailboxes", {
3709
+ method: "POST",
3710
+ body: { source: "zapmail", ...(connection ? { connection_id: connection } : {}) },
3711
+ });
3712
+ }
3713
+ if (!filePath)
3714
+ throw new Error("Provide --file <path> (a { \"mailboxes\": [...] } JSON file) or --from zapmail.");
3715
+ const parsed = JSON.parse(readFileSync(resolve(filePath), "utf8"));
3716
+ return requestOxygen("/api/cli/mailboxes", {
3717
+ method: "POST",
3718
+ body: { mailboxes: parsed.mailboxes ?? [] },
3719
+ });
3720
+ });
3721
+ }))
3722
+ .addCommand(new Command("status")
3723
+ .description("Set a sending mailbox's status. Pausing/disabling takes the inbox out of the rotation pool without losing its warmup state.")
3724
+ .argument("<mailbox>", "Mailbox id or email address.")
3725
+ .requiredOption("--status <status>", "New status: active, paused, or disabled.")
3726
+ .option("--json", "Print a JSON envelope.")
3727
+ .action(async (mailbox, options) => {
3728
+ await handleAsyncAction("mailboxes status", options, () => {
3729
+ const status = readOption(options.status);
3730
+ if (!status)
3731
+ throw new Error("--status is required.");
3732
+ return requestOxygen(`/api/cli/mailboxes/${encodeURIComponent(mailbox)}/status`, {
3733
+ method: "POST",
3734
+ body: { status },
3735
+ });
3736
+ });
3737
+ }))
3738
+ .addCommand(new Command("connect-oauth")
3739
+ .description("Provision Zapmail Custom OAuth across the mailbox pool: registers Oxygen's own OAuth app with Zapmail, which authorizes it across the Workspaces so native send needs no per-Workspace delegation. BYOK — Zapmail bills your account, 0 Oxygen credits. Pass --status <export_id> to poll a run.")
3740
+ .option("--provider <provider>", "Mailbox provider to provision: google or microsoft.")
3741
+ .option("--mailboxes <list>", "Comma-separated mailbox addresses to provision. Omit to provision the whole pool for the provider.")
3742
+ .option("--connection <id>", "Zapmail connection id. Defaults to the org's active Zapmail connection.")
3743
+ .option("--status <export_id>", "Poll a previously started Custom OAuth export instead of starting a new one.")
3744
+ .option("--json", "Print a JSON envelope.")
3745
+ .action(async (options) => {
3746
+ await handleAsyncAction("mailboxes connect-oauth", options, () => {
3747
+ const exportId = readOption(options.status);
3748
+ if (exportId) {
3749
+ const params = new URLSearchParams({ export_id: exportId });
3750
+ const pollConnection = readOption(options.connection);
3751
+ if (pollConnection)
3752
+ params.set("connection_id", pollConnection);
3753
+ return requestOxygen(`/api/cli/mailboxes/connect-oauth?${params.toString()}`);
3754
+ }
3755
+ const provider = readOption(options.provider);
3756
+ if (!provider) {
3757
+ throw new Error("--provider <google|microsoft> is required (or pass --status <export_id> to poll a run).");
3758
+ }
3759
+ const mailboxes = readCsvOption(options.mailboxes);
3760
+ const connection = readOption(options.connection);
3761
+ return requestOxygen("/api/cli/mailboxes/connect-oauth", {
3762
+ method: "POST",
3763
+ body: {
3764
+ provider,
3765
+ ...(mailboxes.length > 0 ? { mailboxes } : {}),
3766
+ ...(connection ? { connection_id: connection } : {}),
3767
+ },
3768
+ });
3769
+ });
3770
+ }))
3771
+ .addCommand(new Command("warmup")
3772
+ .description("Mailbox warmup via Instantly (BYOK — Instantly bills your account, 0 Oxygen credits).")
3773
+ .addCommand(new Command("enable")
3774
+ .description("Enable Instantly warmup for sending mailboxes and stamp each mailbox's warmup state. Targets the whole pool unless --mailboxes is given.")
3775
+ .option("--mailboxes <list>", "Comma-separated mailbox ids or addresses to warm. Omit to warm the whole pool.")
3776
+ .option("--connection <id>", "Instantly connection id. Defaults to the org's active Instantly connection.")
3777
+ .option("--dry-run", "Simulate without calling Instantly (mailboxes marked pending).")
3778
+ .option("--json", "Print a JSON envelope.")
3779
+ .action(async (options) => {
3780
+ await handleAsyncAction("mailboxes warmup enable", options, () => {
3781
+ const mailboxes = readCsvOption(options.mailboxes);
3782
+ const connection = readOption(options.connection);
3783
+ return requestOxygen("/api/cli/mailboxes/warmup", {
3784
+ method: "POST",
3785
+ body: {
3786
+ ...(mailboxes.length > 0 ? { mailboxes } : {}),
3787
+ ...(connection ? { connection_id: connection } : {}),
3788
+ ...(options.dryRun ? { dry_run: true } : {}),
3789
+ },
3790
+ });
3791
+ });
3792
+ }))
3793
+ .addCommand(new Command("status")
3794
+ .description("Sync Instantly's warmup analytics into the pool, updating each mailbox's warmup state. Targets the whole pool unless --mailboxes is given.")
3795
+ .option("--mailboxes <list>", "Comma-separated mailbox ids or addresses to sync. Omit to sync the whole pool.")
3796
+ .option("--connection <id>", "Instantly connection id. Defaults to the org's active Instantly connection.")
3797
+ .option("--dry-run", "Skip the Instantly call (mailboxes marked pending).")
3798
+ .option("--json", "Print a JSON envelope.")
3799
+ .action(async (options) => {
3800
+ await handleAsyncAction("mailboxes warmup status", options, () => {
3801
+ const params = new URLSearchParams();
3802
+ const mailboxes = readCsvOption(options.mailboxes);
3803
+ if (mailboxes.length > 0)
3804
+ params.set("mailboxes", mailboxes.join(","));
3805
+ const connection = readOption(options.connection);
3806
+ if (connection)
3807
+ params.set("connection_id", connection);
3808
+ if (options.dryRun)
3809
+ params.set("dry_run", "true");
3810
+ const suffix = params.toString();
3811
+ return requestOxygen(`/api/cli/mailboxes/warmup/status${suffix ? `?${suffix}` : ""}`);
3812
+ });
3813
+ }))));
3257
3814
  program
3258
3815
  .command("workflows")
3259
3816
  .description("Durable workflow automation commands.")
@@ -4214,10 +4771,13 @@ function isNetworkTimeoutError(error) {
4214
4771
  }
4215
4772
  function readCompaniesSearchPlanBody(options) {
4216
4773
  const targetCount = readPositiveInt(options.targetCount);
4774
+ const filters = readCompanySearchFilters(options);
4217
4775
  return {
4218
4776
  prompt: readFileIfPresent(options.prompt),
4219
4777
  ...(targetCount !== undefined ? { target_count: targetCount } : {}),
4220
4778
  ...(options.sourceIntent ? { source_intent: options.sourceIntent } : {}),
4779
+ ...(filters ? { filters } : {}),
4780
+ ...(options.estimate ? { estimate: true } : {}),
4221
4781
  ...(options.materializePreview ? { materialize_preview: true } : {}),
4222
4782
  };
4223
4783
  }
@@ -4230,6 +4790,7 @@ function readCompaniesSearchRunBody(options) {
4230
4790
  const maxPages = readPositiveInt(options.maxPages);
4231
4791
  const maxCredits = readPositiveNumber(options.maxCredits);
4232
4792
  const targetCount = readPositiveInt(options.targetCount);
4793
+ const filters = prompt ? readCompanySearchFilters(options) : null;
4233
4794
  return {
4234
4795
  ...(prompt ? { prompt } : {}),
4235
4796
  ...(plan ? { plan } : {}),
@@ -4242,9 +4803,121 @@ function readCompaniesSearchRunBody(options) {
4242
4803
  ...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
4243
4804
  ...(targetCount !== undefined ? { target_count: targetCount } : {}),
4244
4805
  ...(options.sourceIntent ? { source_intent: options.sourceIntent } : {}),
4806
+ ...(filters ? { filters } : {}),
4807
+ ...(prompt && options.estimate ? { estimate: true } : {}),
4245
4808
  ...(options.approved ? { approved: true } : {}),
4246
4809
  };
4247
4810
  }
4811
+ // CompanySearchFilters mirror (structural — the server validates the shape). Builds a
4812
+ // filters object from individual --industries/--countries/--employees/etc. flags, then
4813
+ // lets --filters-json win per top-level filter path so an agent can pass a precise object
4814
+ // while still using convenience flags for the rest.
4815
+ function readCompanySearchFilters(options) {
4816
+ const filters = {};
4817
+ const industries = readIncludeExclude(options.industries, options.excludeIndustries);
4818
+ if (industries)
4819
+ filters.industries = industries;
4820
+ const keywords = readIncludeExclude(options.keywords, options.excludeKeywords);
4821
+ if (keywords)
4822
+ filters.keywords = keywords;
4823
+ const countries = readCsvOption(options.countries);
4824
+ if (countries.length > 0)
4825
+ filters.geo = { countries };
4826
+ const employeeCount = readRangeOption(options.employees, "--employees");
4827
+ if (employeeCount)
4828
+ filters.employee_count = employeeCount;
4829
+ const fundingStages = readCsvOption(options.fundingStages);
4830
+ if (fundingStages.length > 0)
4831
+ filters.funding = { stages: fundingStages };
4832
+ const technologies = readIncludeExclude(options.technologies, undefined);
4833
+ if (technologies)
4834
+ filters.technologies = technologies;
4835
+ const revenue = readRangeOption(options.revenue, "--revenue");
4836
+ if (revenue)
4837
+ filters.revenue_usd = revenue;
4838
+ const founded = readRangeOption(options.founded, "--founded");
4839
+ if (founded)
4840
+ filters.founded_year = founded;
4841
+ const lookalike = readCsvOption(options.lookalike);
4842
+ if (lookalike.length > 0)
4843
+ filters.lookalike_domains = lookalike;
4844
+ const explicit = options.filtersJson ? parseJsonObject(readFileIfPresent(options.filtersJson)) : null;
4845
+ if (explicit) {
4846
+ for (const [key, value] of Object.entries(explicit)) {
4847
+ filters[key] = value;
4848
+ }
4849
+ }
4850
+ return Object.keys(filters).length > 0 ? filters : null;
4851
+ }
4852
+ function readIncludeExclude(include, exclude) {
4853
+ const includeValues = readCsvOption(include);
4854
+ const excludeValues = readCsvOption(exclude);
4855
+ const result = {};
4856
+ if (includeValues.length > 0)
4857
+ result.include = includeValues;
4858
+ if (excludeValues.length > 0)
4859
+ result.exclude = excludeValues;
4860
+ return Object.keys(result).length > 0 ? result : null;
4861
+ }
4862
+ // Range flags accept "20-200" -> { min, max }, "500+" -> { min }, "-50" -> { max },
4863
+ // and a bare "200" -> { min, max } (exact value).
4864
+ function readRangeOption(value, flag) {
4865
+ const raw = readOption(value);
4866
+ if (!raw)
4867
+ return null;
4868
+ if (/^\d+\+$/.test(raw)) {
4869
+ return { min: readRangeNumber(raw.slice(0, -1), flag, raw) };
4870
+ }
4871
+ if (/^-\d+$/.test(raw)) {
4872
+ return { max: readRangeNumber(raw.slice(1), flag, raw) };
4873
+ }
4874
+ if (/^\d+-\d+$/.test(raw)) {
4875
+ const [minRaw, maxRaw] = raw.split("-");
4876
+ const min = readRangeNumber(minRaw, flag, raw);
4877
+ const max = readRangeNumber(maxRaw, flag, raw);
4878
+ if (min > max) {
4879
+ throw new OxygenError("invalid_range", `${flag} min must not exceed max.`, {
4880
+ details: { flag, value: raw },
4881
+ exitCode: 1,
4882
+ });
4883
+ }
4884
+ return { min, max };
4885
+ }
4886
+ if (/^\d+$/.test(raw)) {
4887
+ const bound = readRangeNumber(raw, flag, raw);
4888
+ return { min: bound, max: bound };
4889
+ }
4890
+ throw new OxygenError("invalid_range", `${flag} expects a range like 20-200, 500+, or -50.`, {
4891
+ details: { flag, value: raw },
4892
+ exitCode: 1,
4893
+ });
4894
+ }
4895
+ function readRangeNumber(value, flag, raw) {
4896
+ const parsed = Number(value);
4897
+ if (!Number.isInteger(parsed) || parsed < 0) {
4898
+ throw new OxygenError("invalid_range", `${flag} bounds must be non-negative integers.`, {
4899
+ details: { flag, value: raw },
4900
+ exitCode: 1,
4901
+ });
4902
+ }
4903
+ return parsed;
4904
+ }
4905
+ // Company search has moved to the dedicated companies-search surface. The CLI rejects
4906
+ // `oxygen search plan --kind company_search` client-side so a stale agent gets a typed,
4907
+ // self-correcting error instead of an opaque server 400. Message mirrors
4908
+ // COMPANY_SEARCH_MOVED_MESSAGE in @oxygen/tools (CLI does not import that package).
4909
+ 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).";
4910
+ function assertNotCompanySearchKind(kind) {
4911
+ if (readOption(kind) === "company_search") {
4912
+ throw new OxygenError("use_companies_search", COMPANY_SEARCH_MOVED_MESSAGE, {
4913
+ details: {
4914
+ equivalent_cli: "oxygen companies search plan --prompt <goal>",
4915
+ equivalent_mcp: "oxygen_companies_search_plan",
4916
+ },
4917
+ exitCode: 1,
4918
+ });
4919
+ }
4920
+ }
4248
4921
  function readCompanySearchPlanJson(value) {
4249
4922
  const parsed = parseJsonObject(readFileIfPresent(value));
4250
4923
  const data = parsed.data;
@@ -4252,6 +4925,112 @@ function readCompanySearchPlanJson(value) {
4252
4925
  ? data
4253
4926
  : parsed;
4254
4927
  }
4928
+ function readPeopleSearchPlanBody(options) {
4929
+ const targetCount = readPositiveInt(options.targetCount);
4930
+ const filters = readPeopleSearchFilters(options);
4931
+ return {
4932
+ prompt: readFileIfPresent(options.prompt),
4933
+ ...(targetCount !== undefined ? { target_count: targetCount } : {}),
4934
+ ...(options.sourceIntent ? { source_intent: options.sourceIntent } : {}),
4935
+ ...(filters ? { filters } : {}),
4936
+ ...(options.estimate ? { estimate: true } : {}),
4937
+ ...(options.materializePreview ? { materialize_preview: true } : {}),
4938
+ };
4939
+ }
4940
+ function readPeopleSearchRunBody(options) {
4941
+ const prompt = options.prompt ? readFileIfPresent(options.prompt) : null;
4942
+ const plan = options.planJson ? readPeopleSearchPlanJson(options.planJson) : null;
4943
+ if (!prompt && !plan) {
4944
+ throw new OxygenError("invalid_request", "Pass --prompt or --plan-json.", { exitCode: 1 });
4945
+ }
4946
+ const maxPages = readPositiveInt(options.maxPages);
4947
+ const maxCredits = readPositiveNumber(options.maxCredits);
4948
+ const targetCount = readPositiveInt(options.targetCount);
4949
+ const filters = prompt ? readPeopleSearchFilters(options) : null;
4950
+ return {
4951
+ ...(prompt ? { prompt } : {}),
4952
+ ...(plan ? { plan } : {}),
4953
+ ...(options.routeId ? { route_id: options.routeId } : {}),
4954
+ ...(options.toolId ? { tool_id: options.toolId } : {}),
4955
+ ...(options.table ? { table: options.table } : {}),
4956
+ ...(options.upsertKey ? { upsert_key: options.upsertKey } : {}),
4957
+ ...(options.mode ? { mode: options.mode } : {}),
4958
+ ...(maxPages !== undefined ? { max_pages: maxPages } : {}),
4959
+ ...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
4960
+ ...(targetCount !== undefined ? { target_count: targetCount } : {}),
4961
+ ...(options.sourceIntent ? { source_intent: options.sourceIntent } : {}),
4962
+ ...(filters ? { filters } : {}),
4963
+ ...(prompt && options.estimate ? { estimate: true } : {}),
4964
+ ...(options.approved ? { approved: true } : {}),
4965
+ };
4966
+ }
4967
+ // PeopleSearchFilters mirror (structural — the server validates the shape). Builds a
4968
+ // filters object from individual --titles/--seniorities/etc. flags, then lets --filters-json
4969
+ // win per top-level filter path so an agent can pass a precise object while still using
4970
+ // convenience flags for the rest.
4971
+ function readPeopleSearchFilters(options) {
4972
+ const filters = {};
4973
+ const titles = {};
4974
+ const titleInclude = readCsvOption(options.titles);
4975
+ if (titleInclude.length > 0)
4976
+ titles.include = titleInclude;
4977
+ const adjacent = readCsvOption(options.adjacentTitles);
4978
+ if (adjacent.length > 0)
4979
+ titles.include_adjacent = adjacent;
4980
+ const titleExclude = readCsvOption(options.excludeTitles);
4981
+ if (titleExclude.length > 0)
4982
+ titles.exclude = titleExclude;
4983
+ if (Object.keys(titles).length > 0)
4984
+ filters.titles = titles;
4985
+ const seniorities = readCsvOption(options.seniorities);
4986
+ if (seniorities.length > 0)
4987
+ filters.seniorities = { include: seniorities };
4988
+ const departments = readCsvOption(options.departments);
4989
+ if (departments.length > 0)
4990
+ filters.departments = { include: departments };
4991
+ const keywords = readCsvOption(options.keywords);
4992
+ if (keywords.length > 0)
4993
+ filters.keywords = { include: keywords };
4994
+ const countries = readCsvOption(options.countries);
4995
+ if (countries.length > 0)
4996
+ filters.geo = { countries };
4997
+ const contactability = {};
4998
+ if (options.requireEmail)
4999
+ contactability.require_work_email = true;
5000
+ if (options.requirePhone)
5001
+ contactability.require_mobile = true;
5002
+ if (Object.keys(contactability).length > 0)
5003
+ filters.contactability = contactability;
5004
+ const company = {};
5005
+ const companyDomains = readCsvOption(options.companyDomains);
5006
+ if (companyDomains.length > 0)
5007
+ company.domains = { include: companyDomains };
5008
+ const companyNames = readCsvOption(options.companyNames);
5009
+ if (companyNames.length > 0)
5010
+ company.names = { include: companyNames };
5011
+ const employees = readRangeOption(options.employees, "--employees");
5012
+ if (employees)
5013
+ company.employee_count = employees;
5014
+ if (Object.keys(company).length > 0)
5015
+ filters.company = company;
5016
+ const maxPerCompany = readPositiveInt(options.maxPerCompany);
5017
+ if (maxPerCompany !== undefined)
5018
+ filters.max_results_per_company = maxPerCompany;
5019
+ const explicit = options.filtersJson ? parseJsonObject(readFileIfPresent(options.filtersJson)) : null;
5020
+ if (explicit) {
5021
+ for (const [key, value] of Object.entries(explicit)) {
5022
+ filters[key] = value;
5023
+ }
5024
+ }
5025
+ return Object.keys(filters).length > 0 ? filters : null;
5026
+ }
5027
+ function readPeopleSearchPlanJson(value) {
5028
+ const parsed = parseJsonObject(readFileIfPresent(value));
5029
+ const data = parsed.data;
5030
+ return data && typeof data === "object" && !Array.isArray(data)
5031
+ ? data
5032
+ : parsed;
5033
+ }
4255
5034
  function readCompaniesEnrichBody(table, options) {
4256
5035
  const body = { table };
4257
5036
  const fields = readCsvOption(options.missingFields);
@@ -4372,6 +5151,9 @@ async function importRows(table, options) {
4372
5151
  const sourceHash = hashImportFile(options.file);
4373
5152
  const shouldUseBackground = options.background
4374
5153
  || (!options.sync && parsedRows.length > LARGE_IMPORT_BACKGROUND_ROW_THRESHOLD);
5154
+ const preparedBackgroundTarget = shouldUseBackground && table && !options.create
5155
+ ? await prepareImportTarget(table, options, parsedRows)
5156
+ : null;
4375
5157
  if (shouldUseBackground) {
4376
5158
  // Prefer the object-storage fast path: upload the raw file once via a
4377
5159
  // presigned URL (no request-body limit) so the worker COPY-loads it.
@@ -4379,13 +5161,13 @@ async function importRows(table, options) {
4379
5161
  const staged = await tryEnqueueStagedFileImport(table, options, format, parsedRows, {
4380
5162
  autoBackground: !options.background,
4381
5163
  sourceHash,
4382
- });
5164
+ }, preparedBackgroundTarget);
4383
5165
  if (staged)
4384
5166
  return staged;
4385
5167
  return enqueueImportFile(table, options, format, batchSize, {
4386
5168
  autoBackground: !options.background,
4387
5169
  sourceHash,
4388
- });
5170
+ }, preparedBackgroundTarget);
4389
5171
  }
4390
5172
  const target = await prepareImportTarget(table, options, parsedRows);
4391
5173
  let rowCount = 0;
@@ -4395,28 +5177,30 @@ async function importRows(table, options) {
4395
5177
  const warnings = [];
4396
5178
  let warningsTruncated = false;
4397
5179
  let lastResult = null;
5180
+ let writeRequestCount = 0;
5181
+ let requestTooLargeRetries = 0;
5182
+ let minimumBatchSizeUsed = null;
4398
5183
  for (const batch of chunk(target.rows, batchSize)) {
4399
- const result = await requestOxygen(target.upsertKey ? "/api/cli/tables/rows/upsert" : "/api/cli/tables/rows", {
4400
- method: "POST",
4401
- timeoutMs: 300_000,
4402
- body: {
4403
- table: target.tableRef,
4404
- rows: batch,
4405
- ...(target.upsertKey ? { key: target.upsertKey } : {}),
4406
- },
5184
+ const result = await writeImportBatchWithAutoSplit({
5185
+ tableRef: target.tableRef,
5186
+ upsertKey: target.upsertKey,
5187
+ rows: batch,
4407
5188
  });
4408
- rowCount += readCount(result.rowCount);
4409
- insertedCount += readCount(result.insertedCount);
4410
- updatedCount += readCount(result.updatedCount);
4411
- const batchWarningCount = readCount(result.warningCount);
4412
- warningCount += batchWarningCount;
4413
- const batchWarnings = Array.isArray(result.warnings) ? result.warnings : [];
4414
- for (const warning of batchWarnings) {
5189
+ rowCount += result.rowCount;
5190
+ insertedCount += result.insertedCount;
5191
+ updatedCount += result.updatedCount;
5192
+ warningCount += result.warningCount;
5193
+ for (const warning of result.warnings) {
4415
5194
  if (warnings.length < 20)
4416
5195
  warnings.push(warning);
4417
5196
  }
4418
- warningsTruncated = warningsTruncated || result.warningsTruncated === true || warningCount > warnings.length;
4419
- lastResult = result;
5197
+ warningsTruncated = warningsTruncated || result.warningsTruncated || warningCount > warnings.length;
5198
+ writeRequestCount += result.writeRequestCount;
5199
+ requestTooLargeRetries += result.requestTooLargeRetries;
5200
+ minimumBatchSizeUsed = minimumBatchSizeUsed === null
5201
+ ? result.minimumBatchSizeUsed
5202
+ : Math.min(minimumBatchSizeUsed, result.minimumBatchSizeUsed);
5203
+ lastResult = result.lastResult;
4420
5204
  }
4421
5205
  return {
4422
5206
  table: lastResult?.table ?? null,
@@ -4429,8 +5213,12 @@ async function importRows(table, options) {
4429
5213
  warningCount,
4430
5214
  ...(warningsTruncated ? { warningsTruncated: true } : {}),
4431
5215
  } : {}),
4432
- batchCount: Math.ceil(target.rows.length / batchSize),
5216
+ batchCount: writeRequestCount,
4433
5217
  batchSize,
5218
+ ...(requestTooLargeRetries > 0 ? {
5219
+ requestTooLargeRetries,
5220
+ minimumBatchSizeUsed,
5221
+ } : {}),
4434
5222
  ...(target.tableWebUrl ? { table_web_url: target.tableWebUrl } : {}),
4435
5223
  ...(readRecordString(lastResult, "web_url") ? { web_url: readRecordString(lastResult, "web_url") } : {}),
4436
5224
  };
@@ -4447,13 +5235,14 @@ async function prepareImportTarget(table, options, parsedRows) {
4447
5235
  });
4448
5236
  }
4449
5237
  if (!options.create) {
5238
+ const keyMapping = await resolveExistingTableImportKeyMapping(table, parsedRows, readOption(options.upsertKey) ?? undefined);
4450
5239
  return {
4451
5240
  tableRef: table,
4452
- rows: parsedRows,
5241
+ rows: remapImportRows(parsedRows, keyMapping.sourceKeyMap),
4453
5242
  createdTable: null,
4454
5243
  tableWebUrl: null,
4455
- upsertKey: options.upsertKey,
4456
- sourceKeyMap: null,
5244
+ upsertKey: keyMapping.upsertKey,
5245
+ sourceKeyMap: keyMapping.sourceKeyMap,
4457
5246
  };
4458
5247
  }
4459
5248
  const normalized = normalizeRowsForNewTable(parsedRows);
@@ -4483,8 +5272,137 @@ async function prepareImportTarget(table, options, parsedRows) {
4483
5272
  sourceKeyMap: normalized.keyBySource,
4484
5273
  };
4485
5274
  }
5275
+ async function resolveExistingTableImportKeyMapping(table, rows, upsertKey) {
5276
+ const describe = await requestOxygen("/api/cli/tables/describe", { method: "POST", body: { table } });
5277
+ const resolver = buildExistingTableColumnResolver(describe.columns ?? []);
5278
+ const sourceKeyMap = {};
5279
+ for (const sourceKey of inferImportColumnLabels(rows)) {
5280
+ const resolved = resolveImportSourceKey(sourceKey, resolver);
5281
+ if (resolved && resolved !== sourceKey)
5282
+ sourceKeyMap[sourceKey] = resolved;
5283
+ }
5284
+ return {
5285
+ sourceKeyMap: Object.keys(sourceKeyMap).length > 0 ? sourceKeyMap : null,
5286
+ upsertKey: upsertKey
5287
+ ? resolveImportSourceKey(upsertKey, resolver) ?? upsertKey
5288
+ : undefined,
5289
+ };
5290
+ }
5291
+ function buildExistingTableColumnResolver(columns) {
5292
+ const exact = new Map();
5293
+ const aliases = new Map();
5294
+ for (const column of columns) {
5295
+ if (!column.key)
5296
+ continue;
5297
+ exact.set(column.key, column.key);
5298
+ addImportColumnAlias(aliases, column.label, column.key);
5299
+ addImportColumnAlias(aliases, normalizeImportColumnKey(column.key), column.key);
5300
+ if (column.label)
5301
+ addImportColumnAlias(aliases, normalizeImportColumnKey(column.label), column.key);
5302
+ }
5303
+ return { exact, aliases };
5304
+ }
5305
+ function addImportColumnAlias(aliases, alias, key) {
5306
+ const normalizedAlias = alias?.trim();
5307
+ if (!normalizedAlias)
5308
+ return;
5309
+ const existing = aliases.get(normalizedAlias);
5310
+ if (existing === undefined) {
5311
+ aliases.set(normalizedAlias, key);
5312
+ return;
5313
+ }
5314
+ if (existing !== key)
5315
+ aliases.set(normalizedAlias, null);
5316
+ }
5317
+ function resolveImportSourceKey(sourceKey, resolver) {
5318
+ const trimmed = sourceKey.trim();
5319
+ return resolver.exact.get(sourceKey)
5320
+ ?? resolver.exact.get(trimmed)
5321
+ ?? readImportColumnAlias(resolver.aliases, sourceKey)
5322
+ ?? readImportColumnAlias(resolver.aliases, trimmed)
5323
+ ?? readImportColumnAlias(resolver.aliases, normalizeImportColumnKey(sourceKey));
5324
+ }
5325
+ function readImportColumnAlias(aliases, alias) {
5326
+ return aliases.get(alias) ?? null;
5327
+ }
5328
+ function remapImportRows(rows, sourceKeyMap) {
5329
+ if (!sourceKeyMap)
5330
+ return rows;
5331
+ return rows.map((row) => {
5332
+ const remapped = {};
5333
+ for (const [key, value] of Object.entries(row)) {
5334
+ remapped[sourceKeyMap[key] ?? key] = value;
5335
+ }
5336
+ return remapped;
5337
+ });
5338
+ }
5339
+ async function writeImportBatchWithAutoSplit(input) {
5340
+ try {
5341
+ const result = await requestOxygen(input.upsertKey ? "/api/cli/tables/rows/upsert" : "/api/cli/tables/rows", {
5342
+ method: "POST",
5343
+ timeoutMs: 300_000,
5344
+ body: {
5345
+ table: input.tableRef,
5346
+ rows: input.rows,
5347
+ ...(input.upsertKey ? { key: input.upsertKey } : {}),
5348
+ },
5349
+ });
5350
+ return summarizeImportBatchWrite(result, input.rows.length);
5351
+ }
5352
+ catch (error) {
5353
+ if (!isRequestTooLargeError(error) || input.rows.length <= 1)
5354
+ throw error;
5355
+ const midpoint = Math.ceil(input.rows.length / 2);
5356
+ process.stderr.write(`note: import batch of ${input.rows.length} rows exceeded the request limit; `
5357
+ + `retrying as ${midpoint} and ${input.rows.length - midpoint} row batches.\n`);
5358
+ const first = await writeImportBatchWithAutoSplit({
5359
+ ...input,
5360
+ rows: input.rows.slice(0, midpoint),
5361
+ });
5362
+ const second = await writeImportBatchWithAutoSplit({
5363
+ ...input,
5364
+ rows: input.rows.slice(midpoint),
5365
+ });
5366
+ return combineImportBatchWriteSummaries(first, second, 1);
5367
+ }
5368
+ }
5369
+ function summarizeImportBatchWrite(result, batchSize) {
5370
+ const warningCount = readCount(result.warningCount);
5371
+ const batchWarnings = Array.isArray(result.warnings) ? result.warnings : [];
5372
+ return {
5373
+ rowCount: readCount(result.rowCount),
5374
+ insertedCount: readCount(result.insertedCount),
5375
+ updatedCount: readCount(result.updatedCount),
5376
+ warningCount,
5377
+ warnings: batchWarnings.slice(0, 20),
5378
+ warningsTruncated: result.warningsTruncated === true || warningCount > batchWarnings.length,
5379
+ writeRequestCount: 1,
5380
+ requestTooLargeRetries: 0,
5381
+ minimumBatchSizeUsed: batchSize,
5382
+ lastResult: result,
5383
+ };
5384
+ }
5385
+ function combineImportBatchWriteSummaries(first, second, extraRequestTooLargeRetries) {
5386
+ const warnings = [...first.warnings, ...second.warnings].slice(0, 20);
5387
+ const warningCount = first.warningCount + second.warningCount;
5388
+ return {
5389
+ rowCount: first.rowCount + second.rowCount,
5390
+ insertedCount: first.insertedCount + second.insertedCount,
5391
+ updatedCount: first.updatedCount + second.updatedCount,
5392
+ warningCount,
5393
+ warnings,
5394
+ warningsTruncated: first.warningsTruncated || second.warningsTruncated || warningCount > warnings.length,
5395
+ writeRequestCount: first.writeRequestCount + second.writeRequestCount,
5396
+ requestTooLargeRetries: first.requestTooLargeRetries + second.requestTooLargeRetries + extraRequestTooLargeRetries,
5397
+ minimumBatchSizeUsed: Math.min(first.minimumBatchSizeUsed, second.minimumBatchSizeUsed),
5398
+ lastResult: second.lastResult ?? first.lastResult,
5399
+ };
5400
+ }
5401
+ function isRequestTooLargeError(error) {
5402
+ return error instanceof OxygenError && error.code === "request_too_large";
5403
+ }
4486
5404
  async function tryEnqueueStagedFileImport(// skipcq: JS-R1005
4487
- table, options, format, parsedRows, context) {
5405
+ table, options, format, parsedRows, context, preparedTarget = null) {
4488
5406
  if (options.create && table) {
4489
5407
  throw new OxygenError("invalid_import_target", "Pass either a table argument or --create, not both.", {
4490
5408
  exitCode: 1,
@@ -4521,7 +5439,7 @@ table, options, format, parsedRows, context) {
4521
5439
  return null;
4522
5440
  // Create the table for --create (or resolve the existing ref) before the
4523
5441
  // upload so a presign success always pairs with a real target.
4524
- const target = await prepareImportTarget(table, options, parsedRows);
5442
+ const target = preparedTarget ?? await prepareImportTarget(table, options, parsedRows);
4525
5443
  const controller = new AbortController();
4526
5444
  const timer = setTimeout(() => controller.abort(), 300_000);
4527
5445
  let putResponse;
@@ -4566,7 +5484,7 @@ table, options, format, parsedRows, context) {
4566
5484
  storage_provider: storageProvider,
4567
5485
  };
4568
5486
  }
4569
- async function enqueueImportFile(table, options, format, batchSize, context) {
5487
+ async function enqueueImportFile(table, options, format, batchSize, context, preparedTarget = null) {
4570
5488
  if (options.create && table) {
4571
5489
  throw new OxygenError("invalid_import_target", "Pass either a table argument or --create, not both.", {
4572
5490
  exitCode: 1,
@@ -4587,16 +5505,20 @@ async function enqueueImportFile(table, options, format, batchSize, context) {
4587
5505
  form.append("batch_size", String(batchSize));
4588
5506
  form.append("auto_background", String(context.autoBackground));
4589
5507
  form.append("source_hash", context.sourceHash);
4590
- if (table)
4591
- form.append("table", table);
4592
- if (options.create)
5508
+ const targetTable = preparedTarget?.tableRef ?? table;
5509
+ if (targetTable)
5510
+ form.append("table", targetTable);
5511
+ if (!preparedTarget && options.create)
4593
5512
  form.append("table_name", options.create);
4594
5513
  const project = readOption(options.project);
4595
5514
  if (project)
4596
5515
  form.append("project", project);
4597
- const upsertKey = readOption(options.upsertKey);
5516
+ const upsertKey = preparedTarget?.upsertKey ?? readOption(options.upsertKey);
4598
5517
  if (upsertKey)
4599
5518
  form.append("upsert_key", upsertKey);
5519
+ if (preparedTarget?.sourceKeyMap) {
5520
+ form.append("source_key_map", JSON.stringify(preparedTarget.sourceKeyMap));
5521
+ }
4600
5522
  if (options.sheet)
4601
5523
  form.append("sheet", options.sheet);
4602
5524
  const maxConcurrency = readPositiveInt(options.maxConcurrency);
@@ -5204,7 +6126,7 @@ function formatRows(rows, format, columns) {
5204
6126
  const lines = [renderRow(headers), separator, ...formattedRows.map(renderRow)];
5205
6127
  if (rescuedCount > 0) {
5206
6128
  lines.push("");
5207
- lines.push(`# Note: reformatted ${rescuedCount} cell${rescuedCount === 1 ? "" : "s"} that look numeric in text columns. Run \`oxygen columns retype --to numeric\` to make this permanent.`);
6129
+ lines.push(`# Note: reformatted ${rescuedCount} cell${rescuedCount === 1 ? "" : "s"} that look numeric in text columns. Run \`oxygen columns retype <table> <column> --data-type numeric\` to make this permanent.`);
5208
6130
  }
5209
6131
  return { content: `${lines.join("\n")}\n`, rescuedCount };
5210
6132
  }
@@ -6435,6 +7357,93 @@ function ansi(enabled) {
6435
7357
  function readOption(value) {
6436
7358
  return value?.trim() ? value.trim() : null;
6437
7359
  }
7360
+ // Assemble the POST body for `oxygen feedback`. Reads the local chat transcript
7361
+ // (unless --no-transcript) and attaches a non-sensitive environment snapshot so
7362
+ // the Oxygen team can triage. The transcript read happens here, inside the
7363
+ // command action, so any failure surfaces in the JSON envelope.
7364
+ function buildFeedbackBody(options) {
7365
+ const message = readOption(options.message);
7366
+ if (!message) {
7367
+ throw new OxygenError("invalid_input", 'Provide your feedback with --message "...".', { exitCode: 1 });
7368
+ }
7369
+ const body = {
7370
+ message,
7371
+ category: readOption(options.category) ?? "feedback",
7372
+ context: {
7373
+ cli_version: OXYGEN_VERSION,
7374
+ environment: collectFeedbackEnvironment(),
7375
+ },
7376
+ };
7377
+ if (readOption(options.severity))
7378
+ body.severity = readOption(options.severity);
7379
+ if (options.transcript !== false) {
7380
+ try {
7381
+ const captured = captureCurrentTranscript({
7382
+ sessionId: readOption(options.sessionId),
7383
+ file: readOption(options.file),
7384
+ });
7385
+ if (captured) {
7386
+ const { path: _path, ...payload } = captured;
7387
+ body.transcript = payload;
7388
+ }
7389
+ else {
7390
+ process.stderr.write("note: no local chat transcript found; sending your note without one.\n");
7391
+ }
7392
+ }
7393
+ catch (error) {
7394
+ if (error instanceof TranscriptCaptureError) {
7395
+ throw new OxygenError("transcript_not_found", error.message, { exitCode: 1 });
7396
+ }
7397
+ throw error;
7398
+ }
7399
+ }
7400
+ return body;
7401
+ }
7402
+ function splitCsvOption(value) {
7403
+ if (!value)
7404
+ return [];
7405
+ return value
7406
+ .split(",")
7407
+ .map((entry) => entry.trim())
7408
+ .filter((entry) => entry.length > 0);
7409
+ }
7410
+ function buildSupportTicketBody(options) {
7411
+ const body = { subject: readOption(options.subject) };
7412
+ if (readOption(options.body))
7413
+ body.body = readOption(options.body);
7414
+ if (readOption(options.severity))
7415
+ body.severity = readOption(options.severity);
7416
+ if (readOption(options.category))
7417
+ body.category = readOption(options.category);
7418
+ const context = {};
7419
+ if (readOption(options.operation))
7420
+ context.operation = readOption(options.operation);
7421
+ if (readOption(options.errorCode))
7422
+ context.error_code = readOption(options.errorCode);
7423
+ const runIds = splitCsvOption(options.runIds);
7424
+ const tableIds = splitCsvOption(options.tableIds);
7425
+ const deepLinks = splitCsvOption(options.deepLinks);
7426
+ if (runIds.length)
7427
+ context.run_ids = runIds;
7428
+ if (tableIds.length)
7429
+ context.table_ids = tableIds;
7430
+ if (deepLinks.length)
7431
+ context.deep_links = deepLinks;
7432
+ if (Object.keys(context).length)
7433
+ body.context = context;
7434
+ return body;
7435
+ }
7436
+ function withSupportListQuery(path, options) {
7437
+ const params = new URLSearchParams();
7438
+ const status = readOption(options.status);
7439
+ const limit = readOption(options.limit);
7440
+ if (status)
7441
+ params.set("status", status);
7442
+ if (limit)
7443
+ params.set("limit", limit);
7444
+ const query = params.toString();
7445
+ return query ? `${path}?${query}` : path;
7446
+ }
6438
7447
  function readCsvOption(value) {
6439
7448
  const option = readOption(value);
6440
7449
  if (!option)
@@ -6450,6 +7459,26 @@ function splitCsv(value) {
6450
7459
  .map((entry) => entry.trim())
6451
7460
  .filter(Boolean);
6452
7461
  }
7462
+ // Assemble the optional campaign email binding from the --email-* flags. The
7463
+ // content spec (--email-definition-file) is the author-provided email sequence
7464
+ // that the API compiles to an Instantly campaign on start; provider/connection
7465
+ // pick which Instantly account to bind. Returns undefined when no email flags
7466
+ // are set (a LinkedIn-only campaign).
7467
+ function readCampaignEmailBinding(options) {
7468
+ const provider = readOption(options.emailProvider);
7469
+ const connectionId = readOption(options.emailConnection);
7470
+ const definitionPath = readOption(options.emailDefinitionFile);
7471
+ if (!provider && !connectionId && !definitionPath)
7472
+ return undefined;
7473
+ const definition = definitionPath
7474
+ ? JSON.parse(readFileSync(resolve(definitionPath), "utf8"))
7475
+ : undefined;
7476
+ return {
7477
+ ...(provider ? { provider } : {}),
7478
+ ...(connectionId ? { connection_id: connectionId } : {}),
7479
+ ...(definition !== undefined ? { definition } : {}),
7480
+ };
7481
+ }
6453
7482
  function collectMultiple(value, previous) {
6454
7483
  return [...previous, value];
6455
7484
  }
@@ -6612,6 +7641,9 @@ table, options) {
6612
7641
  ...(readOption(options.emailPatternValidation)
6613
7642
  ? { email_pattern_validation: readOption(options.emailPatternValidation) }
6614
7643
  : {}),
7644
+ ...(readOption(options.phoneWaterfallProfile)
7645
+ ? { phone_waterfall_profile: readOption(options.phoneWaterfallProfile) }
7646
+ : {}),
6615
7647
  ...(options.verifyPhone ? { verify_phone: true } : {}),
6616
7648
  ...(readOption(options.phoneVerificationCredentialMode)
6617
7649
  ? { phone_verification_credential_mode: readOption(options.phoneVerificationCredentialMode) }
@@ -6623,22 +7655,7 @@ table, options) {
6623
7655
  ...(options.onlyMissing ? { only_missing: true } : {}),
6624
7656
  };
6625
7657
  }
6626
- function readWorkingDaysOption(value) {
6627
- if (!readOption(value))
6628
- return undefined;
6629
- const days = readCsvOption(value).map((entry) => {
6630
- const parsed = Number(entry);
6631
- if (!Number.isInteger(parsed) || parsed < 1 || parsed > 7) {
6632
- throw new OxygenError("invalid_request", "--working-days must be comma-separated ISO weekdays 1-7 (1=Mon..7=Sun).", {
6633
- details: { value },
6634
- exitCode: 1,
6635
- });
6636
- }
6637
- return parsed;
6638
- });
6639
- return days;
6640
- }
6641
- function buildLinkedinLimitsBody(// skipcq: JS-R1005 -- CLI body builder maps per-account limit and working-hours flags.
7658
+ function buildLinkedinLimitsBody(// skipcq: JS-R1005 -- CLI body builder maps per-account limit flags + reset timezone.
6642
7659
  options) {
6643
7660
  const limits = {};
6644
7661
  const setLimit = (key, value) => {
@@ -6654,25 +7671,23 @@ options) {
6654
7671
  setLimit("follows_per_day", options.followsPerDay);
6655
7672
  setLimit("likes_per_day", options.likesPerDay);
6656
7673
  setLimit("total_actions_per_day", options.totalActionsPerDay);
7674
+ setLimit("relations_reads_per_day", options.relationsReadsPerDay);
7675
+ setLimit("messages_reads_per_day", options.messagesReadsPerDay);
7676
+ setLimit("searches_per_day", options.searchesPerDay);
7677
+ setLimit("api_reads_per_day", options.apiReadsPerDay);
7678
+ setLimit("total_reads_per_day", options.totalReadsPerDay);
6657
7679
  setLimit("min_action_spacing_seconds", options.minSpacingSeconds);
6658
7680
  setLimit("action_spacing_jitter_seconds", options.spacingJitterSeconds);
7681
+ // Send windows are campaign-scoped; the only per-account time setting is the
7682
+ // timezone the daily counters reset in.
6659
7683
  const workingHours = {};
6660
7684
  const timezone = readOption(options.timezone);
6661
7685
  if (timezone)
6662
7686
  workingHours.timezone = timezone;
6663
- const days = readWorkingDaysOption(options.workingDays);
6664
- if (days !== undefined)
6665
- workingHours.days = days;
6666
- const start = readOption(options.workingStart);
6667
- if (start)
6668
- workingHours.start = start;
6669
- const end = readOption(options.workingEnd);
6670
- if (end)
6671
- workingHours.end = end;
6672
7687
  const hasLimits = Object.keys(limits).length > 0;
6673
7688
  const hasWorkingHours = Object.keys(workingHours).length > 0;
6674
7689
  if (!hasLimits && !hasWorkingHours) {
6675
- 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 });
7690
+ throw new OxygenError("invalid_request", "Pass at least one limit flag (e.g. --invites-per-day) or --timezone.", { exitCode: 1 });
6676
7691
  }
6677
7692
  return {
6678
7693
  ...(hasLimits ? { limits } : {}),