@oxygen-agent/cli 1.226.15 → 1.242.6
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 +887 -12
- package/dist/local-custom-http-column.d.ts +2 -0
- package/dist/local-custom-http-column.js +347 -143
- package/node_modules/@oxygen/shared/dist/custom-http-safety.d.ts +18 -0
- package/node_modules/@oxygen/shared/dist/custom-http-safety.js +162 -0
- package/node_modules/@oxygen/shared/dist/email-unsubscribe-token.d.ts +62 -0
- package/node_modules/@oxygen/shared/dist/email-unsubscribe-token.js +99 -0
- package/node_modules/@oxygen/shared/dist/index.d.ts +1 -0
- package/node_modules/@oxygen/shared/dist/index.js +1 -0
- package/node_modules/@oxygen/shared/dist/linkedin-post-url.js +6 -1
- 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 +19 -0
- package/node_modules/@oxygen/shared/dist/sequences.js +104 -11
- 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/shared/package.json +5 -0
- package/node_modules/@oxygen/workflows/dist/index.d.ts +1 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
2
|
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
-
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
|
-
import { basename, dirname, extname, resolve } from "node:path";
|
|
5
|
+
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
6
6
|
import { createInterface } from "node:readline/promises";
|
|
7
7
|
import { stdin as input, stdout as output } from "node:process";
|
|
8
8
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
@@ -337,6 +337,21 @@ function readFileIfPresent(value) {
|
|
|
337
337
|
throw error;
|
|
338
338
|
}
|
|
339
339
|
}
|
|
340
|
+
// `--suppress-list <comma ids or @file>`: a comma-separated list of lead provider
|
|
341
|
+
// ids, or `@<path>` to a file of ids (comma/newline/whitespace separated). Returns
|
|
342
|
+
// the trimmed, de-duplicated, blank-free ids; an absent value yields an empty list.
|
|
343
|
+
function parseSuppressListOption(value) {
|
|
344
|
+
const raw = readOption(value);
|
|
345
|
+
if (!raw)
|
|
346
|
+
return [];
|
|
347
|
+
const text = raw.startsWith("@") ? readFileSync(resolve(raw.slice(1)), "utf8") : raw;
|
|
348
|
+
return [
|
|
349
|
+
...new Set(text
|
|
350
|
+
.split(/[\s,]+/)
|
|
351
|
+
.map((id) => id.trim())
|
|
352
|
+
.filter((id) => id.length > 0)),
|
|
353
|
+
];
|
|
354
|
+
}
|
|
340
355
|
function resolveComposioRunMode(options) {
|
|
341
356
|
if (options.live === true && options.dryRun === true) {
|
|
342
357
|
throw new OxygenError("conflicting_flags", "Pass either --live or --dry-run, not both.", { exitCode: 1 });
|
|
@@ -428,9 +443,66 @@ function buildCrmSyncImportBody(provider, options) {
|
|
|
428
443
|
...(options.approved ? { approved: true } : {}),
|
|
429
444
|
};
|
|
430
445
|
}
|
|
431
|
-
function
|
|
446
|
+
function buildCrmSyncConfigureBody(options) {
|
|
447
|
+
return {
|
|
448
|
+
provider: options.provider,
|
|
449
|
+
object: options.object,
|
|
450
|
+
mode: resolveCrmSetupMode(options),
|
|
451
|
+
...(readOption(options.direction) ? { direction: readOption(options.direction) } : {}),
|
|
452
|
+
...(readOption(options.cron) ? { cron: readOption(options.cron) } : {}),
|
|
453
|
+
...(readOption(options.timezone) ? { timezone: readOption(options.timezone) } : {}),
|
|
454
|
+
...(readPositiveInt(options.maxRows) !== undefined ? { max_rows_per_cycle: readPositiveInt(options.maxRows) } : {}),
|
|
455
|
+
...(readPositiveNumber(options.maxCredits) !== undefined ? { max_credits: readPositiveNumber(options.maxCredits) } : {}),
|
|
456
|
+
...(options.approved ? { approved: true } : {}),
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
function buildCrmSyncRunBody(options) {
|
|
460
|
+
return {
|
|
461
|
+
provider: options.provider,
|
|
462
|
+
object: options.object,
|
|
463
|
+
mode: resolveCrmSetupMode(options),
|
|
464
|
+
...(readPositiveInt(options.maxRows) !== undefined ? { max_rows_per_cycle: readPositiveInt(options.maxRows) } : {}),
|
|
465
|
+
...(readPositiveNumber(options.maxCredits) !== undefined ? { max_credits: readPositiveNumber(options.maxCredits) } : {}),
|
|
466
|
+
...(options.approved ? { approved: true } : {}),
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
function buildCrmSyncEnableBody(options) {
|
|
470
|
+
return {
|
|
471
|
+
provider: options.provider,
|
|
472
|
+
object: options.object,
|
|
473
|
+
...(readOption(options.direction) ? { direction: readOption(options.direction) } : {}),
|
|
474
|
+
...(readOption(options.cron) ? { cron: readOption(options.cron) } : {}),
|
|
475
|
+
...(readOption(options.timezone) ? { timezone: readOption(options.timezone) } : {}),
|
|
476
|
+
...(readPositiveInt(options.maxRows) !== undefined ? { max_rows_per_cycle: readPositiveInt(options.maxRows) } : {}),
|
|
477
|
+
...(readPositiveNumber(options.maxCredits) !== undefined ? { max_credits: readPositiveNumber(options.maxCredits) } : {}),
|
|
478
|
+
...(options.approved ? { approved: true } : {}),
|
|
479
|
+
// Commander maps --no-run-now to runNow:false (default true); only send the
|
|
480
|
+
// opt-out so the route's default (run an immediate first cycle) holds.
|
|
481
|
+
...(options.runNow === false ? { run_now: false } : {}),
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
function buildCrmSyncDisableBody(options) {
|
|
485
|
+
return { provider: options.provider, object: options.object };
|
|
486
|
+
}
|
|
487
|
+
function buildCrmSyncConfigStatusPath(options) {
|
|
488
|
+
const runId = readOption(options.runId);
|
|
489
|
+
const provider = readOption(options.provider);
|
|
490
|
+
const object = readOption(options.object);
|
|
491
|
+
const hasPair = provider !== null && object !== null;
|
|
492
|
+
if (runId !== null && (provider !== null || object !== null)) {
|
|
493
|
+
throw new OxygenError("conflicting_flags", "Pass either --run-id or --provider/--object, not both.", { exitCode: 1 });
|
|
494
|
+
}
|
|
495
|
+
if (runId === null && !hasPair) {
|
|
496
|
+
throw new OxygenError("missing_target", "Pass --run-id for a single run, or --provider and --object for a standing sync config.", { exitCode: 1 });
|
|
497
|
+
}
|
|
432
498
|
const params = new URLSearchParams();
|
|
433
|
-
|
|
499
|
+
if (runId !== null) {
|
|
500
|
+
params.set("run_id", runId);
|
|
501
|
+
}
|
|
502
|
+
else {
|
|
503
|
+
params.set("provider", provider);
|
|
504
|
+
params.set("object", object);
|
|
505
|
+
}
|
|
434
506
|
return `/api/cli/crm/sync/status?${params.toString()}`;
|
|
435
507
|
}
|
|
436
508
|
function buildCrmSyncLinksPath(object, rowId) {
|
|
@@ -563,6 +635,56 @@ function buildCrmDuplicatesPath(object, options) {
|
|
|
563
635
|
const base = `/api/cli/crm/objects/${encodeURIComponent(object)}/duplicates`;
|
|
564
636
|
return query ? `${base}?${query}` : base;
|
|
565
637
|
}
|
|
638
|
+
function buildCrmSignalRecordBody(options) {
|
|
639
|
+
const event = readOption(options.event);
|
|
640
|
+
if (!event) {
|
|
641
|
+
throw new OxygenError("invalid_request", "--event is required.", { exitCode: 1 });
|
|
642
|
+
}
|
|
643
|
+
const sessionId = readOption(options.sessionId);
|
|
644
|
+
// The redelivery/idempotency guard: an explicit id, else derived from the
|
|
645
|
+
// provider session id so a repeat reveal still dedupes.
|
|
646
|
+
const externalEventId = readOption(options.externalEventId) ?? (sessionId ? `signal:${sessionId}` : null);
|
|
647
|
+
if (!externalEventId) {
|
|
648
|
+
throw new OxygenError("invalid_request", "--external-event-id is required (or pass --session-id to derive it).", { exitCode: 1 });
|
|
649
|
+
}
|
|
650
|
+
const payload = {};
|
|
651
|
+
const email = readOption(options.email);
|
|
652
|
+
if (email)
|
|
653
|
+
payload.email = email;
|
|
654
|
+
const linkedinUrl = readOption(options.linkedinUrl);
|
|
655
|
+
if (linkedinUrl)
|
|
656
|
+
payload.linkedin_url = linkedinUrl;
|
|
657
|
+
const companyDomain = readOption(options.companyDomain);
|
|
658
|
+
if (companyDomain)
|
|
659
|
+
payload.company_domain = companyDomain;
|
|
660
|
+
const companyName = readOption(options.companyName);
|
|
661
|
+
if (companyName)
|
|
662
|
+
payload.company_name = companyName;
|
|
663
|
+
const firstName = readOption(options.firstName);
|
|
664
|
+
if (firstName)
|
|
665
|
+
payload.first_name = firstName;
|
|
666
|
+
const lastName = readOption(options.lastName);
|
|
667
|
+
if (lastName)
|
|
668
|
+
payload.last_name = lastName;
|
|
669
|
+
const pageUrl = readOption(options.pageUrl);
|
|
670
|
+
if (pageUrl)
|
|
671
|
+
payload.page_url = pageUrl;
|
|
672
|
+
if (sessionId)
|
|
673
|
+
payload.session_id = sessionId;
|
|
674
|
+
// An explicit --payload-json wins over the convenience flags above.
|
|
675
|
+
if (options.payloadJson)
|
|
676
|
+
Object.assign(payload, parseJsonObject(options.payloadJson));
|
|
677
|
+
return { event, external_event_id: externalEventId, payload };
|
|
678
|
+
}
|
|
679
|
+
function buildCrmLeadsTodayPath(options) {
|
|
680
|
+
const params = new URLSearchParams();
|
|
681
|
+
if (options.limit)
|
|
682
|
+
params.set("limit", options.limit);
|
|
683
|
+
if (options.withinDays)
|
|
684
|
+
params.set("within_days", options.withinDays);
|
|
685
|
+
const query = params.toString();
|
|
686
|
+
return query ? `/api/cli/crm/signals/leads-today?${query}` : "/api/cli/crm/signals/leads-today";
|
|
687
|
+
}
|
|
566
688
|
function buildCrmAutomationSetBody(templateId, options) {
|
|
567
689
|
if (options.armed === true && options.disarmed === true) {
|
|
568
690
|
throw new OxygenError("conflicting_flags", "Pass either --armed or --disarmed, not both.", { exitCode: 1 });
|
|
@@ -1531,6 +1653,36 @@ export function createProgram() {
|
|
|
1531
1653
|
.action(async (options) => {
|
|
1532
1654
|
await handleAsyncAction("crm pipeline", options, () => requestOxygen(buildCrmPipelinePath(options)));
|
|
1533
1655
|
}))
|
|
1656
|
+
.addCommand(new Command("signals")
|
|
1657
|
+
.description("Record inbound GTM intent signals and read the ranked leads-to-contact-today list.")
|
|
1658
|
+
.addCommand(new Command("record")
|
|
1659
|
+
.description("Record one inbound GTM signal (website_visit, profile_view, post_engager, new_follower) into the CRM. Internal write — free, no approval. Idempotent on --external-event-id.")
|
|
1660
|
+
.requiredOption("--event <event>", "Signal event: website_visit | profile_view | post_engager | new_follower.")
|
|
1661
|
+
.option("--external-event-id <id>", "Stable provider event id (redelivery/idempotency guard). Derived from --session-id when omitted.")
|
|
1662
|
+
.option("--email <email>", "Person email (identity).")
|
|
1663
|
+
.option("--linkedin-url <url>", "Person LinkedIn URL (identity when no email).")
|
|
1664
|
+
.option("--company-domain <domain>", "Company domain for the company assert.")
|
|
1665
|
+
.option("--company-name <name>", "Company name.")
|
|
1666
|
+
.option("--first-name <name>", "Person first name.")
|
|
1667
|
+
.option("--last-name <name>", "Person last name.")
|
|
1668
|
+
.option("--page-url <url>", "Page the signal fired on (website visit).")
|
|
1669
|
+
.option("--session-id <id>", "Provider session id (provenance + idempotency).")
|
|
1670
|
+
.option("--payload-json <json>", "JSON object merged into the signal payload (wins over the flags above).")
|
|
1671
|
+
.option("--json", "Print a JSON envelope.")
|
|
1672
|
+
.action(async (options) => {
|
|
1673
|
+
await handleAsyncAction("crm signals record", options, () => requestOxygen("/api/cli/crm/signals", {
|
|
1674
|
+
method: "POST",
|
|
1675
|
+
body: buildCrmSignalRecordBody(options),
|
|
1676
|
+
}));
|
|
1677
|
+
}))
|
|
1678
|
+
.addCommand(new Command("leads-today")
|
|
1679
|
+
.description("List people to contact today, ranked by their most recent GTM intent signal. Do-not-contact filtering is best-effort (email-only leads surface unchecked).")
|
|
1680
|
+
.option("--limit <limit>", "Maximum leads to return. Defaults to 25, max 100.")
|
|
1681
|
+
.option("--within-days <days>", "Signal look-back window in days. Defaults to 7, max 30.")
|
|
1682
|
+
.option("--json", "Print a JSON envelope.")
|
|
1683
|
+
.action(async (options) => {
|
|
1684
|
+
await handleAsyncAction("crm signals leads-today", options, () => requestOxygen(buildCrmLeadsTodayPath(options)));
|
|
1685
|
+
})))
|
|
1534
1686
|
.addCommand(new Command("lists")
|
|
1535
1687
|
.description("Static and dynamic CRM lists over companies, people, deals, and custom objects.")
|
|
1536
1688
|
.addCommand(new Command("create")
|
|
@@ -1625,7 +1777,7 @@ export function createProgram() {
|
|
|
1625
1777
|
await handleAsyncAction("crm automation audit", options, () => requestOxygen(buildCrmAutomationAuditPath(options)));
|
|
1626
1778
|
})))
|
|
1627
1779
|
.addCommand(new Command("sync")
|
|
1628
|
-
.description("
|
|
1780
|
+
.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
1781
|
.addCommand(new Command("import")
|
|
1630
1782
|
.description("Import provider records into an Oxygen CRM object as a durable workflow run. Defaults to dry-run.")
|
|
1631
1783
|
.argument("<provider>", "Connected CRM to import from: hubspot or attio.")
|
|
@@ -1642,13 +1794,84 @@ export function createProgram() {
|
|
|
1642
1794
|
method: "POST",
|
|
1643
1795
|
body: buildCrmSyncImportBody(provider, options),
|
|
1644
1796
|
}));
|
|
1797
|
+
}))
|
|
1798
|
+
.addCommand(new Command("configure")
|
|
1799
|
+
.description("Configure a standing bidirectional sync for one CRM object pair. Defaults to dry-run, which returns a real preview of the next cycle.")
|
|
1800
|
+
.requiredOption("--provider <provider>", "Connected CRM: hubspot or attio.")
|
|
1801
|
+
.requiredOption("--object <object>", "Provider object: hubspot contacts|companies, or attio people|companies.")
|
|
1802
|
+
.option("--direction <direction>", "Sync direction: inbound, outbound, or bidirectional. Defaults to bidirectional.")
|
|
1803
|
+
.option("--cron <cron>", "Cron expression for the scheduled reconciliation cadence, e.g. '*/15 * * * *'.")
|
|
1804
|
+
.option("--timezone <tz>", "IANA timezone the cron schedule runs in, e.g. America/New_York. Defaults to UTC.")
|
|
1805
|
+
.option("--max-rows <n>", "Per-cycle row cap. The real volume brake on provider writes (BYOK writes cost 0 Oxygen credits).")
|
|
1806
|
+
.option("--max-credits <n>", "Managed-credit + external-quota cap per cycle.")
|
|
1807
|
+
.option("--approved", "Confirm a live configuration after inspecting a dry run.")
|
|
1808
|
+
.option("--dry-run", "Preview the configuration and next cycle without writing. Default.")
|
|
1809
|
+
.option("--live", "Write the configuration. Requires --approved and --max-credits.")
|
|
1810
|
+
.option("--json", "Print a JSON envelope.")
|
|
1811
|
+
.action(async (options) => {
|
|
1812
|
+
await handleAsyncAction("crm sync configure", options, () => requestOxygen("/api/cli/crm/sync/configure", {
|
|
1813
|
+
method: "POST",
|
|
1814
|
+
body: buildCrmSyncConfigureBody(options),
|
|
1815
|
+
}));
|
|
1816
|
+
}))
|
|
1817
|
+
.addCommand(new Command("run")
|
|
1818
|
+
.description("Run one bidirectional reconciliation cycle for a configured CRM object pair as a durable workflow run. Defaults to dry-run.")
|
|
1819
|
+
.requiredOption("--provider <provider>", "Connected CRM: hubspot or attio.")
|
|
1820
|
+
.requiredOption("--object <object>", "Provider object: hubspot contacts|companies, or attio people|companies.")
|
|
1821
|
+
.option("--max-rows <n>", "Per-cycle row cap. The real volume brake on provider writes (BYOK writes cost 0 Oxygen credits).")
|
|
1822
|
+
.option("--max-credits <n>", "Managed-credit + external-quota cap for this cycle.")
|
|
1823
|
+
.option("--approved", "Confirm a live cycle after inspecting a dry run.")
|
|
1824
|
+
.option("--dry-run", "Preview the cycle without pulling or pushing. Default.")
|
|
1825
|
+
.option("--live", "Run the live reconciliation cycle. Requires --approved and --max-credits.")
|
|
1826
|
+
.option("--json", "Print a JSON envelope.")
|
|
1827
|
+
.action(async (options) => {
|
|
1828
|
+
await handleAsyncAction("crm sync run", options, () => requestOxygen("/api/cli/crm/sync/run", {
|
|
1829
|
+
method: "POST",
|
|
1830
|
+
body: buildCrmSyncRunBody(options),
|
|
1831
|
+
}));
|
|
1832
|
+
}))
|
|
1833
|
+
.addCommand(new Command("enable")
|
|
1834
|
+
.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.")
|
|
1835
|
+
.requiredOption("--provider <provider>", "Connected CRM: hubspot or attio.")
|
|
1836
|
+
.requiredOption("--object <object>", "Provider object: hubspot contacts|companies, or attio people|companies.")
|
|
1837
|
+
.option("--direction <direction>", "Sync direction: inbound, outbound, or bidirectional. Defaults to bidirectional.")
|
|
1838
|
+
.option("--cron <cron>", "Cron expression for the cadence, e.g. '*/15 * * * *'. Defaults to every 15 minutes.")
|
|
1839
|
+
.option("--timezone <tz>", "IANA timezone the cron runs in. Defaults to UTC.")
|
|
1840
|
+
.requiredOption("--max-rows <n>", "Per-cycle row cap — the volume brake on provider writes (required).")
|
|
1841
|
+
.requiredOption("--max-credits <n>", "Managed-credit + external-quota cap per cycle (required).")
|
|
1842
|
+
.option("--approved", "Confirm enabling scheduled provider writes after inspecting a dry run.")
|
|
1843
|
+
.option("--no-run-now", "Do not run an immediate first cycle after enabling.")
|
|
1844
|
+
.option("--json", "Print a JSON envelope.")
|
|
1845
|
+
.action(async (options) => {
|
|
1846
|
+
await handleAsyncAction("crm sync enable", options, () => requestOxygen("/api/cli/crm/sync/enable", {
|
|
1847
|
+
method: "POST",
|
|
1848
|
+
body: buildCrmSyncEnableBody(options),
|
|
1849
|
+
}));
|
|
1850
|
+
}))
|
|
1851
|
+
.addCommand(new Command("disable")
|
|
1852
|
+
.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.")
|
|
1853
|
+
.requiredOption("--provider <provider>", "Connected CRM: hubspot or attio.")
|
|
1854
|
+
.requiredOption("--object <object>", "Provider object: hubspot contacts|companies, or attio people|companies.")
|
|
1855
|
+
.option("--json", "Print a JSON envelope.")
|
|
1856
|
+
.action(async (options) => {
|
|
1857
|
+
await handleAsyncAction("crm sync disable", options, () => requestOxygen("/api/cli/crm/sync/disable", {
|
|
1858
|
+
method: "POST",
|
|
1859
|
+
body: buildCrmSyncDisableBody(options),
|
|
1860
|
+
}));
|
|
1645
1861
|
}))
|
|
1646
1862
|
.addCommand(new Command("status")
|
|
1647
|
-
.description("Show
|
|
1648
|
-
.argument("
|
|
1863
|
+
.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).")
|
|
1864
|
+
.argument("[run_id]", "Workflow run id. Equivalent to --run-id.")
|
|
1865
|
+
.option("--run-id <id>", "Workflow run id to read a single run.")
|
|
1866
|
+
.option("--provider <provider>", "Connected CRM (hubspot|attio) for a standing-config read. Use with --object.")
|
|
1867
|
+
.option("--object <object>", "Provider object for a standing-config read. Use with --provider.")
|
|
1649
1868
|
.option("--json", "Print a JSON envelope.")
|
|
1650
1869
|
.action(async (runId, options) => {
|
|
1651
|
-
|
|
1870
|
+
const resolved = {
|
|
1871
|
+
...options,
|
|
1872
|
+
...(runId !== undefined && readOption(options.runId) === null ? { runId } : {}),
|
|
1873
|
+
};
|
|
1874
|
+
await handleAsyncAction("crm sync status", resolved, () => requestOxygen(buildCrmSyncConfigStatusPath(resolved)));
|
|
1652
1875
|
}))
|
|
1653
1876
|
.addCommand(new Command("links")
|
|
1654
1877
|
.description("Show provider record links (HubSpot/Attio ids) mapped to one Oxygen CRM record.")
|
|
@@ -1908,6 +2131,15 @@ export function createProgram() {
|
|
|
1908
2131
|
...(options.includeArchived ? { include_archived: true } : {}),
|
|
1909
2132
|
},
|
|
1910
2133
|
}));
|
|
2134
|
+
}))
|
|
2135
|
+
.addCommand(new Command("tidy-suggest")
|
|
2136
|
+
.description("Read-only tidy analysis for a workspace table: duplicate columns, formatting issues, hide candidates, and value-sanity findings.")
|
|
2137
|
+
.argument("<table>", "Table id or slug.")
|
|
2138
|
+
.option("--limit <n>", "Maximum rows to sample for the analysis. Defaults to and is capped at 5000.")
|
|
2139
|
+
.option("--categories <csv>", "Comma-separated subset of duplicates,formatting,hide,valueSanity. Defaults to all four.")
|
|
2140
|
+
.option("--json", "Print a JSON envelope.")
|
|
2141
|
+
.action(async (table, options) => {
|
|
2142
|
+
await handleTablesTidySuggestAction(table, options);
|
|
1911
2143
|
}))
|
|
1912
2144
|
.addCommand(new Command("activity")
|
|
1913
2145
|
.description("Show background runs active on a workspace table right now, with a worker-queue health rollup.")
|
|
@@ -2193,6 +2425,21 @@ export function createProgram() {
|
|
|
2193
2425
|
.option("--json", "Print a JSON envelope.")
|
|
2194
2426
|
.action(async (options) => {
|
|
2195
2427
|
await handleAsyncAction("context assets list", options, () => requestOxygen(`/api/cli/context/assets${contextAssetsQuery(options)}`));
|
|
2428
|
+
}))
|
|
2429
|
+
.addCommand(new Command("search")
|
|
2430
|
+
.description("Full-text 'second brain' search across context assets, ranked by relevance.")
|
|
2431
|
+
.requiredOption("--query <text>", "Search text. Supports quoted phrases, OR, and -exclude.")
|
|
2432
|
+
.option("--type <type>", "Filter by playbook, strategy, campaign, positioning, brand, voice, persona, competitor, research_note, message_playbook, or other.")
|
|
2433
|
+
.option("--status <status>", "Filter by draft, active, or archived.")
|
|
2434
|
+
.option("--tags <csv>", "Comma-separated tags that must be present.")
|
|
2435
|
+
.option("--include-archived", "Include archived assets when no status filter is set.")
|
|
2436
|
+
.option("--limit <n>", "Maximum assets to return. Defaults to 100; hard cap is 500.")
|
|
2437
|
+
.option("--json", "Print a JSON envelope.")
|
|
2438
|
+
.action(async (options) => {
|
|
2439
|
+
await handleAsyncAction("context assets search", options, () => requestOxygen("/api/cli/context/assets/search", {
|
|
2440
|
+
method: "POST",
|
|
2441
|
+
body: buildContextAssetSearchBody(options),
|
|
2442
|
+
}));
|
|
2196
2443
|
}))
|
|
2197
2444
|
.addCommand(new Command("get")
|
|
2198
2445
|
.description("Read one context asset.")
|
|
@@ -2816,6 +3063,29 @@ export function createProgram() {
|
|
|
2816
3063
|
method: "POST",
|
|
2817
3064
|
body: { table, column },
|
|
2818
3065
|
}));
|
|
3066
|
+
}))
|
|
3067
|
+
.addCommand(new Command("reorder")
|
|
3068
|
+
.description("Move a workspace column to a new position, by absolute index or relative to another column.")
|
|
3069
|
+
.argument("<table>", "Table id or slug.")
|
|
3070
|
+
.argument("<column>", "Column id or key to move.")
|
|
3071
|
+
.option("--position <n>", "Target zero-based index for the column.")
|
|
3072
|
+
.option("--before <column>", "Place the column immediately before this column id or key.")
|
|
3073
|
+
.option("--after <column>", "Place the column immediately after this column id or key.")
|
|
3074
|
+
.option("--dry-run", "Return the would-be position without persisting the reorder.")
|
|
3075
|
+
.option("--json", "Print a JSON envelope.")
|
|
3076
|
+
.action(async (table, column, options) => {
|
|
3077
|
+
await handleAsyncAction("columns reorder", options, async () => {
|
|
3078
|
+
const position = await resolveColumnReorderPosition(table, column, options);
|
|
3079
|
+
return requestOxygen("/api/cli/tables/columns/reorder", {
|
|
3080
|
+
method: "POST",
|
|
3081
|
+
body: {
|
|
3082
|
+
table,
|
|
3083
|
+
column,
|
|
3084
|
+
position,
|
|
3085
|
+
...(options.dryRun ? { dry_run: true } : {}),
|
|
3086
|
+
},
|
|
3087
|
+
});
|
|
3088
|
+
});
|
|
2819
3089
|
}));
|
|
2820
3090
|
program
|
|
2821
3091
|
.command("action-column")
|
|
@@ -4430,6 +4700,7 @@ export function createProgram() {
|
|
|
4430
4700
|
.addCommand(new Command("connect")
|
|
4431
4701
|
.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
4702
|
.option("--reconnect <connection_id>", "Reconnect an existing connection instead of creating a new one. Accepts a connection id.")
|
|
4703
|
+
.option("--sales-nav", "Request Classic + Sales Navigator access during Unipile hosted authentication.")
|
|
4433
4704
|
.option("--json", "Print a JSON envelope.")
|
|
4434
4705
|
.action(async (options) => {
|
|
4435
4706
|
await handleAsyncAction("senders connect", options, () => {
|
|
@@ -4438,6 +4709,7 @@ export function createProgram() {
|
|
|
4438
4709
|
method: "POST",
|
|
4439
4710
|
body: {
|
|
4440
4711
|
...(reconnect ? { reconnect_connection_id: reconnect } : {}),
|
|
4712
|
+
...(options.salesNav ? { sales_nav: true } : {}),
|
|
4441
4713
|
},
|
|
4442
4714
|
});
|
|
4443
4715
|
});
|
|
@@ -4448,6 +4720,13 @@ export function createProgram() {
|
|
|
4448
4720
|
.option("--json", "Print a JSON envelope.")
|
|
4449
4721
|
.action(async (id, options) => {
|
|
4450
4722
|
await handleAsyncAction("senders get", options, () => requestOxygen(`/api/cli/senders/${encodeURIComponent(id)}`));
|
|
4723
|
+
}))
|
|
4724
|
+
.addCommand(new Command("health")
|
|
4725
|
+
.description("Show a one-call health snapshot for a LinkedIn sender: status, the latest error reason, any open security checkpoint, today's usage, the warm-up ramp, and when the daily quota resets. Read-only — no provider call, no credits. <id> accepts a sender account id, connection id, or Unipile account id.")
|
|
4726
|
+
.argument("<id>", "Sender account id, connection id, or Unipile account id.")
|
|
4727
|
+
.option("--json", "Print a JSON envelope.")
|
|
4728
|
+
.action(async (id, options) => {
|
|
4729
|
+
await handleAsyncAction("senders health", options, () => requestOxygen(`/api/cli/senders/${encodeURIComponent(id)}/health`));
|
|
4451
4730
|
}))
|
|
4452
4731
|
.addCommand(new Command("sync")
|
|
4453
4732
|
.description("Refresh LinkedIn account state (status, profile) from Unipile. Pass --connection-id to sync one account, or omit it to sync all.")
|
|
@@ -4473,6 +4752,29 @@ export function createProgram() {
|
|
|
4473
4752
|
method: "POST",
|
|
4474
4753
|
}));
|
|
4475
4754
|
}))
|
|
4755
|
+
.addCommand(new Command("checkpoint")
|
|
4756
|
+
.description("Resolve a LinkedIn security checkpoint (2FA / OTP / in-app validation) so a stuck sender can return to active. Account administration — no credits, no approval gate.")
|
|
4757
|
+
.option("--json", "Print a JSON envelope.")
|
|
4758
|
+
.addCommand(new Command("solve")
|
|
4759
|
+
.description("Submit the LinkedIn checkpoint code Unipile is waiting on to finish authentication. <id> accepts a sender account id, connection id, or Unipile account id; <code> is the verification code LinkedIn sent.")
|
|
4760
|
+
.argument("<id>", "Sender account id, connection id, or Unipile account id.")
|
|
4761
|
+
.argument("<code>", "The verification code LinkedIn sent (2FA / OTP / in-app validation).")
|
|
4762
|
+
.option("--json", "Print a JSON envelope.")
|
|
4763
|
+
.action(async (id, code, options) => {
|
|
4764
|
+
await handleAsyncAction("senders checkpoint solve", options, () => requestOxygen(`/api/cli/senders/${encodeURIComponent(id)}/checkpoint/solve`, {
|
|
4765
|
+
method: "POST",
|
|
4766
|
+
body: { code },
|
|
4767
|
+
}));
|
|
4768
|
+
}))
|
|
4769
|
+
.addCommand(new Command("resend")
|
|
4770
|
+
.description("Ask LinkedIn to resend the checkpoint code so you can read a fresh one, then submit it with `senders checkpoint solve`. <id> accepts a sender account id, connection id, or Unipile account id.")
|
|
4771
|
+
.argument("<id>", "Sender account id, connection id, or Unipile account id.")
|
|
4772
|
+
.option("--json", "Print a JSON envelope.")
|
|
4773
|
+
.action(async (id, options) => {
|
|
4774
|
+
await handleAsyncAction("senders checkpoint resend", options, () => requestOxygen(`/api/cli/senders/${encodeURIComponent(id)}/checkpoint/resend`, {
|
|
4775
|
+
method: "POST",
|
|
4776
|
+
}));
|
|
4777
|
+
})))
|
|
4476
4778
|
.addCommand(new Command("limits")
|
|
4477
4779
|
.description("View and adjust per-account daily action limits and the daily-reset timezone.")
|
|
4478
4780
|
.option("--json", "Print a JSON envelope.")
|
|
@@ -4497,8 +4799,9 @@ export function createProgram() {
|
|
|
4497
4799
|
.option("--relations-reads-per-day <n>", "Daily cap on relations/connections list reads (scrape protection).")
|
|
4498
4800
|
.option("--messages-reads-per-day <n>", "Daily cap on chat and message-history reads.")
|
|
4499
4801
|
.option("--searches-per-day <n>", "Daily cap on LinkedIn search executions.")
|
|
4802
|
+
.option("--sales-nav-search-results-per-day <n>", "Daily cap on Sales Navigator search result rows fetched.")
|
|
4500
4803
|
.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
|
|
4804
|
+
.option("--total-reads-per-day <n>", "Daily cap across all read units.")
|
|
4502
4805
|
.option("--min-spacing-seconds <n>", "Minimum seconds between actions.")
|
|
4503
4806
|
.option("--spacing-jitter-seconds <n>", "Random jitter seconds added to action spacing.")
|
|
4504
4807
|
.option("--timezone <tz>", "IANA timezone the daily action counters reset in, e.g. America/New_York.")
|
|
@@ -4549,6 +4852,80 @@ export function createProgram() {
|
|
|
4549
4852
|
return requestOxygen(`/api/cli/linkedin/connections/status${suffix ? `?${suffix}` : ""}`);
|
|
4550
4853
|
});
|
|
4551
4854
|
})));
|
|
4855
|
+
program.addCommand(new Command("followers")
|
|
4856
|
+
.description("Read a LinkedIn account's followers into a Clay-like workspace table and an enrollable CRM list you can enrich, qualify, and enroll from. Imports run as a slow, durable background drip under a dedicated conservative read budget — they never burst and never starve the sequencer's own reads.")
|
|
4857
|
+
.addCommand(new Command("import")
|
|
4858
|
+
.description("Start (or re-arm) a followers import. Followers drip into a workspace table + the deduped mirror over many ticks, and newly-seen people are bridged into an enrollable CRM static list; poll `followers status` to watch them accrue. The import keeps itself fresh by periodically re-walking followers in the background (new followers dedupe in). No messages are sent and no Oxygen credits are charged.")
|
|
4859
|
+
.option("--account <ref>", "Sender account to import (sender id, connection id, or Unipile account id). Omit to import every active LinkedIn account.")
|
|
4860
|
+
.option("--table <ref>", "Existing workspace table (id or slug) to import into. Omit to create or reuse a 'LinkedIn Followers' table.")
|
|
4861
|
+
.option("--list <slug>", "Enrollable CRM static list slug to bridge new followers into. Omit for the default 'linkedin-followers' list.")
|
|
4862
|
+
.option("--json", "Print a JSON envelope.")
|
|
4863
|
+
.action(async (options) => {
|
|
4864
|
+
await handleAsyncAction("followers import", options, () => {
|
|
4865
|
+
const account = readOption(options.account);
|
|
4866
|
+
const table = readOption(options.table);
|
|
4867
|
+
const list = readOption(options.list);
|
|
4868
|
+
return requestOxygen("/api/cli/linkedin/followers/import", {
|
|
4869
|
+
method: "POST",
|
|
4870
|
+
body: {
|
|
4871
|
+
...(account ? { account } : {}),
|
|
4872
|
+
...(table ? { table } : {}),
|
|
4873
|
+
...(list ? { list } : {}),
|
|
4874
|
+
},
|
|
4875
|
+
});
|
|
4876
|
+
});
|
|
4877
|
+
}))
|
|
4878
|
+
.addCommand(new Command("status")
|
|
4879
|
+
.description("Show followers-import progress (per-sender drip status, followers ingested, last error) plus a preview of the followers table, the enrollable CRM list, and a deep-link to open it.")
|
|
4880
|
+
.option("--account <ref>", "Scope to one sender account (sender id, connection id, or Unipile account id).")
|
|
4881
|
+
.option("--json", "Print a JSON envelope.")
|
|
4882
|
+
.action(async (options) => {
|
|
4883
|
+
await handleAsyncAction("followers status", options, () => {
|
|
4884
|
+
const account = readOption(options.account);
|
|
4885
|
+
const params = new URLSearchParams();
|
|
4886
|
+
if (account)
|
|
4887
|
+
params.set("account", account);
|
|
4888
|
+
const suffix = params.toString();
|
|
4889
|
+
return requestOxygen(`/api/cli/linkedin/followers/status${suffix ? `?${suffix}` : ""}`);
|
|
4890
|
+
});
|
|
4891
|
+
})));
|
|
4892
|
+
program.addCommand(new Command("viewers")
|
|
4893
|
+
.description("Read the named LinkedIn 'who viewed my profile' (WVMP) viewers into a Clay-like workspace table and an enrollable CRM list. Imports run as a slow, durable background drip under a dedicated conservative read budget. LinkedIn hides anonymous/private viewers, so the list is always a partial sample.")
|
|
4894
|
+
.addCommand(new Command("import")
|
|
4895
|
+
.description("Start (or re-arm) a durable profile-viewers (WVMP) import. Named viewers drip into a workspace table + the deduped mirror (view_count / last_viewed_at accumulate) over many ticks, and newly-seen viewers are bridged into an enrollable CRM static list; poll `viewers status` to watch them accrue. No messages are sent and no Oxygen credits are charged.")
|
|
4896
|
+
.option("--account <ref>", "Sender account to import (sender id, connection id, or Unipile account id). Omit to import every active LinkedIn account.")
|
|
4897
|
+
.option("--table <ref>", "Existing workspace table (id or slug) to import into. Omit to create or reuse a 'LinkedIn Profile Viewers' table.")
|
|
4898
|
+
.option("--list <slug>", "Enrollable CRM static list slug to bridge new viewers into. Omit for the default 'linkedin-profile-viewers' list.")
|
|
4899
|
+
.option("--json", "Print a JSON envelope.")
|
|
4900
|
+
.action(async (options) => {
|
|
4901
|
+
await handleAsyncAction("viewers import", options, () => {
|
|
4902
|
+
const account = readOption(options.account);
|
|
4903
|
+
const table = readOption(options.table);
|
|
4904
|
+
const list = readOption(options.list);
|
|
4905
|
+
return requestOxygen("/api/cli/linkedin/viewers/import", {
|
|
4906
|
+
method: "POST",
|
|
4907
|
+
body: {
|
|
4908
|
+
...(account ? { account } : {}),
|
|
4909
|
+
...(table ? { table } : {}),
|
|
4910
|
+
...(list ? { list } : {}),
|
|
4911
|
+
},
|
|
4912
|
+
});
|
|
4913
|
+
});
|
|
4914
|
+
}))
|
|
4915
|
+
.addCommand(new Command("status")
|
|
4916
|
+
.description("Show durable profile-viewers (WVMP) import progress (per-sender drip status, viewers ingested, last error, the preserved privacy ceiling note) plus a preview of the viewers table, the enrollable CRM list, and a deep-link to open it.")
|
|
4917
|
+
.option("--account <ref>", "Scope to one sender account (sender id, connection id, or Unipile account id).")
|
|
4918
|
+
.option("--json", "Print a JSON envelope.")
|
|
4919
|
+
.action(async (options) => {
|
|
4920
|
+
await handleAsyncAction("viewers status", options, () => {
|
|
4921
|
+
const account = readOption(options.account);
|
|
4922
|
+
const params = new URLSearchParams();
|
|
4923
|
+
if (account)
|
|
4924
|
+
params.set("account", account);
|
|
4925
|
+
const suffix = params.toString();
|
|
4926
|
+
return requestOxygen(`/api/cli/linkedin/viewers/status${suffix ? `?${suffix}` : ""}`);
|
|
4927
|
+
});
|
|
4928
|
+
})));
|
|
4552
4929
|
program.addCommand(new Command("engagement")
|
|
4553
4930
|
.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
4931
|
.addCommand(new Command("harvest")
|
|
@@ -4605,6 +4982,67 @@ export function createProgram() {
|
|
|
4605
4982
|
const suffix = params.toString();
|
|
4606
4983
|
return requestOxygen(`/api/cli/linkedin/engagement/harvest${suffix ? `?${suffix}` : ""}`);
|
|
4607
4984
|
});
|
|
4985
|
+
}))
|
|
4986
|
+
.addCommand(new Command("react")
|
|
4987
|
+
.description("React to a LinkedIn post (or to a comment with --comment-id). A real external LinkedIn write that everyone can see and that counts against the sender account's daily action quota, gated behind approval: prints a preview by default and only reacts when you pass --approved. --post is the composite post social_id from `oxygen posts get` (NOT the activity URN).")
|
|
4988
|
+
.requiredOption("--post <social_id>", "Composite post social_id from `oxygen posts get` (NOT the activity URN).")
|
|
4989
|
+
.option("--reaction-type <type>", "Reaction kind: like, celebrate, support, love, insightful, or funny (default like).")
|
|
4990
|
+
.option("--comment-id <id>", "React to this comment id instead of the post itself.")
|
|
4991
|
+
.option("--as-organization <org>", "LinkedIn organization id to react as a company page.")
|
|
4992
|
+
.option("--account <ref>", "Sender account that reacts (sender id, connection id, or Unipile account id). Omit for the org default.")
|
|
4993
|
+
.option("--approved", "Actually add the reaction. Omit to preview only.")
|
|
4994
|
+
.option("--json", "Print a JSON envelope.")
|
|
4995
|
+
.action(async (options) => {
|
|
4996
|
+
await handleAsyncAction("engagement react", options, () => {
|
|
4997
|
+
const post = readOption(options.post);
|
|
4998
|
+
const reactionType = readOption(options.reactionType);
|
|
4999
|
+
const commentId = readOption(options.commentId);
|
|
5000
|
+
const asOrganization = readOption(options.asOrganization);
|
|
5001
|
+
const account = readOption(options.account);
|
|
5002
|
+
return requestOxygen("/api/cli/linkedin/engagement/react", {
|
|
5003
|
+
method: "POST",
|
|
5004
|
+
body: {
|
|
5005
|
+
...(post ? { post } : {}),
|
|
5006
|
+
...(reactionType ? { reaction_type: reactionType } : {}),
|
|
5007
|
+
...(commentId ? { comment_id: commentId } : {}),
|
|
5008
|
+
...(asOrganization ? { as_organization: asOrganization } : {}),
|
|
5009
|
+
...(account ? { account } : {}),
|
|
5010
|
+
...(options.approved ? { approved: true } : {}),
|
|
5011
|
+
},
|
|
5012
|
+
});
|
|
5013
|
+
});
|
|
5014
|
+
}))
|
|
5015
|
+
.addCommand(new Command("comment")
|
|
5016
|
+
.description("Comment on a LinkedIn post (or reply to a comment with --comment-id). A real external LinkedIn write that everyone can see and that counts against the sender account's daily action quota, gated behind approval: prints a preview by default and only posts when you pass --approved. --post is the composite post social_id from `oxygen posts get` (NOT the activity URN).")
|
|
5017
|
+
.requiredOption("--post <social_id>", "Composite post social_id from `oxygen posts get` (NOT the activity URN).")
|
|
5018
|
+
.requiredOption("--text <text>", "Comment text to post.")
|
|
5019
|
+
.option("--comment-id <id>", "Reply under this comment id instead of commenting on the post.")
|
|
5020
|
+
.option("--as-organization <org>", "LinkedIn organization id to comment as a company page.")
|
|
5021
|
+
.option("--external-link <url>", "URL to attach as a link preview on the comment.")
|
|
5022
|
+
.option("--account <ref>", "Sender account that comments (sender id, connection id, or Unipile account id). Omit for the org default.")
|
|
5023
|
+
.option("--approved", "Actually post the comment. Omit to preview only.")
|
|
5024
|
+
.option("--json", "Print a JSON envelope.")
|
|
5025
|
+
.action(async (options) => {
|
|
5026
|
+
await handleAsyncAction("engagement comment", options, () => {
|
|
5027
|
+
const post = readOption(options.post);
|
|
5028
|
+
const text = readOption(options.text);
|
|
5029
|
+
const commentId = readOption(options.commentId);
|
|
5030
|
+
const asOrganization = readOption(options.asOrganization);
|
|
5031
|
+
const externalLink = readOption(options.externalLink);
|
|
5032
|
+
const account = readOption(options.account);
|
|
5033
|
+
return requestOxygen("/api/cli/linkedin/engagement/comment", {
|
|
5034
|
+
method: "POST",
|
|
5035
|
+
body: {
|
|
5036
|
+
...(post ? { post } : {}),
|
|
5037
|
+
...(text ? { text } : {}),
|
|
5038
|
+
...(commentId ? { comment_id: commentId } : {}),
|
|
5039
|
+
...(asOrganization ? { as_organization: asOrganization } : {}),
|
|
5040
|
+
...(externalLink ? { external_link: externalLink } : {}),
|
|
5041
|
+
...(account ? { account } : {}),
|
|
5042
|
+
...(options.approved ? { approved: true } : {}),
|
|
5043
|
+
},
|
|
5044
|
+
});
|
|
5045
|
+
});
|
|
4608
5046
|
})));
|
|
4609
5047
|
program.addCommand(new Command("linkedin")
|
|
4610
5048
|
.description("LinkedIn read-into-workspace ingestion controls (Goal-3): the unified status of the connections / engagement / message-history drips and each account's remaining ingest read budget.")
|
|
@@ -4623,6 +5061,74 @@ export function createProgram() {
|
|
|
4623
5061
|
const suffix = params.toString();
|
|
4624
5062
|
return requestOxygen(`/api/cli/linkedin/ingestion/status${suffix ? `?${suffix}` : ""}`);
|
|
4625
5063
|
});
|
|
5064
|
+
})))
|
|
5065
|
+
.addCommand(new Command("connections")
|
|
5066
|
+
.description("Send LinkedIn connection invitations from a sender account.")
|
|
5067
|
+
.addCommand(new Command("invite")
|
|
5068
|
+
.description("Send a LinkedIn connection invitation. A real external social action gated behind approval: prints a preview by default and only sends when you pass --approved. The recipient can be a member provider id (ACo…) or a profile URL / public identifier. The per-account invite quota and warm-up ramp are enforced server-side, and already-invited recipients are de-duplicated automatically.")
|
|
5069
|
+
.requiredOption("--recipient <id_or_url>", "Recipient LinkedIn provider id (ACo…) or profile URL / public identifier.")
|
|
5070
|
+
.option("--account <ref>", "Sender account that sends the invite (sender id, connection id, or Unipile account id). Omit for the org default.")
|
|
5071
|
+
.option("--message <text>", "Optional invitation note (~300 chars max).")
|
|
5072
|
+
.option("--approved", "Actually send the invite. Omit to preview only.")
|
|
5073
|
+
.option("--json", "Print a JSON envelope.")
|
|
5074
|
+
.action(async (options) => {
|
|
5075
|
+
await handleAsyncAction("linkedin connections invite", options, () => {
|
|
5076
|
+
const recipient = readOption(options.recipient);
|
|
5077
|
+
const account = readOption(options.account);
|
|
5078
|
+
const message = readOption(options.message);
|
|
5079
|
+
return requestOxygen("/api/cli/linkedin/connections/invite", {
|
|
5080
|
+
method: "POST",
|
|
5081
|
+
body: {
|
|
5082
|
+
...(recipient ? { recipient } : {}),
|
|
5083
|
+
...(account ? { account } : {}),
|
|
5084
|
+
...(message ? { message } : {}),
|
|
5085
|
+
...(options.approved ? { approved: true } : {}),
|
|
5086
|
+
},
|
|
5087
|
+
});
|
|
5088
|
+
});
|
|
5089
|
+
})))
|
|
5090
|
+
.addCommand(new Command("invitations")
|
|
5091
|
+
.description("List and withdraw the pending LinkedIn connection invitations a sender account has sent.")
|
|
5092
|
+
.addCommand(new Command("withdraw")
|
|
5093
|
+
.description("Withdraw pending sent LinkedIn invitations. A real external social action gated behind approval: prints the resolved target list by default and only withdraws when you pass --approved. Select targets with --ids (a comma-separated id list from `linkedin invitations list`) OR --older-than <days> (pending invites older than N days, among the 100 most recent sent) — not both. Idempotent: an invite that is already gone counts as withdrawn.")
|
|
5094
|
+
.option("--ids <a,b>", "Comma-separated invitation ids to withdraw (from `linkedin invitations list`).")
|
|
5095
|
+
.option("--older-than <days>", "Withdraw pending sent invites older than this many days (among the 100 most recent sent) instead of an explicit id list.")
|
|
5096
|
+
.option("--account <ref>", "Sender account (sender id, connection id, or Unipile account id). Omit for the org default.")
|
|
5097
|
+
.option("--approved", "Actually withdraw. Omit to preview the target list only.")
|
|
5098
|
+
.option("--json", "Print a JSON envelope.")
|
|
5099
|
+
.action(async (options) => {
|
|
5100
|
+
await handleAsyncAction("linkedin invitations withdraw", options, () => {
|
|
5101
|
+
const ids = (options.ids ?? "").split(",").map((id) => id.trim()).filter(Boolean);
|
|
5102
|
+
const olderThan = readPositiveInteger(options.olderThan);
|
|
5103
|
+
const account = readOption(options.account);
|
|
5104
|
+
return requestOxygen("/api/cli/linkedin/connections/invite/withdraw", {
|
|
5105
|
+
method: "POST",
|
|
5106
|
+
body: {
|
|
5107
|
+
...(ids.length > 0 ? { invitation_ids: ids } : {}),
|
|
5108
|
+
...(olderThan !== undefined ? { older_than_days: olderThan } : {}),
|
|
5109
|
+
...(account ? { account } : {}),
|
|
5110
|
+
...(options.approved ? { approved: true } : {}),
|
|
5111
|
+
},
|
|
5112
|
+
});
|
|
5113
|
+
});
|
|
5114
|
+
}))
|
|
5115
|
+
.addCommand(new Command("list")
|
|
5116
|
+
.description("List the account's pending sent LinkedIn invitations with their invitation ids — the ids `linkedin invitations withdraw --ids` cancels. Read-only: no provider write, no credits.")
|
|
5117
|
+
.option("--account <ref>", "Scope to one sender account (sender id, connection id, or Unipile account id).")
|
|
5118
|
+
.option("--limit <n>", "Maximum invitations to return.")
|
|
5119
|
+
.option("--json", "Print a JSON envelope.")
|
|
5120
|
+
.action(async (options) => {
|
|
5121
|
+
await handleAsyncAction("linkedin invitations list", options, () => {
|
|
5122
|
+
const account = readOption(options.account);
|
|
5123
|
+
const limit = readPositiveInteger(options.limit);
|
|
5124
|
+
const params = new URLSearchParams();
|
|
5125
|
+
if (account)
|
|
5126
|
+
params.set("account", account);
|
|
5127
|
+
if (limit !== undefined)
|
|
5128
|
+
params.set("limit", String(limit));
|
|
5129
|
+
const suffix = params.toString();
|
|
5130
|
+
return requestOxygen(`/api/cli/linkedin/connections/invites-sent${suffix ? `?${suffix}` : ""}`);
|
|
5131
|
+
});
|
|
4626
5132
|
}))));
|
|
4627
5133
|
program.addCommand(new Command("whatsapp")
|
|
4628
5134
|
.description("Manage the org's connected WhatsApp accounts for Sequencer: list, connect, sync, inspect, disconnect, tune daily limits, and warm-send into an existing conversation. Cold outbound runs through a WhatsApp sequence (sequences create --channels whatsapp).")
|
|
@@ -4866,13 +5372,14 @@ export function createProgram() {
|
|
|
4866
5372
|
.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.")
|
|
4867
5373
|
.argument("<conversation>", "Conversation id, Unipile chat id, or (email) Zapbox thread id.")
|
|
4868
5374
|
.option("--channel <channel>", "Inbox channel: email (default), linkedin, or whatsapp.")
|
|
5375
|
+
.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.")
|
|
4869
5376
|
.option("--json", "Print a JSON envelope.")
|
|
4870
5377
|
.action(async (conversation, options) => {
|
|
4871
5378
|
await handleAsyncAction("inbox analyze", options, () => {
|
|
4872
5379
|
const channel = readOption(options.channel) ?? "email";
|
|
4873
5380
|
return requestOxygen(`/api/cli/inbox/${encodeURIComponent(conversation)}/analyze`, {
|
|
4874
5381
|
method: "POST",
|
|
4875
|
-
body: { channel },
|
|
5382
|
+
body: { channel, ...(options.force ? { force: true } : {}) },
|
|
4876
5383
|
});
|
|
4877
5384
|
});
|
|
4878
5385
|
}))
|
|
@@ -5367,9 +5874,11 @@ export function createProgram() {
|
|
|
5367
5874
|
await handleAsyncAction("sequences get", options, () => requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}`));
|
|
5368
5875
|
}))
|
|
5369
5876
|
.addCommand(new Command("enroll")
|
|
5370
|
-
.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.")
|
|
5877
|
+
.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. The org do-not-contact list is always enforced; --exclude-contacted and --suppress-list add further opt-in skips (reported under skipped_by_reason).")
|
|
5371
5878
|
.argument("<sequence>", "Sequence id or slug.")
|
|
5372
5879
|
.requiredOption("--leads-file <path>", "Path to a JSON file: { \"leads\": [{ lead_provider_id, lead_name, table_row_id, row_values }] }.")
|
|
5880
|
+
.option("--exclude-contacted", "Also skip leads any OTHER active sequence is already contacting (cross-campaign exclusion). Off by default.")
|
|
5881
|
+
.option("--suppress-list <ids>", "Per-call do-not-enroll lead provider ids dropped for this enroll only: a comma-separated list, or @<path> to a file of ids (comma/whitespace separated).")
|
|
5373
5882
|
.option("--json", "Print a JSON envelope.")
|
|
5374
5883
|
.action(async (sequence, options) => {
|
|
5375
5884
|
await handleAsyncAction("sequences enroll", options, () => {
|
|
@@ -5377,9 +5886,14 @@ export function createProgram() {
|
|
|
5377
5886
|
if (!leadsPath)
|
|
5378
5887
|
throw new Error("--leads-file is required.");
|
|
5379
5888
|
const parsed = readJsonFileValue(resolve(leadsPath), "--leads-file");
|
|
5889
|
+
const suppressList = parseSuppressListOption(options.suppressList);
|
|
5380
5890
|
return requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/enroll`, {
|
|
5381
5891
|
method: "POST",
|
|
5382
|
-
body: {
|
|
5892
|
+
body: {
|
|
5893
|
+
leads: parsed.leads ?? [],
|
|
5894
|
+
...(options.excludeContacted ? { exclude_contacted: true } : {}),
|
|
5895
|
+
...(suppressList.length > 0 ? { suppress_list: suppressList } : {}),
|
|
5896
|
+
},
|
|
5383
5897
|
});
|
|
5384
5898
|
});
|
|
5385
5899
|
}))
|
|
@@ -5476,6 +5990,71 @@ export function createProgram() {
|
|
|
5476
5990
|
.action(async (sequence, options) => {
|
|
5477
5991
|
await handleSequenceVariantsAction(sequence, options);
|
|
5478
5992
|
})));
|
|
5993
|
+
program.addCommand(new Command("suppressions")
|
|
5994
|
+
.description("Org do-not-contact list (people/multichannel): lead provider ids the sequencer enroller always skips at plan time. list | add | remove. Consumes 0 credits.")
|
|
5995
|
+
.addCommand(new Command("list")
|
|
5996
|
+
.description("List the org's do-not-contact suppressions, newest first. Filter by --reason or by --search (case-insensitive substring of the lead provider id).")
|
|
5997
|
+
.option("--reason <reason>", "Filter by reason: manual, replied, unsubscribed, bounced, do_not_contact, friends.")
|
|
5998
|
+
.option("--search <text>", "Case-insensitive substring match on the lead provider id.")
|
|
5999
|
+
.option("--limit <n>", "Maximum suppressions to return (1-500; default 100).")
|
|
6000
|
+
.option("--offset <n>", "Pagination offset (0-based).")
|
|
6001
|
+
.option("--json", "Print a JSON envelope.")
|
|
6002
|
+
.action(async (options) => {
|
|
6003
|
+
await handleAsyncAction("suppressions list", options, () => {
|
|
6004
|
+
const params = new URLSearchParams();
|
|
6005
|
+
const reason = readOption(options.reason);
|
|
6006
|
+
if (reason)
|
|
6007
|
+
params.set("reason", reason);
|
|
6008
|
+
const search = readOption(options.search);
|
|
6009
|
+
if (search)
|
|
6010
|
+
params.set("search", search);
|
|
6011
|
+
const limit = readOption(options.limit);
|
|
6012
|
+
if (limit)
|
|
6013
|
+
params.set("limit", limit);
|
|
6014
|
+
const offset = readOption(options.offset);
|
|
6015
|
+
if (offset)
|
|
6016
|
+
params.set("offset", offset);
|
|
6017
|
+
const suffix = params.toString();
|
|
6018
|
+
return requestOxygen(`/api/cli/suppressions${suffix ? `?${suffix}` : ""}`);
|
|
6019
|
+
});
|
|
6020
|
+
}))
|
|
6021
|
+
.addCommand(new Command("add")
|
|
6022
|
+
.description("Add (or refresh) a lead provider id on the org do-not-contact list so the sequencer never enrolls them again. Idempotent. --reason defaults to manual.")
|
|
6023
|
+
.requiredOption("--lead <provider_id>", "The lead provider id (e.g. a LinkedIn ACo... id) to suppress.")
|
|
6024
|
+
.option("--reason <reason>", "Why: manual (default), replied, unsubscribed, bounced, do_not_contact, friends.")
|
|
6025
|
+
.option("--detail <text>", "Optional free-text note stored with the suppression.")
|
|
6026
|
+
.option("--json", "Print a JSON envelope.")
|
|
6027
|
+
.action(async (options) => {
|
|
6028
|
+
await handleAsyncAction("suppressions add", options, () => {
|
|
6029
|
+
const lead = readOption(options.lead);
|
|
6030
|
+
if (!lead)
|
|
6031
|
+
throw new Error("--lead is required.");
|
|
6032
|
+
const reason = readOption(options.reason);
|
|
6033
|
+
const detail = readOption(options.detail);
|
|
6034
|
+
return requestOxygen("/api/cli/suppressions", {
|
|
6035
|
+
method: "POST",
|
|
6036
|
+
body: {
|
|
6037
|
+
lead_provider_id: lead,
|
|
6038
|
+
...(reason ? { reason } : {}),
|
|
6039
|
+
...(detail ? { detail } : {}),
|
|
6040
|
+
},
|
|
6041
|
+
});
|
|
6042
|
+
});
|
|
6043
|
+
}))
|
|
6044
|
+
.addCommand(new Command("remove")
|
|
6045
|
+
.description("Remove a lead provider id from the org do-not-contact list (re-enable contact).")
|
|
6046
|
+
.requiredOption("--lead <provider_id>", "The lead provider id to un-suppress.")
|
|
6047
|
+
.option("--json", "Print a JSON envelope.")
|
|
6048
|
+
.action(async (options) => {
|
|
6049
|
+
await handleAsyncAction("suppressions remove", options, () => {
|
|
6050
|
+
const lead = readOption(options.lead);
|
|
6051
|
+
if (!lead)
|
|
6052
|
+
throw new Error("--lead is required.");
|
|
6053
|
+
return requestOxygen(`/api/cli/suppressions?lead_provider_id=${encodeURIComponent(lead)}`, {
|
|
6054
|
+
method: "DELETE",
|
|
6055
|
+
});
|
|
6056
|
+
});
|
|
6057
|
+
})));
|
|
5479
6058
|
program.addCommand(new Command("email")
|
|
5480
6059
|
.description("Ad-hoc email from the org's sending fleet: one-off sends with no sequence, enrollment, or contact sync. Zapbox-connected mailboxes send through Zapmail's API (no Google/Microsoft consent); BYOK — 0 Oxygen credits.")
|
|
5481
6060
|
.addCommand(new Command("send")
|
|
@@ -5520,6 +6099,13 @@ export function createProgram() {
|
|
|
5520
6099
|
const suffix = params.toString();
|
|
5521
6100
|
return requestOxygen(`/api/cli/mailboxes${suffix ? `?${suffix}` : ""}`);
|
|
5522
6101
|
});
|
|
6102
|
+
}))
|
|
6103
|
+
.addCommand(new Command("get")
|
|
6104
|
+
.description("Get one sending mailbox's detail (provider, status, daily cap, warmup state, auth mode) plus a one-row pool summary. <mailbox> accepts a mailbox id or email address.")
|
|
6105
|
+
.argument("<mailbox>", "Mailbox id or email address.")
|
|
6106
|
+
.option("--json", "Print a JSON envelope.")
|
|
6107
|
+
.action(async (mailbox, options) => {
|
|
6108
|
+
await handleAsyncAction("mailboxes get", options, () => requestOxygen(`/api/cli/mailboxes/${encodeURIComponent(mailbox)}`));
|
|
5523
6109
|
}))
|
|
5524
6110
|
.addCommand(new Command("import")
|
|
5525
6111
|
.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.")
|
|
@@ -5571,6 +6157,24 @@ export function createProgram() {
|
|
|
5571
6157
|
});
|
|
5572
6158
|
});
|
|
5573
6159
|
}))
|
|
6160
|
+
.addCommand(new Command("cap")
|
|
6161
|
+
.description("View and adjust a single mailbox's daily send cap (throttle a problem inbox or ramp a newly warmed one without pausing it).")
|
|
6162
|
+
.addCommand(new Command("set")
|
|
6163
|
+
.description("Set one sending mailbox's daily send cap (sends per day). --daily-cap must be a positive whole number. Consumes no Oxygen credits. <mailbox> accepts a mailbox id or email address.")
|
|
6164
|
+
.argument("<mailbox>", "Mailbox id or email address.")
|
|
6165
|
+
.requiredOption("--daily-cap <n>", "New daily send cap (a positive whole number of sends per day).")
|
|
6166
|
+
.option("--json", "Print a JSON envelope.")
|
|
6167
|
+
.action(async (mailbox, options) => {
|
|
6168
|
+
await handleAsyncAction("mailboxes cap set", options, () => {
|
|
6169
|
+
const raw = readOption(options.dailyCap);
|
|
6170
|
+
if (!raw)
|
|
6171
|
+
throw new Error("--daily-cap is required.");
|
|
6172
|
+
return requestOxygen(`/api/cli/mailboxes/${encodeURIComponent(mailbox)}/cap`, {
|
|
6173
|
+
method: "PATCH",
|
|
6174
|
+
body: { daily_cap: Number(raw) },
|
|
6175
|
+
});
|
|
6176
|
+
});
|
|
6177
|
+
})))
|
|
5574
6178
|
.addCommand(new Command("connect-oauth")
|
|
5575
6179
|
.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.")
|
|
5576
6180
|
.option("--provider <provider>", "Mailbox provider to provision: google or microsoft.")
|
|
@@ -5847,6 +6451,16 @@ export function createProgram() {
|
|
|
5847
6451
|
.option("--json", "Print a JSON envelope.")
|
|
5848
6452
|
.action(async (options) => {
|
|
5849
6453
|
await handleAsyncAction("workflows schema", options, () => requestOxygen(`/api/cli/workflows/schema?subject=${encodeURIComponent(options.subject ?? "all")}`));
|
|
6454
|
+
}))
|
|
6455
|
+
.addCommand(new Command("init")
|
|
6456
|
+
.description("Scaffold a local durable-recipe project (starter recipe, vendored @oxygen/recipe-sdk types, tsconfig) so your editor resolves the SDK without an npm install.")
|
|
6457
|
+
.option("--dir <path>", "Directory to scaffold into. Defaults to the current directory.")
|
|
6458
|
+
.option("--id <id>", "Recipe id for the starter file. Defaults to 'my-recipe'.")
|
|
6459
|
+
.option("--name <name>", "Recipe display name. Defaults to 'My recipe'.")
|
|
6460
|
+
.option("--force", "Overwrite existing files instead of skipping them.")
|
|
6461
|
+
.option("--json", "Print a JSON envelope.")
|
|
6462
|
+
.action(async (options) => {
|
|
6463
|
+
await handleAsyncAction("workflows init", options, async () => scaffoldRecipeProject(options));
|
|
5850
6464
|
}))
|
|
5851
6465
|
.addCommand(new Command("lint")
|
|
5852
6466
|
.description("Compile and lint a workflow file without saving it.")
|
|
@@ -6166,6 +6780,131 @@ function asWorkflowCompileError(error, filePath) {
|
|
|
6166
6780
|
exitCode: 1,
|
|
6167
6781
|
});
|
|
6168
6782
|
}
|
|
6783
|
+
// ---- oxygen workflows init: scaffold a local durable-recipe project ----
|
|
6784
|
+
//
|
|
6785
|
+
// @oxygen/recipe-sdk is never published to npm — it ships bundled inside this CLI
|
|
6786
|
+
// (scripts/build-cli-npm-package.mjs → bundledDependencies). `init` vendors the SDK's
|
|
6787
|
+
// compiled type declarations into the user's project so their editor resolves
|
|
6788
|
+
// `@oxygen/recipe-sdk` (and the `@oxygen/workflows` types it re-exports) without an npm
|
|
6789
|
+
// install. We read the .d.ts from the same node paths esbuild uses to bundle the runtime
|
|
6790
|
+
// at `apply` time, so the vendored types stay locked to the CLI actually installed.
|
|
6791
|
+
const RECIPE_TYPES_VENDOR_DIR = ".oxygen/types";
|
|
6792
|
+
const RECIPE_INIT_ID_PATTERN = /^[a-z0-9][a-z0-9_-]*$/i;
|
|
6793
|
+
function readBundledSdkDts(pkg) {
|
|
6794
|
+
for (const base of RECIPE_ESBUILD_NODE_PATHS) {
|
|
6795
|
+
const candidate = join(base, "@oxygen", pkg, "dist", "index.d.ts");
|
|
6796
|
+
if (existsSync(candidate)) {
|
|
6797
|
+
try {
|
|
6798
|
+
return readFileSync(candidate, "utf8");
|
|
6799
|
+
}
|
|
6800
|
+
catch {
|
|
6801
|
+
// Unreadable copy — fall through to the next node path.
|
|
6802
|
+
}
|
|
6803
|
+
}
|
|
6804
|
+
}
|
|
6805
|
+
return null;
|
|
6806
|
+
}
|
|
6807
|
+
function renderStarterRecipe(id, name) {
|
|
6808
|
+
return [
|
|
6809
|
+
`import { defineRecipe, type RecipeContext } from "@oxygen/recipe-sdk";`,
|
|
6810
|
+
``,
|
|
6811
|
+
`// A durable recipe is the default export of defineRecipe(...); it compiles into an`,
|
|
6812
|
+
`// Oxygen workflow. Recipes reach external systems ONLY through catalog tools —`,
|
|
6813
|
+
`// raw fetch/network is disabled in the recipe sandbox.`,
|
|
6814
|
+
`//`,
|
|
6815
|
+
`// Deploy: oxygen workflows apply --file ./${id}.ts`,
|
|
6816
|
+
`// Dry-run: oxygen workflows call <workflow-id> --mode dry_run --json`,
|
|
6817
|
+
`// Docs: https://oxygen-agent.com/docs/authoring/recipes`,
|
|
6818
|
+
`export default defineRecipe({`,
|
|
6819
|
+
` id: ${JSON.stringify(id)},`,
|
|
6820
|
+
` name: ${JSON.stringify(name)},`,
|
|
6821
|
+
` // Allowlist of every tool id this recipe may call — including native table`,
|
|
6822
|
+
` // ops, which dispatch through oxygen.* tools (e.g. oxygen.rows_upsert for`,
|
|
6823
|
+
` // ctx.rows.upsert). Discover provider tools with: oxygen tools search <query>`,
|
|
6824
|
+
` tools: ["firecrawl.scrape", "oxygen.rows_upsert"],`,
|
|
6825
|
+
` trigger: { type: "api" },`,
|
|
6826
|
+
` inputSchema: {`,
|
|
6827
|
+
` type: "object",`,
|
|
6828
|
+
` properties: {},`,
|
|
6829
|
+
` },`,
|
|
6830
|
+
` async run(ctx: RecipeContext) {`,
|
|
6831
|
+
` ctx.log("info", "recipe started", { mode: ctx.mode });`,
|
|
6832
|
+
` // Example: pull data through a tool, then persist it to a table.`,
|
|
6833
|
+
` // const page = await ctx.tools.run("firecrawl.scrape", { url }, { key: "scrape" });`,
|
|
6834
|
+
` // await ctx.rows.upsert("my_table", rows, { key: "save", upsertKey: "id" });`,
|
|
6835
|
+
` return { ok: true, at: await ctx.now() };`,
|
|
6836
|
+
` },`,
|
|
6837
|
+
`});`,
|
|
6838
|
+
``,
|
|
6839
|
+
].join("\n");
|
|
6840
|
+
}
|
|
6841
|
+
function renderRecipeTsconfig() {
|
|
6842
|
+
const tsconfig = {
|
|
6843
|
+
compilerOptions: {
|
|
6844
|
+
target: "ES2022",
|
|
6845
|
+
module: "ESNext",
|
|
6846
|
+
moduleResolution: "Bundler",
|
|
6847
|
+
strict: true,
|
|
6848
|
+
noEmit: true,
|
|
6849
|
+
skipLibCheck: true,
|
|
6850
|
+
types: [],
|
|
6851
|
+
// paths are resolved relative to this tsconfig (moduleResolution "Bundler"
|
|
6852
|
+
// needs no baseUrl — and baseUrl is deprecated in TypeScript 6+).
|
|
6853
|
+
paths: {
|
|
6854
|
+
"@oxygen/recipe-sdk": [`./${RECIPE_TYPES_VENDOR_DIR}/@oxygen/recipe-sdk/index.d.ts`],
|
|
6855
|
+
"@oxygen/workflows": [`./${RECIPE_TYPES_VENDOR_DIR}/@oxygen/workflows/index.d.ts`],
|
|
6856
|
+
},
|
|
6857
|
+
},
|
|
6858
|
+
include: ["*.ts"],
|
|
6859
|
+
};
|
|
6860
|
+
return `${JSON.stringify(tsconfig, null, 2)}\n`;
|
|
6861
|
+
}
|
|
6862
|
+
function scaffoldRecipeProject(options) {
|
|
6863
|
+
const recipeId = readOption(options.id) ?? "my-recipe";
|
|
6864
|
+
if (!RECIPE_INIT_ID_PATTERN.test(recipeId)) {
|
|
6865
|
+
throw new OxygenError("invalid_recipe_id", "Recipe id must start with a letter or digit and contain only letters, digits, dashes, or underscores.", {
|
|
6866
|
+
details: { id: recipeId },
|
|
6867
|
+
exitCode: 1,
|
|
6868
|
+
});
|
|
6869
|
+
}
|
|
6870
|
+
const recipeName = readOption(options.name) ?? "My recipe";
|
|
6871
|
+
const directory = resolve(readOption(options.dir) ?? ".");
|
|
6872
|
+
const force = options.force === true;
|
|
6873
|
+
const recipeDts = readBundledSdkDts("recipe-sdk");
|
|
6874
|
+
const workflowsDts = readBundledSdkDts("workflows");
|
|
6875
|
+
if (!recipeDts || !workflowsDts) {
|
|
6876
|
+
throw new OxygenError("recipe_sdk_types_unavailable", "Could not locate the @oxygen/recipe-sdk types bundled with this CLI. Update the CLI (npm install -g @oxygen-agent/cli@latest) and retry.", { exitCode: 1 });
|
|
6877
|
+
}
|
|
6878
|
+
const recipeFileName = `${recipeId}.ts`;
|
|
6879
|
+
const targets = [
|
|
6880
|
+
{ path: join(directory, recipeFileName), content: renderStarterRecipe(recipeId, recipeName) },
|
|
6881
|
+
{ path: join(directory, "tsconfig.json"), content: renderRecipeTsconfig() },
|
|
6882
|
+
{ path: join(directory, RECIPE_TYPES_VENDOR_DIR, "@oxygen", "recipe-sdk", "index.d.ts"), content: recipeDts },
|
|
6883
|
+
{ path: join(directory, RECIPE_TYPES_VENDOR_DIR, "@oxygen", "workflows", "index.d.ts"), content: workflowsDts },
|
|
6884
|
+
];
|
|
6885
|
+
const filesWritten = [];
|
|
6886
|
+
const filesSkipped = [];
|
|
6887
|
+
for (const target of targets) {
|
|
6888
|
+
if (existsSync(target.path) && !force) {
|
|
6889
|
+
filesSkipped.push(target.path);
|
|
6890
|
+
continue;
|
|
6891
|
+
}
|
|
6892
|
+
mkdirSync(dirname(target.path), { recursive: true });
|
|
6893
|
+
writeFileSync(target.path, target.content, "utf8");
|
|
6894
|
+
filesWritten.push(target.path);
|
|
6895
|
+
}
|
|
6896
|
+
return {
|
|
6897
|
+
directory,
|
|
6898
|
+
recipe_file: join(directory, recipeFileName),
|
|
6899
|
+
files_written: filesWritten,
|
|
6900
|
+
files_skipped: filesSkipped,
|
|
6901
|
+
next_steps: [
|
|
6902
|
+
`Edit ${recipeFileName}, then find tools with: oxygen tools search <query>`,
|
|
6903
|
+
`Deploy it: oxygen workflows apply --file ./${recipeFileName}`,
|
|
6904
|
+
`Dry-run it: oxygen workflows call <workflow-id> --mode dry_run --json`,
|
|
6905
|
+
],
|
|
6906
|
+
};
|
|
6907
|
+
}
|
|
6169
6908
|
async function compileWorkflowFile(filePath) {
|
|
6170
6909
|
try {
|
|
6171
6910
|
return await loadWorkflowManifestFromFile(filePath);
|
|
@@ -9629,6 +10368,129 @@ function formatSequenceVariants(data) {
|
|
|
9629
10368
|
lines.push("");
|
|
9630
10369
|
return lines.join("\n");
|
|
9631
10370
|
}
|
|
10371
|
+
// `columns reorder` accepts either an absolute --position or a relative
|
|
10372
|
+
// --before/--after naming a sibling column. The relative form is resolved
|
|
10373
|
+
// client-side into an absolute index against the table's current column order
|
|
10374
|
+
// (fetched via /api/cli/tables/describe — the same describe the other column
|
|
10375
|
+
// commands read) so the server only ever receives a single numeric position.
|
|
10376
|
+
// Exactly one of the three flags must be supplied.
|
|
10377
|
+
async function resolveColumnReorderPosition(table, column, options) {
|
|
10378
|
+
const before = readOption(options.before);
|
|
10379
|
+
const after = readOption(options.after);
|
|
10380
|
+
const explicit = readNonNegativeInt(options.position);
|
|
10381
|
+
const provided = [explicit !== undefined, Boolean(before), Boolean(after)].filter(Boolean).length;
|
|
10382
|
+
if (provided === 0) {
|
|
10383
|
+
throw new OxygenError("invalid_request", "Provide --position <n>, --before <column>, or --after <column>.", { exitCode: 1 });
|
|
10384
|
+
}
|
|
10385
|
+
if (provided > 1) {
|
|
10386
|
+
throw new OxygenError("invalid_request", "Pass only one of --position, --before, or --after.", { exitCode: 1 });
|
|
10387
|
+
}
|
|
10388
|
+
if (explicit !== undefined)
|
|
10389
|
+
return explicit;
|
|
10390
|
+
const reference = (before ?? after);
|
|
10391
|
+
const describe = await requestOxygen("/api/cli/tables/describe", { method: "POST", body: { table } });
|
|
10392
|
+
const columns = describe.columns ?? [];
|
|
10393
|
+
const index = columns.findIndex((candidate) => candidate.id === reference || candidate.key === reference);
|
|
10394
|
+
if (index < 0) {
|
|
10395
|
+
throw new OxygenError("invalid_request", `Column "${reference}" was not found on table "${table}".`, { details: { table, column: reference }, exitCode: 1 });
|
|
10396
|
+
}
|
|
10397
|
+
// --before lands the column at the reference's index; --after lands it one slot later.
|
|
10398
|
+
const target = before ? index : index + 1;
|
|
10399
|
+
// The server removes the source column before splicing it back in at `target`.
|
|
10400
|
+
// When the source currently sits before the reference, that removal shifts the
|
|
10401
|
+
// reference (and everything after it) down one slot, so decrement to compensate.
|
|
10402
|
+
const sourceIndex = columns.findIndex((candidate) => candidate.id === column || candidate.key === column);
|
|
10403
|
+
if (sourceIndex >= 0 && sourceIndex < index) {
|
|
10404
|
+
return target - 1;
|
|
10405
|
+
}
|
|
10406
|
+
return target;
|
|
10407
|
+
}
|
|
10408
|
+
// `tables tidy-suggest` is a read-only analysis (no credits, no writes): it
|
|
10409
|
+
// surfaces duplicate columns, formatting inconsistencies, hide candidates, and
|
|
10410
|
+
// value-sanity findings so the caller can pick which reversible cleanup to run
|
|
10411
|
+
// next. --json returns the raw report envelope; the default rendering compacts
|
|
10412
|
+
// the four sections for a terminal reader and echoes the deep-link.
|
|
10413
|
+
async function handleTablesTidySuggestAction(table, options) {
|
|
10414
|
+
try {
|
|
10415
|
+
const limit = readPositiveInt(options.limit);
|
|
10416
|
+
const categories = readCsvOption(options.categories);
|
|
10417
|
+
const data = await requestOxygen("/api/cli/tables/tidy/suggest", {
|
|
10418
|
+
method: "POST",
|
|
10419
|
+
body: {
|
|
10420
|
+
table,
|
|
10421
|
+
...(limit ? { limit } : {}),
|
|
10422
|
+
...(categories.length ? { categories } : {}),
|
|
10423
|
+
},
|
|
10424
|
+
});
|
|
10425
|
+
if (options.json) {
|
|
10426
|
+
writeJson(success("tables tidy-suggest", data));
|
|
10427
|
+
return;
|
|
10428
|
+
}
|
|
10429
|
+
process.stdout.write(formatTidySuggestions(data));
|
|
10430
|
+
}
|
|
10431
|
+
catch (error) {
|
|
10432
|
+
const failure = toFailure("tables tidy-suggest", error);
|
|
10433
|
+
writeJson(failure);
|
|
10434
|
+
writeMaxCreditsHint(error);
|
|
10435
|
+
process.exitCode = error instanceof OxygenError ? error.exitCode : 1;
|
|
10436
|
+
}
|
|
10437
|
+
}
|
|
10438
|
+
function formatTidyCount(value) {
|
|
10439
|
+
return typeof value === "number" && Number.isFinite(value) ? String(value) : "0";
|
|
10440
|
+
}
|
|
10441
|
+
function formatTidyPercent(value) {
|
|
10442
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
10443
|
+
return "—";
|
|
10444
|
+
return `${(value * 100).toFixed(0)}%`;
|
|
10445
|
+
}
|
|
10446
|
+
// Render one tidy-suggest section (header + count, then either a dim "None" or
|
|
10447
|
+
// one formatted line per item) into the shared `lines` buffer. Extracted so the
|
|
10448
|
+
// four sections share a single control-flow shape instead of repeating it.
|
|
10449
|
+
function pushTidySection(lines, styles, title, items, renderItem) {
|
|
10450
|
+
lines.push("", styles.bold(`${title} (${items.length})`));
|
|
10451
|
+
if (items.length === 0) {
|
|
10452
|
+
lines.push(` ${styles.dim("None")}`);
|
|
10453
|
+
return;
|
|
10454
|
+
}
|
|
10455
|
+
for (const item of items) {
|
|
10456
|
+
lines.push(renderItem(item));
|
|
10457
|
+
}
|
|
10458
|
+
}
|
|
10459
|
+
function formatTidySuggestions(data) {
|
|
10460
|
+
const styles = ansi(output.isTTY === true && !process.env.NO_COLOR);
|
|
10461
|
+
const duplicates = Array.isArray(data.duplicateColumns) ? data.duplicateColumns : [];
|
|
10462
|
+
const formatting = Array.isArray(data.formattingIssues) ? data.formattingIssues : [];
|
|
10463
|
+
const hide = Array.isArray(data.hideSuggestions) ? data.hideSuggestions : [];
|
|
10464
|
+
const valueSanity = Array.isArray(data.valueSanity) ? data.valueSanity : [];
|
|
10465
|
+
const lines = [
|
|
10466
|
+
"",
|
|
10467
|
+
`${styles.bold("Table tidy suggestions")} ${styles.dim(`(sampled ${formatTidyCount(data.sampledRows)} of ${formatTidyCount(data.rowCount)} rows)`)}`,
|
|
10468
|
+
];
|
|
10469
|
+
pushTidySection(lines, styles, "Duplicate columns", duplicates, (dup) => {
|
|
10470
|
+
const loser = dup.loserKey ?? dup.loserColumnId ?? "?";
|
|
10471
|
+
const survivor = dup.survivorKey ?? dup.survivorColumnId ?? "?";
|
|
10472
|
+
return ` ${loser} ${styles.dim("→ duplicate of")} ${survivor} ${styles.dim(`(${formatTidyPercent(dup.similarity)} match, ${dup.recommendation ?? "review"})`)}`;
|
|
10473
|
+
});
|
|
10474
|
+
pushTidySection(lines, styles, "Formatting issues", formatting, (issue) => {
|
|
10475
|
+
const sampleCount = Array.isArray(issue.sampleRowIds) ? issue.sampleRowIds.length : 0;
|
|
10476
|
+
const column = issue.columnKey ?? issue.columnId ?? "?";
|
|
10477
|
+
return ` ${column} ${styles.dim(`— ${issue.issue ?? "issue"}`)}${issue.detail ? `: ${issue.detail}` : ""} ${styles.dim(`(${sampleCount} sample row(s))`)}`;
|
|
10478
|
+
});
|
|
10479
|
+
pushTidySection(lines, styles, "Hide suggestions", hide, (suggestion) => {
|
|
10480
|
+
const column = suggestion.columnKey ?? suggestion.columnId ?? "?";
|
|
10481
|
+
return ` ${column} ${styles.dim(`— ${suggestion.reason ?? "hide"} (${formatTidyPercent(suggestion.fillRate)} filled)`)}`;
|
|
10482
|
+
});
|
|
10483
|
+
pushTidySection(lines, styles, "Value-sanity findings", valueSanity, (finding) => {
|
|
10484
|
+
const column = finding.columnKey ?? finding.columnId ?? "?";
|
|
10485
|
+
return ` ${column} ${styles.dim(`row ${finding.rowId ?? "?"} — ${finding.reason ?? "check value"}`)}`;
|
|
10486
|
+
});
|
|
10487
|
+
const link = data.deepLink ?? data.web_url ?? data.url;
|
|
10488
|
+
if (link) {
|
|
10489
|
+
lines.push("", styles.dim(`View table: ${link}`));
|
|
10490
|
+
}
|
|
10491
|
+
lines.push("");
|
|
10492
|
+
return lines.join("\n");
|
|
10493
|
+
}
|
|
9632
10494
|
function formatWhoami(identity, context) {
|
|
9633
10495
|
const styles = ansi(output.isTTY === true && !process.env.NO_COLOR);
|
|
9634
10496
|
const email = identity.user.email ?? identity.user.id;
|
|
@@ -9947,6 +10809,18 @@ function contextAssetsQuery(options) {
|
|
|
9947
10809
|
const value = query.toString();
|
|
9948
10810
|
return value ? `?${value}` : "";
|
|
9949
10811
|
}
|
|
10812
|
+
function buildContextAssetSearchBody(options) {
|
|
10813
|
+
const tags = readCsvOption(options.tags);
|
|
10814
|
+
const limit = readPositiveInt(options.limit);
|
|
10815
|
+
return {
|
|
10816
|
+
query: (readOption(options.query) ?? "").trim(),
|
|
10817
|
+
...(readOption(options.type) ? { type: readOption(options.type) } : {}),
|
|
10818
|
+
...(readOption(options.status) ? { status: readOption(options.status) } : {}),
|
|
10819
|
+
...(tags.length > 0 ? { tags } : {}),
|
|
10820
|
+
...(options.includeArchived ? { include_archived: true } : {}),
|
|
10821
|
+
...(limit !== undefined ? { limit } : {}),
|
|
10822
|
+
};
|
|
10823
|
+
}
|
|
9950
10824
|
function buildBillingLedgerParams(options) {
|
|
9951
10825
|
const params = new URLSearchParams();
|
|
9952
10826
|
const days = readPositiveInt(options.days);
|
|
@@ -10083,6 +10957,7 @@ options) {
|
|
|
10083
10957
|
setLimit("relations_reads_per_day", options.relationsReadsPerDay);
|
|
10084
10958
|
setLimit("messages_reads_per_day", options.messagesReadsPerDay);
|
|
10085
10959
|
setLimit("searches_per_day", options.searchesPerDay);
|
|
10960
|
+
setLimit("sales_nav_search_results_per_day", options.salesNavSearchResultsPerDay);
|
|
10086
10961
|
setLimit("api_reads_per_day", options.apiReadsPerDay);
|
|
10087
10962
|
setLimit("total_reads_per_day", options.totalReadsPerDay);
|
|
10088
10963
|
setLimit("min_action_spacing_seconds", options.minSpacingSeconds);
|