@oxygen-agent/cli 1.225.28 → 1.233.8
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 +1 -1
- package/dist/index.js +356 -18
- package/dist/local-custom-http-column.js +378 -141
- package/node_modules/@oxygen/shared/dist/email-unsubscribe-token.d.ts +62 -0
- package/node_modules/@oxygen/shared/dist/email-unsubscribe-token.js +91 -0
- package/node_modules/@oxygen/shared/dist/index.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/index.js +2 -0
- package/node_modules/@oxygen/shared/dist/linkedin-post-url.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/linkedin-post-url.js +56 -0
- package/node_modules/@oxygen/shared/dist/select-options.d.ts +9 -0
- package/node_modules/@oxygen/shared/dist/select-options.js +11 -0
- package/node_modules/@oxygen/shared/dist/sequences.d.ts +8 -0
- package/node_modules/@oxygen/shared/dist/sequences.js +15 -0
- package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.js +1 -1
- package/node_modules/@oxygen/workflows/dist/index.d.ts +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
package/dist/index.js
CHANGED
|
@@ -428,9 +428,66 @@ function buildCrmSyncImportBody(provider, options) {
|
|
|
428
428
|
...(options.approved ? { approved: true } : {}),
|
|
429
429
|
};
|
|
430
430
|
}
|
|
431
|
-
function
|
|
431
|
+
function buildCrmSyncConfigureBody(options) {
|
|
432
|
+
return {
|
|
433
|
+
provider: options.provider,
|
|
434
|
+
object: options.object,
|
|
435
|
+
mode: resolveCrmSetupMode(options),
|
|
436
|
+
...(readOption(options.direction) ? { direction: readOption(options.direction) } : {}),
|
|
437
|
+
...(readOption(options.cron) ? { cron: readOption(options.cron) } : {}),
|
|
438
|
+
...(readOption(options.timezone) ? { timezone: readOption(options.timezone) } : {}),
|
|
439
|
+
...(readPositiveInt(options.maxRows) !== undefined ? { max_rows_per_cycle: readPositiveInt(options.maxRows) } : {}),
|
|
440
|
+
...(readPositiveNumber(options.maxCredits) !== undefined ? { max_credits: readPositiveNumber(options.maxCredits) } : {}),
|
|
441
|
+
...(options.approved ? { approved: true } : {}),
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
function buildCrmSyncRunBody(options) {
|
|
445
|
+
return {
|
|
446
|
+
provider: options.provider,
|
|
447
|
+
object: options.object,
|
|
448
|
+
mode: resolveCrmSetupMode(options),
|
|
449
|
+
...(readPositiveInt(options.maxRows) !== undefined ? { max_rows_per_cycle: readPositiveInt(options.maxRows) } : {}),
|
|
450
|
+
...(readPositiveNumber(options.maxCredits) !== undefined ? { max_credits: readPositiveNumber(options.maxCredits) } : {}),
|
|
451
|
+
...(options.approved ? { approved: true } : {}),
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
function buildCrmSyncEnableBody(options) {
|
|
455
|
+
return {
|
|
456
|
+
provider: options.provider,
|
|
457
|
+
object: options.object,
|
|
458
|
+
...(readOption(options.direction) ? { direction: readOption(options.direction) } : {}),
|
|
459
|
+
...(readOption(options.cron) ? { cron: readOption(options.cron) } : {}),
|
|
460
|
+
...(readOption(options.timezone) ? { timezone: readOption(options.timezone) } : {}),
|
|
461
|
+
...(readPositiveInt(options.maxRows) !== undefined ? { max_rows_per_cycle: readPositiveInt(options.maxRows) } : {}),
|
|
462
|
+
...(readPositiveNumber(options.maxCredits) !== undefined ? { max_credits: readPositiveNumber(options.maxCredits) } : {}),
|
|
463
|
+
...(options.approved ? { approved: true } : {}),
|
|
464
|
+
// Commander maps --no-run-now to runNow:false (default true); only send the
|
|
465
|
+
// opt-out so the route's default (run an immediate first cycle) holds.
|
|
466
|
+
...(options.runNow === false ? { run_now: false } : {}),
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
function buildCrmSyncDisableBody(options) {
|
|
470
|
+
return { provider: options.provider, object: options.object };
|
|
471
|
+
}
|
|
472
|
+
function buildCrmSyncConfigStatusPath(options) {
|
|
473
|
+
const runId = readOption(options.runId);
|
|
474
|
+
const provider = readOption(options.provider);
|
|
475
|
+
const object = readOption(options.object);
|
|
476
|
+
const hasPair = provider !== null && object !== null;
|
|
477
|
+
if (runId !== null && (provider !== null || object !== null)) {
|
|
478
|
+
throw new OxygenError("conflicting_flags", "Pass either --run-id or --provider/--object, not both.", { exitCode: 1 });
|
|
479
|
+
}
|
|
480
|
+
if (runId === null && !hasPair) {
|
|
481
|
+
throw new OxygenError("missing_target", "Pass --run-id for a single run, or --provider and --object for a standing sync config.", { exitCode: 1 });
|
|
482
|
+
}
|
|
432
483
|
const params = new URLSearchParams();
|
|
433
|
-
|
|
484
|
+
if (runId !== null) {
|
|
485
|
+
params.set("run_id", runId);
|
|
486
|
+
}
|
|
487
|
+
else {
|
|
488
|
+
params.set("provider", provider);
|
|
489
|
+
params.set("object", object);
|
|
490
|
+
}
|
|
434
491
|
return `/api/cli/crm/sync/status?${params.toString()}`;
|
|
435
492
|
}
|
|
436
493
|
function buildCrmSyncLinksPath(object, rowId) {
|
|
@@ -1625,7 +1682,7 @@ export function createProgram() {
|
|
|
1625
1682
|
await handleAsyncAction("crm automation audit", options, () => requestOxygen(buildCrmAutomationAuditPath(options)));
|
|
1626
1683
|
})))
|
|
1627
1684
|
.addCommand(new Command("sync")
|
|
1628
|
-
.description("
|
|
1685
|
+
.description("Bidirectionally sync a connected CRM (HubSpot/Attio) with Oxygen CRM objects: import records, configure a standing two-way sync, run a reconciliation cycle, and inspect runs and provider links.")
|
|
1629
1686
|
.addCommand(new Command("import")
|
|
1630
1687
|
.description("Import provider records into an Oxygen CRM object as a durable workflow run. Defaults to dry-run.")
|
|
1631
1688
|
.argument("<provider>", "Connected CRM to import from: hubspot or attio.")
|
|
@@ -1642,13 +1699,84 @@ export function createProgram() {
|
|
|
1642
1699
|
method: "POST",
|
|
1643
1700
|
body: buildCrmSyncImportBody(provider, options),
|
|
1644
1701
|
}));
|
|
1702
|
+
}))
|
|
1703
|
+
.addCommand(new Command("configure")
|
|
1704
|
+
.description("Configure a standing bidirectional sync for one CRM object pair. Defaults to dry-run, which returns a real preview of the next cycle.")
|
|
1705
|
+
.requiredOption("--provider <provider>", "Connected CRM: hubspot or attio.")
|
|
1706
|
+
.requiredOption("--object <object>", "Provider object: hubspot contacts|companies, or attio people|companies.")
|
|
1707
|
+
.option("--direction <direction>", "Sync direction: inbound, outbound, or bidirectional. Defaults to bidirectional.")
|
|
1708
|
+
.option("--cron <cron>", "Cron expression for the scheduled reconciliation cadence, e.g. '*/15 * * * *'.")
|
|
1709
|
+
.option("--timezone <tz>", "IANA timezone the cron schedule runs in, e.g. America/New_York. Defaults to UTC.")
|
|
1710
|
+
.option("--max-rows <n>", "Per-cycle row cap. The real volume brake on provider writes (BYOK writes cost 0 Oxygen credits).")
|
|
1711
|
+
.option("--max-credits <n>", "Managed-credit + external-quota cap per cycle.")
|
|
1712
|
+
.option("--approved", "Confirm a live configuration after inspecting a dry run.")
|
|
1713
|
+
.option("--dry-run", "Preview the configuration and next cycle without writing. Default.")
|
|
1714
|
+
.option("--live", "Write the configuration. Requires --approved and --max-credits.")
|
|
1715
|
+
.option("--json", "Print a JSON envelope.")
|
|
1716
|
+
.action(async (options) => {
|
|
1717
|
+
await handleAsyncAction("crm sync configure", options, () => requestOxygen("/api/cli/crm/sync/configure", {
|
|
1718
|
+
method: "POST",
|
|
1719
|
+
body: buildCrmSyncConfigureBody(options),
|
|
1720
|
+
}));
|
|
1721
|
+
}))
|
|
1722
|
+
.addCommand(new Command("run")
|
|
1723
|
+
.description("Run one bidirectional reconciliation cycle for a configured CRM object pair as a durable workflow run. Defaults to dry-run.")
|
|
1724
|
+
.requiredOption("--provider <provider>", "Connected CRM: hubspot or attio.")
|
|
1725
|
+
.requiredOption("--object <object>", "Provider object: hubspot contacts|companies, or attio people|companies.")
|
|
1726
|
+
.option("--max-rows <n>", "Per-cycle row cap. The real volume brake on provider writes (BYOK writes cost 0 Oxygen credits).")
|
|
1727
|
+
.option("--max-credits <n>", "Managed-credit + external-quota cap for this cycle.")
|
|
1728
|
+
.option("--approved", "Confirm a live cycle after inspecting a dry run.")
|
|
1729
|
+
.option("--dry-run", "Preview the cycle without pulling or pushing. Default.")
|
|
1730
|
+
.option("--live", "Run the live reconciliation cycle. Requires --approved and --max-credits.")
|
|
1731
|
+
.option("--json", "Print a JSON envelope.")
|
|
1732
|
+
.action(async (options) => {
|
|
1733
|
+
await handleAsyncAction("crm sync run", options, () => requestOxygen("/api/cli/crm/sync/run", {
|
|
1734
|
+
method: "POST",
|
|
1735
|
+
body: buildCrmSyncRunBody(options),
|
|
1736
|
+
}));
|
|
1737
|
+
}))
|
|
1738
|
+
.addCommand(new Command("enable")
|
|
1739
|
+
.description("Enable continuous (scheduled) bidirectional sync for one CRM object pair: arms the cron trigger + the standing write permission, and runs an immediate first cycle. Always live — requires --approved, --max-credits, and --max-rows.")
|
|
1740
|
+
.requiredOption("--provider <provider>", "Connected CRM: hubspot or attio.")
|
|
1741
|
+
.requiredOption("--object <object>", "Provider object: hubspot contacts|companies, or attio people|companies.")
|
|
1742
|
+
.option("--direction <direction>", "Sync direction: inbound, outbound, or bidirectional. Defaults to bidirectional.")
|
|
1743
|
+
.option("--cron <cron>", "Cron expression for the cadence, e.g. '*/15 * * * *'. Defaults to every 15 minutes.")
|
|
1744
|
+
.option("--timezone <tz>", "IANA timezone the cron runs in. Defaults to UTC.")
|
|
1745
|
+
.requiredOption("--max-rows <n>", "Per-cycle row cap — the volume brake on provider writes (required).")
|
|
1746
|
+
.requiredOption("--max-credits <n>", "Managed-credit + external-quota cap per cycle (required).")
|
|
1747
|
+
.option("--approved", "Confirm enabling scheduled provider writes after inspecting a dry run.")
|
|
1748
|
+
.option("--no-run-now", "Do not run an immediate first cycle after enabling.")
|
|
1749
|
+
.option("--json", "Print a JSON envelope.")
|
|
1750
|
+
.action(async (options) => {
|
|
1751
|
+
await handleAsyncAction("crm sync enable", options, () => requestOxygen("/api/cli/crm/sync/enable", {
|
|
1752
|
+
method: "POST",
|
|
1753
|
+
body: buildCrmSyncEnableBody(options),
|
|
1754
|
+
}));
|
|
1755
|
+
}))
|
|
1756
|
+
.addCommand(new Command("disable")
|
|
1757
|
+
.description("Disable the scheduled sync for one CRM object pair: stops the cron trigger and revokes the standing write permission. The standing config (direction, cadence, mappings, watermarks) is preserved for re-enable.")
|
|
1758
|
+
.requiredOption("--provider <provider>", "Connected CRM: hubspot or attio.")
|
|
1759
|
+
.requiredOption("--object <object>", "Provider object: hubspot contacts|companies, or attio people|companies.")
|
|
1760
|
+
.option("--json", "Print a JSON envelope.")
|
|
1761
|
+
.action(async (options) => {
|
|
1762
|
+
await handleAsyncAction("crm sync disable", options, () => requestOxygen("/api/cli/crm/sync/disable", {
|
|
1763
|
+
method: "POST",
|
|
1764
|
+
body: buildCrmSyncDisableBody(options),
|
|
1765
|
+
}));
|
|
1645
1766
|
}))
|
|
1646
1767
|
.addCommand(new Command("status")
|
|
1647
|
-
.description("Show
|
|
1648
|
-
.argument("
|
|
1768
|
+
.description("Show a CRM sync status: pass --run-id for one workflow run, or --provider/--object for the standing sync config (cadence, watermarks, last-cycle counts).")
|
|
1769
|
+
.argument("[run_id]", "Workflow run id. Equivalent to --run-id.")
|
|
1770
|
+
.option("--run-id <id>", "Workflow run id to read a single run.")
|
|
1771
|
+
.option("--provider <provider>", "Connected CRM (hubspot|attio) for a standing-config read. Use with --object.")
|
|
1772
|
+
.option("--object <object>", "Provider object for a standing-config read. Use with --provider.")
|
|
1649
1773
|
.option("--json", "Print a JSON envelope.")
|
|
1650
1774
|
.action(async (runId, options) => {
|
|
1651
|
-
|
|
1775
|
+
const resolved = {
|
|
1776
|
+
...options,
|
|
1777
|
+
...(runId !== undefined && readOption(options.runId) === null ? { runId } : {}),
|
|
1778
|
+
};
|
|
1779
|
+
await handleAsyncAction("crm sync status", resolved, () => requestOxygen(buildCrmSyncConfigStatusPath(resolved)));
|
|
1652
1780
|
}))
|
|
1653
1781
|
.addCommand(new Command("links")
|
|
1654
1782
|
.description("Show provider record links (HubSpot/Attio ids) mapped to one Oxygen CRM record.")
|
|
@@ -1908,6 +2036,15 @@ export function createProgram() {
|
|
|
1908
2036
|
...(options.includeArchived ? { include_archived: true } : {}),
|
|
1909
2037
|
},
|
|
1910
2038
|
}));
|
|
2039
|
+
}))
|
|
2040
|
+
.addCommand(new Command("tidy-suggest")
|
|
2041
|
+
.description("Read-only tidy analysis for a workspace table: duplicate columns, formatting issues, hide candidates, and value-sanity findings.")
|
|
2042
|
+
.argument("<table>", "Table id or slug.")
|
|
2043
|
+
.option("--limit <n>", "Maximum rows to sample for the analysis. Defaults to and is capped at 5000.")
|
|
2044
|
+
.option("--categories <csv>", "Comma-separated subset of duplicates,formatting,hide,valueSanity. Defaults to all four.")
|
|
2045
|
+
.option("--json", "Print a JSON envelope.")
|
|
2046
|
+
.action(async (table, options) => {
|
|
2047
|
+
await handleTablesTidySuggestAction(table, options);
|
|
1911
2048
|
}))
|
|
1912
2049
|
.addCommand(new Command("activity")
|
|
1913
2050
|
.description("Show background runs active on a workspace table right now, with a worker-queue health rollup.")
|
|
@@ -2816,6 +2953,29 @@ export function createProgram() {
|
|
|
2816
2953
|
method: "POST",
|
|
2817
2954
|
body: { table, column },
|
|
2818
2955
|
}));
|
|
2956
|
+
}))
|
|
2957
|
+
.addCommand(new Command("reorder")
|
|
2958
|
+
.description("Move a workspace column to a new position, by absolute index or relative to another column.")
|
|
2959
|
+
.argument("<table>", "Table id or slug.")
|
|
2960
|
+
.argument("<column>", "Column id or key to move.")
|
|
2961
|
+
.option("--position <n>", "Target zero-based index for the column.")
|
|
2962
|
+
.option("--before <column>", "Place the column immediately before this column id or key.")
|
|
2963
|
+
.option("--after <column>", "Place the column immediately after this column id or key.")
|
|
2964
|
+
.option("--dry-run", "Return the would-be position without persisting the reorder.")
|
|
2965
|
+
.option("--json", "Print a JSON envelope.")
|
|
2966
|
+
.action(async (table, column, options) => {
|
|
2967
|
+
await handleAsyncAction("columns reorder", options, async () => {
|
|
2968
|
+
const position = await resolveColumnReorderPosition(table, options);
|
|
2969
|
+
return requestOxygen("/api/cli/tables/columns/reorder", {
|
|
2970
|
+
method: "POST",
|
|
2971
|
+
body: {
|
|
2972
|
+
table,
|
|
2973
|
+
column,
|
|
2974
|
+
position,
|
|
2975
|
+
...(options.dryRun ? { dry_run: true } : {}),
|
|
2976
|
+
},
|
|
2977
|
+
});
|
|
2978
|
+
});
|
|
2819
2979
|
}));
|
|
2820
2980
|
program
|
|
2821
2981
|
.command("action-column")
|
|
@@ -4430,6 +4590,7 @@ export function createProgram() {
|
|
|
4430
4590
|
.addCommand(new Command("connect")
|
|
4431
4591
|
.description("Get a Unipile hosted-auth URL to connect a new LinkedIn account (or reconnect with --reconnect). Open the URL in a browser to complete authentication.")
|
|
4432
4592
|
.option("--reconnect <connection_id>", "Reconnect an existing connection instead of creating a new one. Accepts a connection id.")
|
|
4593
|
+
.option("--sales-nav", "Request Classic + Sales Navigator access during Unipile hosted authentication.")
|
|
4433
4594
|
.option("--json", "Print a JSON envelope.")
|
|
4434
4595
|
.action(async (options) => {
|
|
4435
4596
|
await handleAsyncAction("senders connect", options, () => {
|
|
@@ -4438,6 +4599,7 @@ export function createProgram() {
|
|
|
4438
4599
|
method: "POST",
|
|
4439
4600
|
body: {
|
|
4440
4601
|
...(reconnect ? { reconnect_connection_id: reconnect } : {}),
|
|
4602
|
+
...(options.salesNav ? { sales_nav: true } : {}),
|
|
4441
4603
|
},
|
|
4442
4604
|
});
|
|
4443
4605
|
});
|
|
@@ -4497,8 +4659,9 @@ export function createProgram() {
|
|
|
4497
4659
|
.option("--relations-reads-per-day <n>", "Daily cap on relations/connections list reads (scrape protection).")
|
|
4498
4660
|
.option("--messages-reads-per-day <n>", "Daily cap on chat and message-history reads.")
|
|
4499
4661
|
.option("--searches-per-day <n>", "Daily cap on LinkedIn search executions.")
|
|
4662
|
+
.option("--sales-nav-search-results-per-day <n>", "Daily cap on Sales Navigator search result rows fetched.")
|
|
4500
4663
|
.option("--api-reads-per-day <n>", "Daily cap on all other LinkedIn API reads.")
|
|
4501
|
-
.option("--total-reads-per-day <n>", "Daily cap across all read
|
|
4664
|
+
.option("--total-reads-per-day <n>", "Daily cap across all read units.")
|
|
4502
4665
|
.option("--min-spacing-seconds <n>", "Minimum seconds between actions.")
|
|
4503
4666
|
.option("--spacing-jitter-seconds <n>", "Random jitter seconds added to action spacing.")
|
|
4504
4667
|
.option("--timezone <tz>", "IANA timezone the daily action counters reset in, e.g. America/New_York.")
|
|
@@ -4552,9 +4715,12 @@ export function createProgram() {
|
|
|
4552
4715
|
program.addCommand(new Command("engagement")
|
|
4553
4716
|
.description("Harvest a LinkedIn post's engagers (reactors + commenters) into an enrollable CRM static list. The harvest runs as a slow, durable background drip under a dedicated conservative read budget — it never bursts and never starves the sequencer's own reads.")
|
|
4554
4717
|
.addCommand(new Command("harvest")
|
|
4555
|
-
.description("Start (or re-arm) a harvest of a post's engagers into a CRM static list you can enroll into a sequence. Engagers drip into the list over many ticks; poll `engagement status` to watch it fill. No messages are sent
|
|
4556
|
-
.requiredOption("--post <
|
|
4718
|
+
.description("Start (or re-arm) a harvest of a post's engagers into a CRM static list you can enroll into a sequence. Engagers drip into the list over many ticks; poll `engagement status` to watch it fill. No messages are sent. Cookieless harvests spend Oxygen credits per scraper page and require --max-credits.")
|
|
4719
|
+
.requiredOption("--post <social_id_or_url>", "Composite post social_id from `oxygen posts get` (NOT the activity URN), or the public LinkedIn post URL for cookieless.")
|
|
4557
4720
|
.requiredOption("--list <slug>", "CRM static list slug to fill (created or reused over the people object).")
|
|
4721
|
+
.option("--source <source>", "cookieless|unipile. Defaults to cookieless when a public post URL is supplied.")
|
|
4722
|
+
.option("--post-url <url>", "Public LinkedIn post URL for cookieless harvesting when --post is a social id.")
|
|
4723
|
+
.option("--max-credits <credits>", "Required for cookieless harvesting; caps managed scraper spend for this armed run.")
|
|
4558
4724
|
.option("--recurring", "Keep re-polling the newest pages for new engagers (default: one walk).")
|
|
4559
4725
|
.option("--account <ref>", "Sender account that reads (sender id, connection id, or Unipile account id). Omit for the org default.")
|
|
4560
4726
|
.option("--no-reactions", "Skip reactors.")
|
|
@@ -4565,11 +4731,17 @@ export function createProgram() {
|
|
|
4565
4731
|
const post = readOption(options.post);
|
|
4566
4732
|
const list = readOption(options.list);
|
|
4567
4733
|
const account = readOption(options.account);
|
|
4734
|
+
const source = readOption(options.source);
|
|
4735
|
+
const postUrl = readOption(options.postUrl);
|
|
4736
|
+
const maxCredits = readPositiveNumber(options.maxCredits);
|
|
4568
4737
|
return requestOxygen("/api/cli/linkedin/engagement/harvest", {
|
|
4569
4738
|
method: "POST",
|
|
4570
4739
|
body: {
|
|
4571
4740
|
...(post ? { post } : {}),
|
|
4572
4741
|
...(list ? { list } : {}),
|
|
4742
|
+
...(source ? { source } : {}),
|
|
4743
|
+
...(postUrl ? { post_url: postUrl } : {}),
|
|
4744
|
+
...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
|
|
4573
4745
|
...(options.recurring ? { recurring: true } : {}),
|
|
4574
4746
|
...(account ? { account } : {}),
|
|
4575
4747
|
// commander sets reactions/comments=false for --no-* flags.
|
|
@@ -4743,13 +4915,15 @@ export function createProgram() {
|
|
|
4743
4915
|
.option("--responses-only", "Email only: only conversations with an inbound reply (never sent-only threads).")
|
|
4744
4916
|
.option("--bucket <bucket>", "Email only: primary or others (superseded by --segment).")
|
|
4745
4917
|
.option("--segment <segment>", "Email only: top-tab folder — primary, others, sent, warmup, or dmarc.")
|
|
4746
|
-
.option("--status <keys>", "
|
|
4918
|
+
.option("--status <keys>", "Comma-separated status keys (e.g. interested,meeting_booked). Cross-channel — filters email + LinkedIn + WhatsApp by the shared taxonomy.")
|
|
4919
|
+
.option("--since <iso>", "Only conversations whose last message is on/after this ISO date/timestamp. Cross-channel.")
|
|
4920
|
+
.option("--until <iso>", "Only conversations whose last message is on/before this ISO date/timestamp. Cross-channel.")
|
|
4747
4921
|
.option("--sequence-id <ids>", "Email only: comma-separated campaign (sequence) ids.")
|
|
4748
4922
|
.option("--provider <providers>", "Email only: comma-separated providers (google,microsoft).")
|
|
4749
4923
|
.option("--domain <domains>", "Email only: comma-separated counterpart domains to include.")
|
|
4750
4924
|
.option("--exclude-domain <domains>", "Email only: comma-separated counterpart domains to exclude.")
|
|
4751
4925
|
.option("--mailbox-id <ids>", "Email only: comma-separated mailbox ids.")
|
|
4752
|
-
.option("--search <text>", "Filter by attendee name or last-message text.")
|
|
4926
|
+
.option("--search <text>", "Filter by attendee name or last-message text. Cross-channel.")
|
|
4753
4927
|
.option("--include-archived", "Include archived conversations.")
|
|
4754
4928
|
.option("--limit <n>", "Maximum conversations to return (1-200). Defaults to 50.")
|
|
4755
4929
|
.option("--json", "Print a JSON envelope.")
|
|
@@ -4771,6 +4945,8 @@ export function createProgram() {
|
|
|
4771
4945
|
["segment", "segment"],
|
|
4772
4946
|
["channels", "channels"],
|
|
4773
4947
|
["status", "status"],
|
|
4948
|
+
["since", "since"],
|
|
4949
|
+
["until", "until"],
|
|
4774
4950
|
["sequenceId", "sequence_id"],
|
|
4775
4951
|
["provider", "provider"],
|
|
4776
4952
|
["domain", "domain"],
|
|
@@ -4853,26 +5029,31 @@ export function createProgram() {
|
|
|
4853
5029
|
.description("Run analysis on a conversation now: status + sentiment + a drafted reply (Oxygen's default model). The worker also does this automatically on inbound conversations.")
|
|
4854
5030
|
.argument("<conversation>", "Conversation id, Unipile chat id, or (email) Zapbox thread id.")
|
|
4855
5031
|
.option("--channel <channel>", "Inbox channel: email (default), linkedin, or whatsapp.")
|
|
5032
|
+
.option("--force", "Re-analyze even if the conversation was already classified (an explicit paid re-classification; spends one managed credit). Without it, an already-analyzed email is a free no-op. DMs always re-analyze.")
|
|
4856
5033
|
.option("--json", "Print a JSON envelope.")
|
|
4857
5034
|
.action(async (conversation, options) => {
|
|
4858
5035
|
await handleAsyncAction("inbox analyze", options, () => {
|
|
4859
5036
|
const channel = readOption(options.channel) ?? "email";
|
|
4860
5037
|
return requestOxygen(`/api/cli/inbox/${encodeURIComponent(conversation)}/analyze`, {
|
|
4861
5038
|
method: "POST",
|
|
4862
|
-
body: { channel },
|
|
5039
|
+
body: { channel, ...(options.force ? { force: true } : {}) },
|
|
4863
5040
|
});
|
|
4864
5041
|
});
|
|
4865
5042
|
}))
|
|
4866
5043
|
.addCommand(new Command("archive")
|
|
4867
|
-
.description("Archive
|
|
4868
|
-
.argument("<conversation>", "Conversation id or Zapbox thread id.")
|
|
5044
|
+
.description("Archive a conversation (triage it out of the inbox). --unarchive restores it. --channel linkedin/whatsapp archives a DM.")
|
|
5045
|
+
.argument("<conversation>", "Conversation id, Unipile chat id, or (email) Zapbox thread id.")
|
|
5046
|
+
.option("--channel <channel>", "Inbox channel: email (default), linkedin, or whatsapp.")
|
|
4869
5047
|
.option("--unarchive", "Restore the conversation instead of archiving it.")
|
|
4870
5048
|
.option("--json", "Print a JSON envelope.")
|
|
4871
5049
|
.action(async (conversation, options) => {
|
|
4872
|
-
await handleAsyncAction("inbox archive", options, () =>
|
|
4873
|
-
|
|
4874
|
-
|
|
4875
|
-
|
|
5050
|
+
await handleAsyncAction("inbox archive", options, () => {
|
|
5051
|
+
const channel = readOption(options.channel) ?? "email";
|
|
5052
|
+
return requestOxygen(`/api/cli/inbox/${encodeURIComponent(conversation)}/archive`, {
|
|
5053
|
+
method: "POST",
|
|
5054
|
+
body: { channel, archived: !options.unarchive },
|
|
5055
|
+
});
|
|
5056
|
+
});
|
|
4876
5057
|
}))
|
|
4877
5058
|
.addCommand(new Command("sync")
|
|
4878
5059
|
.description("Force a backstop inbox sync from Unipile for all active sender accounts (pulls recent chats + messages into the unibox).")
|
|
@@ -4941,6 +5122,36 @@ export function createProgram() {
|
|
|
4941
5122
|
body: { channel, confirm: Boolean(options.yes) },
|
|
4942
5123
|
});
|
|
4943
5124
|
});
|
|
5125
|
+
}))
|
|
5126
|
+
.addCommand(new Command("bulk")
|
|
5127
|
+
.description("Bulk-apply one triage action — set-status / archive / unarchive — across up to 100 hand-picked conversations (the ids `inbox list` returns) in one call. Without --yes, previews the matched/would-change counts (no mutation). With --yes, applies it. --channel scopes the ids (email default; run once per channel).")
|
|
5128
|
+
.requiredOption("--action <action>", "set-status, archive, or unarchive.")
|
|
5129
|
+
.requiredOption("--ids <ids>", "Comma-separated conversation ids (the uuids `inbox list` returns). Max 100.")
|
|
5130
|
+
.option("--status <key>", "Required for --action set-status: the status label key to set (e.g. interested).")
|
|
5131
|
+
.option("--channel <channel>", "Inbox channel the ids belong to: email (default), linkedin, or whatsapp.")
|
|
5132
|
+
.option("--yes", "Apply the action. Without this flag, returns a preview of the counts only.")
|
|
5133
|
+
.option("--json", "Print a JSON envelope.")
|
|
5134
|
+
.action(async (options) => {
|
|
5135
|
+
await handleAsyncAction("inbox bulk", options, () => {
|
|
5136
|
+
// CLI uses hyphenated `set-status` for ergonomics; the API wants `set_status`.
|
|
5137
|
+
const action = (readOption(options.action) ?? "").replace(/-/g, "_");
|
|
5138
|
+
const channel = readOption(options.channel) ?? "email";
|
|
5139
|
+
const status = readOption(options.status);
|
|
5140
|
+
const ids = (readOption(options.ids) ?? "")
|
|
5141
|
+
.split(",")
|
|
5142
|
+
.map((id) => id.trim())
|
|
5143
|
+
.filter(Boolean);
|
|
5144
|
+
return requestOxygen("/api/cli/inbox/bulk", {
|
|
5145
|
+
method: "POST",
|
|
5146
|
+
body: {
|
|
5147
|
+
action,
|
|
5148
|
+
channel,
|
|
5149
|
+
conversation_ids: ids,
|
|
5150
|
+
...(status ? { status } : {}),
|
|
5151
|
+
...(options.yes ? { approved: true } : {}),
|
|
5152
|
+
},
|
|
5153
|
+
});
|
|
5154
|
+
});
|
|
4944
5155
|
}))
|
|
4945
5156
|
.addCommand(new Command("labels")
|
|
4946
5157
|
.description("Manage inbox status labels (the Instantly-style system set + custom labels).")
|
|
@@ -9582,6 +9793,132 @@ function formatSequenceVariants(data) {
|
|
|
9582
9793
|
lines.push("");
|
|
9583
9794
|
return lines.join("\n");
|
|
9584
9795
|
}
|
|
9796
|
+
// `columns reorder` accepts either an absolute --position or a relative
|
|
9797
|
+
// --before/--after naming a sibling column. The relative form is resolved
|
|
9798
|
+
// client-side into an absolute index against the table's current column order
|
|
9799
|
+
// (fetched via /api/cli/tables/describe — the same describe the other column
|
|
9800
|
+
// commands read) so the server only ever receives a single numeric position.
|
|
9801
|
+
// Exactly one of the three flags must be supplied.
|
|
9802
|
+
async function resolveColumnReorderPosition(table, options) {
|
|
9803
|
+
const before = readOption(options.before);
|
|
9804
|
+
const after = readOption(options.after);
|
|
9805
|
+
const explicit = readNonNegativeInt(options.position);
|
|
9806
|
+
const provided = [explicit !== undefined, Boolean(before), Boolean(after)].filter(Boolean).length;
|
|
9807
|
+
if (provided === 0) {
|
|
9808
|
+
throw new OxygenError("invalid_request", "Provide --position <n>, --before <column>, or --after <column>.", { exitCode: 1 });
|
|
9809
|
+
}
|
|
9810
|
+
if (provided > 1) {
|
|
9811
|
+
throw new OxygenError("invalid_request", "Pass only one of --position, --before, or --after.", { exitCode: 1 });
|
|
9812
|
+
}
|
|
9813
|
+
if (explicit !== undefined)
|
|
9814
|
+
return explicit;
|
|
9815
|
+
const reference = (before ?? after);
|
|
9816
|
+
const describe = await requestOxygen("/api/cli/tables/describe", { method: "POST", body: { table } });
|
|
9817
|
+
const columns = describe.columns ?? [];
|
|
9818
|
+
const index = columns.findIndex((candidate) => candidate.id === reference || candidate.key === reference);
|
|
9819
|
+
if (index < 0) {
|
|
9820
|
+
throw new OxygenError("invalid_request", `Column "${reference}" was not found on table "${table}".`, { details: { table, column: reference }, exitCode: 1 });
|
|
9821
|
+
}
|
|
9822
|
+
// --before lands the column at the reference's index; --after lands it one slot later.
|
|
9823
|
+
return before ? index : index + 1;
|
|
9824
|
+
}
|
|
9825
|
+
// `tables tidy-suggest` is a read-only analysis (no credits, no writes): it
|
|
9826
|
+
// surfaces duplicate columns, formatting inconsistencies, hide candidates, and
|
|
9827
|
+
// value-sanity findings so the caller can pick which reversible cleanup to run
|
|
9828
|
+
// next. --json returns the raw report envelope; the default rendering compacts
|
|
9829
|
+
// the four sections for a terminal reader and echoes the deep-link.
|
|
9830
|
+
async function handleTablesTidySuggestAction(table, options) {
|
|
9831
|
+
try {
|
|
9832
|
+
const limit = readPositiveInt(options.limit);
|
|
9833
|
+
const categories = readCsvOption(options.categories);
|
|
9834
|
+
const data = await requestOxygen("/api/cli/tables/tidy/suggest", {
|
|
9835
|
+
method: "POST",
|
|
9836
|
+
body: {
|
|
9837
|
+
table,
|
|
9838
|
+
...(limit ? { limit } : {}),
|
|
9839
|
+
...(categories.length ? { categories } : {}),
|
|
9840
|
+
},
|
|
9841
|
+
});
|
|
9842
|
+
if (options.json) {
|
|
9843
|
+
writeJson(success("tables tidy-suggest", data));
|
|
9844
|
+
return;
|
|
9845
|
+
}
|
|
9846
|
+
process.stdout.write(formatTidySuggestions(data));
|
|
9847
|
+
}
|
|
9848
|
+
catch (error) {
|
|
9849
|
+
const failure = toFailure("tables tidy-suggest", error);
|
|
9850
|
+
writeJson(failure);
|
|
9851
|
+
writeMaxCreditsHint(error);
|
|
9852
|
+
process.exitCode = error instanceof OxygenError ? error.exitCode : 1;
|
|
9853
|
+
}
|
|
9854
|
+
}
|
|
9855
|
+
function formatTidyCount(value) {
|
|
9856
|
+
return typeof value === "number" && Number.isFinite(value) ? String(value) : "0";
|
|
9857
|
+
}
|
|
9858
|
+
function formatTidyPercent(value) {
|
|
9859
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
9860
|
+
return "—";
|
|
9861
|
+
return `${(value * 100).toFixed(0)}%`;
|
|
9862
|
+
}
|
|
9863
|
+
function formatTidySuggestions(data) {
|
|
9864
|
+
const styles = ansi(output.isTTY === true && !process.env.NO_COLOR);
|
|
9865
|
+
const duplicates = Array.isArray(data.duplicateColumns) ? data.duplicateColumns : [];
|
|
9866
|
+
const formatting = Array.isArray(data.formattingIssues) ? data.formattingIssues : [];
|
|
9867
|
+
const hide = Array.isArray(data.hideSuggestions) ? data.hideSuggestions : [];
|
|
9868
|
+
const valueSanity = Array.isArray(data.valueSanity) ? data.valueSanity : [];
|
|
9869
|
+
const lines = [
|
|
9870
|
+
"",
|
|
9871
|
+
`${styles.bold("Table tidy suggestions")} ${styles.dim(`(sampled ${formatTidyCount(data.sampledRows)} of ${formatTidyCount(data.rowCount)} rows)`)}`,
|
|
9872
|
+
];
|
|
9873
|
+
lines.push("", styles.bold(`Duplicate columns (${duplicates.length})`));
|
|
9874
|
+
if (duplicates.length === 0) {
|
|
9875
|
+
lines.push(` ${styles.dim("None")}`);
|
|
9876
|
+
}
|
|
9877
|
+
else {
|
|
9878
|
+
for (const dup of duplicates) {
|
|
9879
|
+
const loser = dup.loserKey ?? dup.loserColumnId ?? "?";
|
|
9880
|
+
const survivor = dup.survivorKey ?? dup.survivorColumnId ?? "?";
|
|
9881
|
+
lines.push(` ${loser} ${styles.dim("→ duplicate of")} ${survivor} ${styles.dim(`(${formatTidyPercent(dup.similarity)} match, ${dup.recommendation ?? "review"})`)}`);
|
|
9882
|
+
}
|
|
9883
|
+
}
|
|
9884
|
+
lines.push("", styles.bold(`Formatting issues (${formatting.length})`));
|
|
9885
|
+
if (formatting.length === 0) {
|
|
9886
|
+
lines.push(` ${styles.dim("None")}`);
|
|
9887
|
+
}
|
|
9888
|
+
else {
|
|
9889
|
+
for (const issue of formatting) {
|
|
9890
|
+
const sampleCount = Array.isArray(issue.sampleRowIds) ? issue.sampleRowIds.length : 0;
|
|
9891
|
+
const column = issue.columnKey ?? issue.columnId ?? "?";
|
|
9892
|
+
lines.push(` ${column} ${styles.dim(`— ${issue.issue ?? "issue"}`)}${issue.detail ? `: ${issue.detail}` : ""} ${styles.dim(`(${sampleCount} sample row(s))`)}`);
|
|
9893
|
+
}
|
|
9894
|
+
}
|
|
9895
|
+
lines.push("", styles.bold(`Hide suggestions (${hide.length})`));
|
|
9896
|
+
if (hide.length === 0) {
|
|
9897
|
+
lines.push(` ${styles.dim("None")}`);
|
|
9898
|
+
}
|
|
9899
|
+
else {
|
|
9900
|
+
for (const suggestion of hide) {
|
|
9901
|
+
const column = suggestion.columnKey ?? suggestion.columnId ?? "?";
|
|
9902
|
+
lines.push(` ${column} ${styles.dim(`— ${suggestion.reason ?? "hide"} (${formatTidyPercent(suggestion.fillRate)} filled)`)}`);
|
|
9903
|
+
}
|
|
9904
|
+
}
|
|
9905
|
+
lines.push("", styles.bold(`Value-sanity findings (${valueSanity.length})`));
|
|
9906
|
+
if (valueSanity.length === 0) {
|
|
9907
|
+
lines.push(` ${styles.dim("None")}`);
|
|
9908
|
+
}
|
|
9909
|
+
else {
|
|
9910
|
+
for (const finding of valueSanity) {
|
|
9911
|
+
const column = finding.columnKey ?? finding.columnId ?? "?";
|
|
9912
|
+
lines.push(` ${column} ${styles.dim(`row ${finding.rowId ?? "?"} — ${finding.reason ?? "check value"}`)}`);
|
|
9913
|
+
}
|
|
9914
|
+
}
|
|
9915
|
+
const link = data.deepLink ?? data.web_url ?? data.url;
|
|
9916
|
+
if (link) {
|
|
9917
|
+
lines.push("", styles.dim(`View table: ${link}`));
|
|
9918
|
+
}
|
|
9919
|
+
lines.push("");
|
|
9920
|
+
return lines.join("\n");
|
|
9921
|
+
}
|
|
9585
9922
|
function formatWhoami(identity, context) {
|
|
9586
9923
|
const styles = ansi(output.isTTY === true && !process.env.NO_COLOR);
|
|
9587
9924
|
const email = identity.user.email ?? identity.user.id;
|
|
@@ -10036,6 +10373,7 @@ options) {
|
|
|
10036
10373
|
setLimit("relations_reads_per_day", options.relationsReadsPerDay);
|
|
10037
10374
|
setLimit("messages_reads_per_day", options.messagesReadsPerDay);
|
|
10038
10375
|
setLimit("searches_per_day", options.searchesPerDay);
|
|
10376
|
+
setLimit("sales_nav_search_results_per_day", options.salesNavSearchResultsPerDay);
|
|
10039
10377
|
setLimit("api_reads_per_day", options.apiReadsPerDay);
|
|
10040
10378
|
setLimit("total_reads_per_day", options.totalReadsPerDay);
|
|
10041
10379
|
setLimit("min_action_spacing_seconds", options.minSpacingSeconds);
|