@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/README.md +2 -2
- package/dist/http-client.js +40 -2
- package/dist/index.js +1129 -114
- package/dist/transcript.d.ts +21 -0
- package/dist/transcript.js +208 -0
- package/node_modules/@oxygen/shared/dist/index.d.ts +4 -0
- package/node_modules/@oxygen/shared/dist/index.js +9 -0
- package/node_modules/@oxygen/shared/dist/linkedin-sequences.d.ts +106 -0
- package/node_modules/@oxygen/shared/dist/linkedin-sequences.js +64 -0
- package/node_modules/@oxygen/shared/dist/log.js +12 -4
- package/node_modules/@oxygen/shared/dist/sequences.d.ts +238 -0
- package/node_modules/@oxygen/shared/dist/sequences.js +501 -0
- package/node_modules/@oxygen/shared/dist/sql-error.d.ts +43 -0
- package/node_modules/@oxygen/shared/dist/sql-error.js +318 -0
- package/node_modules/@oxygen/shared/dist/telemetry.js +26 -3
- package/node_modules/@oxygen/shared/dist/version.d.ts +2 -2
- package/node_modules/@oxygen/shared/dist/version.js +5 -2
- package/node_modules/@oxygen/workflows/dist/index.d.ts +0 -19
- package/node_modules/@oxygen/workflows/dist/index.js +16 -19
- package/package.json +1 -1
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
|
|
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:
|
|
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, () =>
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
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
|
|
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("--
|
|
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("--
|
|
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
|
|
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
|
|
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/
|
|
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
|
|
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/
|
|
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
|
-
.
|
|
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("
|
|
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/
|
|
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("
|
|
3285
|
+
await handleAsyncAction("senders connect", options, () => {
|
|
3094
3286
|
const reconnect = readOption(options.reconnect);
|
|
3095
|
-
return requestOxygen("/api/cli/
|
|
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,
|
|
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("
|
|
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("
|
|
3307
|
+
await handleAsyncAction("senders sync", options, () => {
|
|
3116
3308
|
const connectionId = readOption(options.connectionId);
|
|
3117
|
-
return requestOxygen("/api/cli/
|
|
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("
|
|
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
|
|
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,
|
|
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("
|
|
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
|
|
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
|
|
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("
|
|
3357
|
+
await handleAsyncAction("senders limits set", options, () => {
|
|
3164
3358
|
const body = buildLinkedinLimitsBody(options);
|
|
3165
|
-
return requestOxygen(`/api/cli/
|
|
3359
|
+
return requestOxygen(`/api/cli/senders/${encodeURIComponent(id)}/limits`, {
|
|
3166
3360
|
method: "PATCH",
|
|
3167
3361
|
body,
|
|
3168
3362
|
});
|
|
3169
3363
|
});
|
|
3170
|
-
}))))
|
|
3171
|
-
|
|
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("
|
|
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/
|
|
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("
|
|
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/
|
|
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("
|
|
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("
|
|
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("
|
|
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/
|
|
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
|
|
4400
|
-
|
|
4401
|
-
|
|
4402
|
-
|
|
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 +=
|
|
4409
|
-
insertedCount +=
|
|
4410
|
-
updatedCount +=
|
|
4411
|
-
|
|
4412
|
-
|
|
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
|
|
4419
|
-
|
|
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:
|
|
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:
|
|
4456
|
-
sourceKeyMap:
|
|
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
|
-
|
|
4591
|
-
|
|
4592
|
-
|
|
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 --
|
|
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
|
|
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
|
|
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 } : {}),
|