@oxygen-agent/cli 1.152.15 → 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.")
@@ -2288,6 +2383,75 @@ export function createProgram() {
2288
2383
  body: readCompaniesEnrichBody(table, options),
2289
2384
  }));
2290
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
+ })));
2291
2455
  program
2292
2456
  .command("worker")
2293
2457
  .description("Background worker commands.")
@@ -2792,7 +2956,7 @@ export function createProgram() {
2792
2956
  .description("Run an executable Oxygen tool through the HTTPS API.")
2793
2957
  .argument("<tool_id>", "Tool id.")
2794
2958
  .requiredOption("--input-json <json>", "Tool input as a JSON object.")
2795
- .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.")
2796
2960
  .option("--credential-mode <mode>", "Credential mode: managed, user_api_key, or user_oauth.")
2797
2961
  .option("--org <organization_id>", "Optional organization id. Must match the stored CLI token.")
2798
2962
  .option("--org-id <organization_id>", "Optional organization id. Must match the stored CLI token.")
@@ -2841,7 +3005,8 @@ export function createProgram() {
2841
3005
  .option("--provider-order <providers>", "Comma-separated provider order. Overrides the default cost-aware waterfall profile.")
2842
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.")
2843
3007
  .option("--email-pattern-validation <mode>", "Work-email pattern pre-step: leadmagic_valid_only or disabled.")
2844
- .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).")
2845
3010
  .option("--phone-verification-credential-mode <mode>", "ClearoutPhone credential mode for phone verification: managed or user_api_key.")
2846
3011
  .option("--limit <n>", "Rows to estimate. Defaults to 10.")
2847
3012
  .option("--all", "Estimate all rows.")
@@ -2874,7 +3039,8 @@ export function createProgram() {
2874
3039
  .option("--provider-order <providers>", "Comma-separated provider order. Overrides the default cost-aware waterfall profile.")
2875
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.")
2876
3041
  .option("--email-pattern-validation <mode>", "Work-email pattern pre-step: leadmagic_valid_only or disabled.")
2877
- .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).")
2878
3044
  .option("--phone-verification-credential-mode <mode>", "ClearoutPhone credential mode for phone verification: managed or user_api_key.")
2879
3045
  .option("--limit <n>", "Rows to queue.")
2880
3046
  .option("--all", "Queue all rows.")
@@ -3019,20 +3185,20 @@ export function createProgram() {
3019
3185
  });
3020
3186
  })))
3021
3187
  .addCommand(new Command("list")
3022
- .description("List supported Composio integrations and this org's connections.")
3188
+ .description("List supported integrations and this org's connections.")
3023
3189
  .option("--json", "Print a JSON envelope.")
3024
3190
  .action(async (options) => {
3025
3191
  await handleAsyncAction("integrations list", options, async () => requestOxygen("/api/cli/integrations/composio/list"));
3026
3192
  }))
3027
3193
  .addCommand(new Command("connect")
3028
- .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.")
3029
3195
  .argument("<integration_id>", "Integration id, such as 'slack' or 'serpapi'.")
3030
3196
  .option("--api-key <value>", "API key for Composio API-key toolkits (e.g. SerpAPI, Resend).")
3031
3197
  .option("--json", "Print a JSON envelope.")
3032
3198
  .action(async (integrationId, options) => {
3033
3199
  await handleAsyncAction("integrations connect", options, async () => {
3034
3200
  const apiKey = readOption(options.apiKey)?.trim();
3035
- return requestOxygen("/api/cli/integrations/composio/start", {
3201
+ return requestOxygen("/api/cli/integrations/connect", {
3036
3202
  method: "POST",
3037
3203
  body: {
3038
3204
  integration_id: integrationId,
@@ -3042,13 +3208,13 @@ export function createProgram() {
3042
3208
  });
3043
3209
  }))
3044
3210
  .addCommand(new Command("disconnect")
3045
- .description("Disconnect a Composio integration.")
3211
+ .description("Disconnect an integration.")
3046
3212
  .argument("<integration_id>", "Integration id, such as 'slack'.")
3047
3213
  .option("--connection-id <id>", "Disconnect a specific connected account when multiple exist.")
3048
3214
  .option("--json", "Print a JSON envelope.")
3049
3215
  .action(async (integrationId, options) => {
3050
3216
  const connectionId = readOption(options.connectionId)?.trim();
3051
- await handleAsyncAction("integrations disconnect", options, () => requestOxygen("/api/cli/integrations/composio/disconnect", {
3217
+ await handleAsyncAction("integrations disconnect", options, () => requestOxygen("/api/cli/integrations/disconnect", {
3052
3218
  method: "POST",
3053
3219
  body: {
3054
3220
  integration_id: integrationId,
@@ -3092,18 +3258,15 @@ export function createProgram() {
3092
3258
  });
3093
3259
  });
3094
3260
  }));
3095
- program
3096
- .command("linkedin")
3097
- .description("LinkedIn sender account management for the native LinkedIn sequencer (accounts, limits, usage).")
3098
- .addCommand(new Command("accounts")
3099
- .description("Manage the org's connected LinkedIn sender accounts: list, connect, sync, inspect, disconnect, and tune rate limits.")
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.")
3100
3263
  .addCommand(new Command("list")
3101
3264
  .description("List connected LinkedIn sender accounts with health status, rate limits, and today's usage.")
3102
3265
  .option("--status <status>", "Filter by sender status: active, paused, disconnected, restricted, or credentials_required.")
3103
3266
  .option("--no-usage", "Skip today's per-account usage counts for a faster, lighter response.")
3104
3267
  .option("--json", "Print a JSON envelope.")
3105
3268
  .action(async (options) => {
3106
- await handleAsyncAction("linkedin accounts list", options, () => {
3269
+ await handleAsyncAction("senders list", options, () => {
3107
3270
  const params = new URLSearchParams();
3108
3271
  if (options.usage !== false)
3109
3272
  params.set("include_usage", "true");
@@ -3111,7 +3274,7 @@ export function createProgram() {
3111
3274
  if (status)
3112
3275
  params.set("status", status);
3113
3276
  const suffix = params.toString();
3114
- return requestOxygen(`/api/cli/linkedin/accounts${suffix ? `?${suffix}` : ""}`);
3277
+ return requestOxygen(`/api/cli/senders${suffix ? `?${suffix}` : ""}`);
3115
3278
  });
3116
3279
  }))
3117
3280
  .addCommand(new Command("connect")
@@ -3119,9 +3282,9 @@ export function createProgram() {
3119
3282
  .option("--reconnect <connection_id>", "Reconnect an existing connection instead of creating a new one. Accepts a connection id.")
3120
3283
  .option("--json", "Print a JSON envelope.")
3121
3284
  .action(async (options) => {
3122
- await handleAsyncAction("linkedin accounts connect", options, () => {
3285
+ await handleAsyncAction("senders connect", options, () => {
3123
3286
  const reconnect = readOption(options.reconnect);
3124
- return requestOxygen("/api/cli/linkedin/accounts/connect", {
3287
+ return requestOxygen("/api/cli/senders/connect", {
3125
3288
  method: "POST",
3126
3289
  body: {
3127
3290
  ...(reconnect ? { reconnect_connection_id: reconnect } : {}),
@@ -3130,20 +3293,20 @@ export function createProgram() {
3130
3293
  });
3131
3294
  }))
3132
3295
  .addCommand(new Command("get")
3133
- .description("Get one LinkedIn sender account with status, limits, working hours, and usage. <id> accepts a sender account id, connection id, or Unipile account id.")
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.")
3134
3297
  .argument("<id>", "Sender account id, connection id, or Unipile account id.")
3135
3298
  .option("--json", "Print a JSON envelope.")
3136
3299
  .action(async (id, options) => {
3137
- 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)}`));
3138
3301
  }))
3139
3302
  .addCommand(new Command("sync")
3140
3303
  .description("Refresh LinkedIn account state (status, profile) from Unipile. Pass --connection-id to sync one account, or omit it to sync all.")
3141
3304
  .option("--connection-id <id>", "Sync a specific connection id. Defaults to syncing all connected accounts.")
3142
3305
  .option("--json", "Print a JSON envelope.")
3143
3306
  .action(async (options) => {
3144
- await handleAsyncAction("linkedin accounts sync", options, () => {
3307
+ await handleAsyncAction("senders sync", options, () => {
3145
3308
  const connectionId = readOption(options.connectionId);
3146
- return requestOxygen("/api/cli/linkedin/accounts/sync", {
3309
+ return requestOxygen("/api/cli/senders/sync", {
3147
3310
  method: "POST",
3148
3311
  body: {
3149
3312
  ...(connectionId ? { connection_id: connectionId } : {}),
@@ -3156,22 +3319,22 @@ export function createProgram() {
3156
3319
  .argument("<id>", "Sender account id, connection id, or Unipile account id.")
3157
3320
  .option("--json", "Print a JSON envelope.")
3158
3321
  .action(async (id, options) => {
3159
- 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`, {
3160
3323
  method: "POST",
3161
3324
  }));
3162
3325
  }))
3163
3326
  .addCommand(new Command("limits")
3164
- .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.")
3165
3328
  .option("--json", "Print a JSON envelope.")
3166
3329
  .addCommand(new Command("get")
3167
- .description("Show current limits, overrides, working hours, defaults, and safe maximums for an account. <id> accepts a sender account id, connection id, or Unipile account id.")
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.")
3168
3331
  .argument("<id>", "Sender account id, connection id, or Unipile account id.")
3169
3332
  .option("--json", "Print a JSON envelope.")
3170
3333
  .action(async (id, options) => {
3171
- 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`));
3172
3335
  }))
3173
3336
  .addCommand(new Command("set")
3174
- .description("Adjust per-account daily action limits and working hours. Values are clamped to safe maximums (e.g. max 80 invites/day). <id> accepts a sender account id, connection id, or Unipile account id.")
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.")
3175
3338
  .argument("<id>", "Sender account id, connection id, or Unipile account id.")
3176
3339
  .option("--invites-per-day <n>", "Daily LinkedIn connection invites cap.")
3177
3340
  .option("--invites-per-week <n>", "Weekly LinkedIn connection invites cap.")
@@ -3188,21 +3351,18 @@ export function createProgram() {
3188
3351
  .option("--total-reads-per-day <n>", "Daily cap across all read types.")
3189
3352
  .option("--min-spacing-seconds <n>", "Minimum seconds between actions.")
3190
3353
  .option("--spacing-jitter-seconds <n>", "Random jitter seconds added to action spacing.")
3191
- .option("--timezone <tz>", "IANA timezone for working hours, e.g. America/New_York.")
3192
- .option("--working-days <days>", "Comma-separated ISO weekdays the account sends, e.g. 1,2,3,4,5 (1=Mon..7=Sun).")
3193
- .option("--working-start <HH:MM>", "Working hours start time, e.g. 09:00.")
3194
- .option("--working-end <HH:MM>", "Working hours end time, e.g. 17:00.")
3354
+ .option("--timezone <tz>", "IANA timezone the daily action counters reset in, e.g. America/New_York.")
3195
3355
  .option("--json", "Print a JSON envelope.")
3196
3356
  .action(async (id, options) => {
3197
- await handleAsyncAction("linkedin accounts limits set", options, () => {
3357
+ await handleAsyncAction("senders limits set", options, () => {
3198
3358
  const body = buildLinkedinLimitsBody(options);
3199
- return requestOxygen(`/api/cli/linkedin/accounts/${encodeURIComponent(id)}/limits`, {
3359
+ return requestOxygen(`/api/cli/senders/${encodeURIComponent(id)}/limits`, {
3200
3360
  method: "PATCH",
3201
3361
  body,
3202
3362
  });
3203
3363
  });
3204
- }))))
3205
- .addCommand(new Command("inbox")
3364
+ }))));
3365
+ program.addCommand(new Command("inbox")
3206
3366
  .description("LinkedIn unified inbox (unibox): scan conversations across all sender accounts, read threads, and reply.")
3207
3367
  .addCommand(new Command("list")
3208
3368
  .description("List LinkedIn conversations across all connected accounts, newest first.")
@@ -3213,7 +3373,7 @@ export function createProgram() {
3213
3373
  .option("--limit <n>", "Maximum conversations to return (1-200). Defaults to 50.")
3214
3374
  .option("--json", "Print a JSON envelope.")
3215
3375
  .action(async (options) => {
3216
- await handleAsyncAction("linkedin inbox list", options, () => {
3376
+ await handleAsyncAction("inbox list", options, () => {
3217
3377
  const params = new URLSearchParams();
3218
3378
  const account = readOption(options.account);
3219
3379
  if (account)
@@ -3229,7 +3389,7 @@ export function createProgram() {
3229
3389
  if (limit)
3230
3390
  params.set("limit", limit);
3231
3391
  const suffix = params.toString();
3232
- return requestOxygen(`/api/cli/linkedin/inbox${suffix ? `?${suffix}` : ""}`);
3392
+ return requestOxygen(`/api/cli/inbox${suffix ? `?${suffix}` : ""}`);
3233
3393
  });
3234
3394
  }))
3235
3395
  .addCommand(new Command("get")
@@ -3238,13 +3398,13 @@ export function createProgram() {
3238
3398
  .option("--message-limit <n>", "Maximum messages to return (1-500). Defaults to 100.")
3239
3399
  .option("--json", "Print a JSON envelope.")
3240
3400
  .action(async (conversation, options) => {
3241
- await handleAsyncAction("linkedin inbox get", options, () => {
3401
+ await handleAsyncAction("inbox get", options, () => {
3242
3402
  const params = new URLSearchParams();
3243
3403
  const messageLimit = readOption(options.messageLimit);
3244
3404
  if (messageLimit)
3245
3405
  params.set("message_limit", messageLimit);
3246
3406
  const suffix = params.toString();
3247
- return requestOxygen(`/api/cli/linkedin/inbox/${encodeURIComponent(conversation)}${suffix ? `?${suffix}` : ""}`);
3407
+ return requestOxygen(`/api/cli/inbox/${encodeURIComponent(conversation)}${suffix ? `?${suffix}` : ""}`);
3248
3408
  });
3249
3409
  }))
3250
3410
  .addCommand(new Command("send")
@@ -3254,7 +3414,7 @@ export function createProgram() {
3254
3414
  .option("--approved", "Approve and send the message. Without this flag, returns a preview only.")
3255
3415
  .option("--json", "Print a JSON envelope.")
3256
3416
  .action(async (conversation, options) => {
3257
- 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`, {
3258
3418
  method: "POST",
3259
3419
  body: {
3260
3420
  text: readOption(options.text),
@@ -3267,7 +3427,7 @@ export function createProgram() {
3267
3427
  .argument("<conversation>", "Conversation id or Unipile chat id.")
3268
3428
  .option("--json", "Print a JSON envelope.")
3269
3429
  .action(async (conversation, options) => {
3270
- 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`, {
3271
3431
  method: "POST",
3272
3432
  }));
3273
3433
  }))
@@ -3277,7 +3437,7 @@ export function createProgram() {
3277
3437
  .option("--message-limit <n>", "Maximum messages to sync per chat. Defaults to 20.")
3278
3438
  .option("--json", "Print a JSON envelope.")
3279
3439
  .action(async (options) => {
3280
- await handleAsyncAction("linkedin inbox sync", options, () => {
3440
+ await handleAsyncAction("inbox sync", options, () => {
3281
3441
  const body = {};
3282
3442
  const chatLimit = readOption(options.chatLimit);
3283
3443
  if (chatLimit)
@@ -3285,77 +3445,160 @@ export function createProgram() {
3285
3445
  const messageLimit = readOption(options.messageLimit);
3286
3446
  if (messageLimit)
3287
3447
  body.message_limit = Number(messageLimit);
3288
- return requestOxygen("/api/cli/linkedin/inbox/sync", { method: "POST", body });
3448
+ return requestOxygen("/api/cli/inbox/sync", { method: "POST", body });
3289
3449
  });
3290
- })))
3291
- .addCommand(new Command("sequences")
3292
- .description("LinkedIn outreach sequences: multi-step campaigns over a lead table dispatched across sender accounts with rate limits and reply-stop.")
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.")
3293
3453
  .addCommand(new Command("list")
3294
- .description("List LinkedIn sequences with status and credit usage.")
3454
+ .description("List sequences with status, channels, and credit usage.")
3295
3455
  .option("--status <status>", "Filter by status: draft, active, paused, or archived.")
3296
3456
  .option("--json", "Print a JSON envelope.")
3297
3457
  .action(async (options) => {
3298
- await handleAsyncAction("linkedin sequences list", options, () => {
3458
+ await handleAsyncAction("sequences list", options, () => {
3299
3459
  const params = new URLSearchParams();
3300
3460
  const status = readOption(options.status);
3301
3461
  if (status)
3302
3462
  params.set("status", status);
3303
3463
  const suffix = params.toString();
3304
- return requestOxygen(`/api/cli/linkedin/sequences${suffix ? `?${suffix}` : ""}`);
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}` : ""}`);
3305
3491
  });
3306
3492
  }))
3307
3493
  .addCommand(new Command("create")
3308
- .description("Create a draft sequence from a steps JSON file. Assign sender accounts with --senders.")
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-*.")
3309
3495
  .requiredOption("--name <name>", "Human-readable sequence name.")
3310
3496
  .requiredOption("--slug <slug>", "Unique slug for the sequence.")
3311
- .requiredOption("--steps-file <path>", "Path to a JSON file: { \"steps\": [...] }.")
3312
- .requiredOption("--senders <ids>", "Comma-separated sender account ids (or connection / Unipile ids).")
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.")
3313
3500
  .option("--table <id>", "Source table id whose rows supply {{column}} template values.")
3314
3501
  .option("--url-column <key>", "Column key holding each lead's LinkedIn URL/provider id.")
3315
- .option("--max-credits <n>", "Credit cap for the sequence (also set when starting).")
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).")
3316
3506
  .option("--json", "Print a JSON envelope.")
3317
3507
  .action(async (options) => {
3318
- await handleAsyncAction("linkedin sequences create", options, () => {
3508
+ await handleAsyncAction("sequences create", options, () => {
3319
3509
  const stepsPath = readOption(options.stepsFile);
3320
3510
  if (!stepsPath)
3321
3511
  throw new Error("--steps-file is required.");
3322
3512
  const raw = readFileSync(resolve(stepsPath), "utf8");
3323
3513
  const definition = JSON.parse(raw);
3324
- const senders = (readOption(options.senders) ?? "").split(",").map((s) => s.trim()).filter(Boolean);
3514
+ const channels = readCsvOption(options.channels);
3515
+ const senders = readCsvOption(options.senders);
3516
+ const email = readCampaignEmailBinding(options);
3325
3517
  const maxCredits = readPositiveNumber(options.maxCredits);
3326
- return requestOxygen("/api/cli/linkedin/sequences", {
3518
+ return requestOxygen("/api/cli/sequences", {
3327
3519
  method: "POST",
3328
3520
  body: {
3329
3521
  name: readOption(options.name),
3330
3522
  slug: readOption(options.slug),
3331
3523
  definition,
3332
- senders,
3524
+ ...(channels.length > 0 ? { channels } : {}),
3525
+ ...(senders.length > 0 ? { senders } : {}),
3333
3526
  ...(readOption(options.table) ? { source_table_id: readOption(options.table) } : {}),
3334
3527
  ...(readOption(options.urlColumn) ? { linkedin_url_column_key: readOption(options.urlColumn) } : {}),
3528
+ ...(email ? { email } : {}),
3335
3529
  ...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
3336
3530
  },
3337
3531
  });
3338
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
+ });
3339
3582
  }))
3340
3583
  .addCommand(new Command("get")
3341
3584
  .description("Get a sequence's definition, senders, status, and credit usage.")
3342
3585
  .argument("<sequence>", "Sequence id or slug.")
3343
3586
  .option("--json", "Print a JSON envelope.")
3344
3587
  .action(async (sequence, options) => {
3345
- await handleAsyncAction("linkedin sequences get", options, () => requestOxygen(`/api/cli/linkedin/sequences/${encodeURIComponent(sequence)}`));
3588
+ await handleAsyncAction("sequences get", options, () => requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}`));
3346
3589
  }))
3347
3590
  .addCommand(new Command("enroll")
3348
- .description("Enroll leads into a sequence from a JSON file of { leads: [...] }. Idempotent per table row.")
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.")
3349
3592
  .argument("<sequence>", "Sequence id or slug.")
3350
- .requiredOption("--leads-file <path>", "Path to a JSON file: { \"leads\": [{ lead_provider_id, lead_name, row_values }] }.")
3593
+ .requiredOption("--leads-file <path>", "Path to a JSON file: { \"leads\": [{ lead_provider_id, lead_name, table_row_id, row_values }] }.")
3351
3594
  .option("--json", "Print a JSON envelope.")
3352
3595
  .action(async (sequence, options) => {
3353
- await handleAsyncAction("linkedin sequences enroll", options, () => {
3596
+ await handleAsyncAction("sequences enroll", options, () => {
3354
3597
  const leadsPath = readOption(options.leadsFile);
3355
3598
  if (!leadsPath)
3356
3599
  throw new Error("--leads-file is required.");
3357
3600
  const parsed = JSON.parse(readFileSync(resolve(leadsPath), "utf8"));
3358
- return requestOxygen(`/api/cli/linkedin/sequences/${encodeURIComponent(sequence)}/enroll`, {
3601
+ return requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/enroll`, {
3359
3602
  method: "POST",
3360
3603
  body: { leads: parsed.leads ?? [] },
3361
3604
  });
@@ -3369,9 +3612,9 @@ export function createProgram() {
3369
3612
  .option("--dry-run", "Activate in dry-run mode: advance every step with simulated sends, no LinkedIn actions, no credits.")
3370
3613
  .option("--json", "Print a JSON envelope.")
3371
3614
  .action(async (sequence, options) => {
3372
- await handleAsyncAction("linkedin sequences start", options, () => {
3615
+ await handleAsyncAction("sequences start", options, () => {
3373
3616
  const maxCredits = readPositiveNumber(options.maxCredits);
3374
- return requestOxygen(`/api/cli/linkedin/sequences/${encodeURIComponent(sequence)}/start`, {
3617
+ return requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/start`, {
3375
3618
  method: "POST",
3376
3619
  body: {
3377
3620
  ...(options.dryRun ? { dry_run: true } : {}),
@@ -3386,7 +3629,7 @@ export function createProgram() {
3386
3629
  .argument("<sequence>", "Sequence id or slug.")
3387
3630
  .option("--json", "Print a JSON envelope.")
3388
3631
  .action(async (sequence, options) => {
3389
- await handleAsyncAction("linkedin sequences pause", options, () => requestOxygen(`/api/cli/linkedin/sequences/${encodeURIComponent(sequence)}/status`, {
3632
+ await handleAsyncAction("sequences pause", options, () => requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/status`, {
3390
3633
  method: "POST", body: { status: "paused" },
3391
3634
  }));
3392
3635
  }))
@@ -3395,7 +3638,7 @@ export function createProgram() {
3395
3638
  .argument("<sequence>", "Sequence id or slug.")
3396
3639
  .option("--json", "Print a JSON envelope.")
3397
3640
  .action(async (sequence, options) => {
3398
- await handleAsyncAction("linkedin sequences resume", options, () => requestOxygen(`/api/cli/linkedin/sequences/${encodeURIComponent(sequence)}/status`, {
3641
+ await handleAsyncAction("sequences resume", options, () => requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/status`, {
3399
3642
  method: "POST", body: { status: "active" },
3400
3643
  }));
3401
3644
  }))
@@ -3404,7 +3647,7 @@ export function createProgram() {
3404
3647
  .argument("<sequence>", "Sequence id or slug.")
3405
3648
  .option("--json", "Print a JSON envelope.")
3406
3649
  .action(async (sequence, options) => {
3407
- await handleAsyncAction("linkedin sequences archive", options, () => requestOxygen(`/api/cli/linkedin/sequences/${encodeURIComponent(sequence)}/status`, {
3650
+ await handleAsyncAction("sequences archive", options, () => requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/status`, {
3408
3651
  method: "POST", body: { status: "archived" },
3409
3652
  }));
3410
3653
  }))
@@ -3415,7 +3658,7 @@ export function createProgram() {
3415
3658
  .option("--limit <n>", "Maximum enrollments to return (1-500).")
3416
3659
  .option("--json", "Print a JSON envelope.")
3417
3660
  .action(async (sequence, options) => {
3418
- await handleAsyncAction("linkedin sequences enrollments", options, () => {
3661
+ await handleAsyncAction("sequences enrollments", options, () => {
3419
3662
  const params = new URLSearchParams();
3420
3663
  const status = readOption(options.status);
3421
3664
  if (status)
@@ -3424,7 +3667,7 @@ export function createProgram() {
3424
3667
  if (limit)
3425
3668
  params.set("limit", limit);
3426
3669
  const suffix = params.toString();
3427
- return requestOxygen(`/api/cli/linkedin/sequences/${encodeURIComponent(sequence)}/enrollments${suffix ? `?${suffix}` : ""}`);
3670
+ return requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/enrollments${suffix ? `?${suffix}` : ""}`);
3428
3671
  });
3429
3672
  }))
3430
3673
  .addCommand(new Command("stats")
@@ -3432,8 +3675,142 @@ export function createProgram() {
3432
3675
  .argument("<sequence>", "Sequence id or slug.")
3433
3676
  .option("--json", "Print a JSON envelope.")
3434
3677
  .action(async (sequence, options) => {
3435
- await handleAsyncAction("linkedin sequences stats", options, () => requestOxygen(`/api/cli/linkedin/sequences/${encodeURIComponent(sequence)}/stats`));
3678
+ await handleAsyncAction("sequences stats", options, () => requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/stats`));
3436
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
+ }))));
3437
3814
  program
3438
3815
  .command("workflows")
3439
3816
  .description("Durable workflow automation commands.")
@@ -4548,6 +4925,112 @@ function readCompanySearchPlanJson(value) {
4548
4925
  ? data
4549
4926
  : parsed;
4550
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
+ }
4551
5034
  function readCompaniesEnrichBody(table, options) {
4552
5035
  const body = { table };
4553
5036
  const fields = readCsvOption(options.missingFields);
@@ -4668,6 +5151,9 @@ async function importRows(table, options) {
4668
5151
  const sourceHash = hashImportFile(options.file);
4669
5152
  const shouldUseBackground = options.background
4670
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;
4671
5157
  if (shouldUseBackground) {
4672
5158
  // Prefer the object-storage fast path: upload the raw file once via a
4673
5159
  // presigned URL (no request-body limit) so the worker COPY-loads it.
@@ -4675,13 +5161,13 @@ async function importRows(table, options) {
4675
5161
  const staged = await tryEnqueueStagedFileImport(table, options, format, parsedRows, {
4676
5162
  autoBackground: !options.background,
4677
5163
  sourceHash,
4678
- });
5164
+ }, preparedBackgroundTarget);
4679
5165
  if (staged)
4680
5166
  return staged;
4681
5167
  return enqueueImportFile(table, options, format, batchSize, {
4682
5168
  autoBackground: !options.background,
4683
5169
  sourceHash,
4684
- });
5170
+ }, preparedBackgroundTarget);
4685
5171
  }
4686
5172
  const target = await prepareImportTarget(table, options, parsedRows);
4687
5173
  let rowCount = 0;
@@ -4691,28 +5177,30 @@ async function importRows(table, options) {
4691
5177
  const warnings = [];
4692
5178
  let warningsTruncated = false;
4693
5179
  let lastResult = null;
5180
+ let writeRequestCount = 0;
5181
+ let requestTooLargeRetries = 0;
5182
+ let minimumBatchSizeUsed = null;
4694
5183
  for (const batch of chunk(target.rows, batchSize)) {
4695
- const result = await requestOxygen(target.upsertKey ? "/api/cli/tables/rows/upsert" : "/api/cli/tables/rows", {
4696
- method: "POST",
4697
- timeoutMs: 300_000,
4698
- body: {
4699
- table: target.tableRef,
4700
- rows: batch,
4701
- ...(target.upsertKey ? { key: target.upsertKey } : {}),
4702
- },
5184
+ const result = await writeImportBatchWithAutoSplit({
5185
+ tableRef: target.tableRef,
5186
+ upsertKey: target.upsertKey,
5187
+ rows: batch,
4703
5188
  });
4704
- rowCount += readCount(result.rowCount);
4705
- insertedCount += readCount(result.insertedCount);
4706
- updatedCount += readCount(result.updatedCount);
4707
- const batchWarningCount = readCount(result.warningCount);
4708
- warningCount += batchWarningCount;
4709
- const batchWarnings = Array.isArray(result.warnings) ? result.warnings : [];
4710
- 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) {
4711
5194
  if (warnings.length < 20)
4712
5195
  warnings.push(warning);
4713
5196
  }
4714
- warningsTruncated = warningsTruncated || result.warningsTruncated === true || warningCount > warnings.length;
4715
- 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;
4716
5204
  }
4717
5205
  return {
4718
5206
  table: lastResult?.table ?? null,
@@ -4725,8 +5213,12 @@ async function importRows(table, options) {
4725
5213
  warningCount,
4726
5214
  ...(warningsTruncated ? { warningsTruncated: true } : {}),
4727
5215
  } : {}),
4728
- batchCount: Math.ceil(target.rows.length / batchSize),
5216
+ batchCount: writeRequestCount,
4729
5217
  batchSize,
5218
+ ...(requestTooLargeRetries > 0 ? {
5219
+ requestTooLargeRetries,
5220
+ minimumBatchSizeUsed,
5221
+ } : {}),
4730
5222
  ...(target.tableWebUrl ? { table_web_url: target.tableWebUrl } : {}),
4731
5223
  ...(readRecordString(lastResult, "web_url") ? { web_url: readRecordString(lastResult, "web_url") } : {}),
4732
5224
  };
@@ -4743,13 +5235,14 @@ async function prepareImportTarget(table, options, parsedRows) {
4743
5235
  });
4744
5236
  }
4745
5237
  if (!options.create) {
5238
+ const keyMapping = await resolveExistingTableImportKeyMapping(table, parsedRows, readOption(options.upsertKey) ?? undefined);
4746
5239
  return {
4747
5240
  tableRef: table,
4748
- rows: parsedRows,
5241
+ rows: remapImportRows(parsedRows, keyMapping.sourceKeyMap),
4749
5242
  createdTable: null,
4750
5243
  tableWebUrl: null,
4751
- upsertKey: options.upsertKey,
4752
- sourceKeyMap: null,
5244
+ upsertKey: keyMapping.upsertKey,
5245
+ sourceKeyMap: keyMapping.sourceKeyMap,
4753
5246
  };
4754
5247
  }
4755
5248
  const normalized = normalizeRowsForNewTable(parsedRows);
@@ -4779,8 +5272,137 @@ async function prepareImportTarget(table, options, parsedRows) {
4779
5272
  sourceKeyMap: normalized.keyBySource,
4780
5273
  };
4781
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
+ }
4782
5404
  async function tryEnqueueStagedFileImport(// skipcq: JS-R1005
4783
- table, options, format, parsedRows, context) {
5405
+ table, options, format, parsedRows, context, preparedTarget = null) {
4784
5406
  if (options.create && table) {
4785
5407
  throw new OxygenError("invalid_import_target", "Pass either a table argument or --create, not both.", {
4786
5408
  exitCode: 1,
@@ -4817,7 +5439,7 @@ table, options, format, parsedRows, context) {
4817
5439
  return null;
4818
5440
  // Create the table for --create (or resolve the existing ref) before the
4819
5441
  // upload so a presign success always pairs with a real target.
4820
- const target = await prepareImportTarget(table, options, parsedRows);
5442
+ const target = preparedTarget ?? await prepareImportTarget(table, options, parsedRows);
4821
5443
  const controller = new AbortController();
4822
5444
  const timer = setTimeout(() => controller.abort(), 300_000);
4823
5445
  let putResponse;
@@ -4862,7 +5484,7 @@ table, options, format, parsedRows, context) {
4862
5484
  storage_provider: storageProvider,
4863
5485
  };
4864
5486
  }
4865
- async function enqueueImportFile(table, options, format, batchSize, context) {
5487
+ async function enqueueImportFile(table, options, format, batchSize, context, preparedTarget = null) {
4866
5488
  if (options.create && table) {
4867
5489
  throw new OxygenError("invalid_import_target", "Pass either a table argument or --create, not both.", {
4868
5490
  exitCode: 1,
@@ -4883,16 +5505,20 @@ async function enqueueImportFile(table, options, format, batchSize, context) {
4883
5505
  form.append("batch_size", String(batchSize));
4884
5506
  form.append("auto_background", String(context.autoBackground));
4885
5507
  form.append("source_hash", context.sourceHash);
4886
- if (table)
4887
- form.append("table", table);
4888
- if (options.create)
5508
+ const targetTable = preparedTarget?.tableRef ?? table;
5509
+ if (targetTable)
5510
+ form.append("table", targetTable);
5511
+ if (!preparedTarget && options.create)
4889
5512
  form.append("table_name", options.create);
4890
5513
  const project = readOption(options.project);
4891
5514
  if (project)
4892
5515
  form.append("project", project);
4893
- const upsertKey = readOption(options.upsertKey);
5516
+ const upsertKey = preparedTarget?.upsertKey ?? readOption(options.upsertKey);
4894
5517
  if (upsertKey)
4895
5518
  form.append("upsert_key", upsertKey);
5519
+ if (preparedTarget?.sourceKeyMap) {
5520
+ form.append("source_key_map", JSON.stringify(preparedTarget.sourceKeyMap));
5521
+ }
4896
5522
  if (options.sheet)
4897
5523
  form.append("sheet", options.sheet);
4898
5524
  const maxConcurrency = readPositiveInt(options.maxConcurrency);
@@ -5500,7 +6126,7 @@ function formatRows(rows, format, columns) {
5500
6126
  const lines = [renderRow(headers), separator, ...formattedRows.map(renderRow)];
5501
6127
  if (rescuedCount > 0) {
5502
6128
  lines.push("");
5503
- 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.`);
5504
6130
  }
5505
6131
  return { content: `${lines.join("\n")}\n`, rescuedCount };
5506
6132
  }
@@ -6731,6 +7357,93 @@ function ansi(enabled) {
6731
7357
  function readOption(value) {
6732
7358
  return value?.trim() ? value.trim() : null;
6733
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
+ }
6734
7447
  function readCsvOption(value) {
6735
7448
  const option = readOption(value);
6736
7449
  if (!option)
@@ -6746,6 +7459,26 @@ function splitCsv(value) {
6746
7459
  .map((entry) => entry.trim())
6747
7460
  .filter(Boolean);
6748
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
+ }
6749
7482
  function collectMultiple(value, previous) {
6750
7483
  return [...previous, value];
6751
7484
  }
@@ -6908,6 +7641,9 @@ table, options) {
6908
7641
  ...(readOption(options.emailPatternValidation)
6909
7642
  ? { email_pattern_validation: readOption(options.emailPatternValidation) }
6910
7643
  : {}),
7644
+ ...(readOption(options.phoneWaterfallProfile)
7645
+ ? { phone_waterfall_profile: readOption(options.phoneWaterfallProfile) }
7646
+ : {}),
6911
7647
  ...(options.verifyPhone ? { verify_phone: true } : {}),
6912
7648
  ...(readOption(options.phoneVerificationCredentialMode)
6913
7649
  ? { phone_verification_credential_mode: readOption(options.phoneVerificationCredentialMode) }
@@ -6919,22 +7655,7 @@ table, options) {
6919
7655
  ...(options.onlyMissing ? { only_missing: true } : {}),
6920
7656
  };
6921
7657
  }
6922
- function readWorkingDaysOption(value) {
6923
- if (!readOption(value))
6924
- return undefined;
6925
- const days = readCsvOption(value).map((entry) => {
6926
- const parsed = Number(entry);
6927
- if (!Number.isInteger(parsed) || parsed < 1 || parsed > 7) {
6928
- throw new OxygenError("invalid_request", "--working-days must be comma-separated ISO weekdays 1-7 (1=Mon..7=Sun).", {
6929
- details: { value },
6930
- exitCode: 1,
6931
- });
6932
- }
6933
- return parsed;
6934
- });
6935
- return days;
6936
- }
6937
- function buildLinkedinLimitsBody(// skipcq: JS-R1005 -- CLI body builder maps per-account limit and working-hours flags.
7658
+ function buildLinkedinLimitsBody(// skipcq: JS-R1005 -- CLI body builder maps per-account limit flags + reset timezone.
6938
7659
  options) {
6939
7660
  const limits = {};
6940
7661
  const setLimit = (key, value) => {
@@ -6957,23 +7678,16 @@ options) {
6957
7678
  setLimit("total_reads_per_day", options.totalReadsPerDay);
6958
7679
  setLimit("min_action_spacing_seconds", options.minSpacingSeconds);
6959
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.
6960
7683
  const workingHours = {};
6961
7684
  const timezone = readOption(options.timezone);
6962
7685
  if (timezone)
6963
7686
  workingHours.timezone = timezone;
6964
- const days = readWorkingDaysOption(options.workingDays);
6965
- if (days !== undefined)
6966
- workingHours.days = days;
6967
- const start = readOption(options.workingStart);
6968
- if (start)
6969
- workingHours.start = start;
6970
- const end = readOption(options.workingEnd);
6971
- if (end)
6972
- workingHours.end = end;
6973
7687
  const hasLimits = Object.keys(limits).length > 0;
6974
7688
  const hasWorkingHours = Object.keys(workingHours).length > 0;
6975
7689
  if (!hasLimits && !hasWorkingHours) {
6976
- throw new OxygenError("invalid_request", "Pass at least one limit flag (e.g. --invites-per-day) or working-hours flag (e.g. --timezone, --working-days).", { exitCode: 1 });
7690
+ throw new OxygenError("invalid_request", "Pass at least one limit flag (e.g. --invites-per-day) or --timezone.", { exitCode: 1 });
6977
7691
  }
6978
7692
  return {
6979
7693
  ...(hasLimits ? { limits } : {}),