@oxygen-agent/cli 1.1014.136 → 1.1014.619
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/admin-primary-providers-render.js +27 -19
- package/dist/cli-output.d.ts +12 -0
- package/dist/cli-output.js +395 -0
- package/dist/cli-paths.d.ts +1 -0
- package/dist/cli-paths.js +3 -0
- package/dist/cli-profile-context.d.ts +3 -0
- package/dist/cli-profile-context.js +13 -0
- package/dist/command-manifest.d.ts +15 -0
- package/dist/command-manifest.js +23 -1
- package/dist/command-support.d.ts +438 -0
- package/dist/command-support.js +1610 -0
- package/dist/commands/action-column.d.ts +3 -0
- package/dist/commands/action-column.js +43 -0
- package/dist/commands/activation.d.ts +3 -0
- package/dist/commands/activation.js +24 -0
- package/dist/commands/admin.d.ts +3 -0
- package/dist/commands/admin.js +347 -0
- package/dist/commands/ads.d.ts +3 -0
- package/dist/commands/ads.js +62 -0
- package/dist/commands/agent.d.ts +3 -0
- package/dist/commands/agent.js +274 -0
- package/dist/commands/api-keys.d.ts +3 -0
- package/dist/commands/api-keys.js +77 -0
- package/dist/commands/approvals.d.ts +3 -0
- package/dist/commands/approvals.js +518 -0
- package/dist/commands/auth.d.ts +3 -0
- package/dist/commands/auth.js +230 -0
- package/dist/commands/billing.d.ts +3 -0
- package/dist/commands/billing.js +357 -0
- package/dist/commands/blueprints.d.ts +3 -0
- package/dist/commands/blueprints.js +312 -0
- package/dist/commands/budget.d.ts +3 -0
- package/dist/commands/budget.js +55 -0
- package/dist/commands/callables.d.ts +3 -0
- package/dist/commands/callables.js +111 -0
- package/dist/commands/capabilities.d.ts +3 -0
- package/dist/commands/capabilities.js +44 -0
- package/dist/commands/cells.d.ts +3 -0
- package/dist/commands/cells.js +94 -0
- package/dist/commands/columns.d.ts +3 -0
- package/dist/commands/columns.js +1628 -0
- package/dist/commands/commands.d.ts +3 -0
- package/dist/commands/commands.js +45 -0
- package/dist/commands/comments.d.ts +3 -0
- package/dist/commands/comments.js +188 -0
- package/dist/commands/companies.d.ts +3 -0
- package/dist/commands/companies.js +312 -0
- package/dist/commands/connections.d.ts +3 -0
- package/dist/commands/connections.js +39 -0
- package/dist/commands/context.d.ts +3 -0
- package/dist/commands/context.js +170 -0
- package/dist/commands/copilot.d.ts +3 -0
- package/dist/commands/copilot.js +565 -0
- package/dist/commands/crm.d.ts +3 -0
- package/dist/commands/crm.js +1672 -0
- package/dist/commands/custom-integrations.d.ts +3 -0
- package/dist/commands/custom-integrations.js +140 -0
- package/dist/commands/dashboards.d.ts +3 -0
- package/dist/commands/dashboards.js +240 -0
- package/dist/commands/db.d.ts +3 -0
- package/dist/commands/db.js +190 -0
- package/dist/commands/deliverability.d.ts +3 -0
- package/dist/commands/deliverability.js +90 -0
- package/dist/commands/directory.d.ts +3 -0
- package/dist/commands/directory.js +101 -0
- package/dist/commands/domains.d.ts +3 -0
- package/dist/commands/domains.js +604 -0
- package/dist/commands/egress.d.ts +3 -0
- package/dist/commands/egress.js +130 -0
- package/dist/commands/email.d.ts +3 -0
- package/dist/commands/email.js +49 -0
- package/dist/commands/engagement.d.ts +3 -0
- package/dist/commands/engagement.js +189 -0
- package/dist/commands/enrich-column.d.ts +3 -0
- package/dist/commands/enrich-column.js +149 -0
- package/dist/commands/enrichment.d.ts +3 -0
- package/dist/commands/enrichment.js +49 -0
- package/dist/commands/feedback.d.ts +3 -0
- package/dist/commands/feedback.js +96 -0
- package/dist/commands/feeds.d.ts +3 -0
- package/dist/commands/feeds.js +486 -0
- package/dist/commands/find.d.ts +3 -0
- package/dist/commands/find.js +159 -0
- package/dist/commands/followers.d.ts +3 -0
- package/dist/commands/followers.js +39 -0
- package/dist/commands/formulas.d.ts +3 -0
- package/dist/commands/formulas.js +43 -0
- package/dist/commands/home.d.ts +3 -0
- package/dist/commands/home.js +22 -0
- package/dist/commands/inbox.d.ts +3 -0
- package/dist/commands/inbox.js +779 -0
- package/dist/commands/integrations.d.ts +3 -0
- package/dist/commands/integrations.js +290 -0
- package/dist/commands/knowledge.d.ts +3 -0
- package/dist/commands/knowledge.js +874 -0
- package/dist/commands/lead-sourcing.d.ts +3 -0
- package/dist/commands/lead-sourcing.js +52 -0
- package/dist/commands/limits.d.ts +3 -0
- package/dist/commands/limits.js +54 -0
- package/dist/commands/linkedin.d.ts +3 -0
- package/dist/commands/linkedin.js +275 -0
- package/dist/commands/login.d.ts +3 -0
- package/dist/commands/login.js +347 -0
- package/dist/commands/logout.d.ts +3 -0
- package/dist/commands/logout.js +12 -0
- package/dist/commands/mailboxes.d.ts +3 -0
- package/dist/commands/mailboxes.js +1290 -0
- package/dist/commands/managed-inboxes.d.ts +3 -0
- package/dist/commands/managed-inboxes.js +361 -0
- package/dist/commands/messages.d.ts +3 -0
- package/dist/commands/messages.js +78 -0
- package/dist/commands/notetaker.d.ts +3 -0
- package/dist/commands/notetaker.js +79 -0
- package/dist/commands/observability.d.ts +3 -0
- package/dist/commands/observability.js +125 -0
- package/dist/commands/orgs.d.ts +3 -0
- package/dist/commands/orgs.js +237 -0
- package/dist/commands/people.d.ts +3 -0
- package/dist/commands/people.js +353 -0
- package/dist/commands/posts.d.ts +3 -0
- package/dist/commands/posts.js +139 -0
- package/dist/commands/profiles.d.ts +3 -0
- package/dist/commands/profiles.js +250 -0
- package/dist/commands/projects.d.ts +3 -0
- package/dist/commands/projects.js +94 -0
- package/dist/commands/prompts.d.ts +3 -0
- package/dist/commands/prompts.js +84 -0
- package/dist/commands/publishing.d.ts +3 -0
- package/dist/commands/publishing.js +1419 -0
- package/dist/commands/recipes.d.ts +3 -0
- package/dist/commands/recipes.js +120 -0
- package/dist/commands/reviews.d.ts +3 -0
- package/dist/commands/reviews.js +75 -0
- package/dist/commands/rows.d.ts +3 -0
- package/dist/commands/rows.js +27 -0
- package/dist/commands/runs.d.ts +3 -0
- package/dist/commands/runs.js +37 -0
- package/dist/commands/search.d.ts +3 -0
- package/dist/commands/search.js +182 -0
- package/dist/commands/senders.d.ts +3 -0
- package/dist/commands/senders.js +393 -0
- package/dist/commands/sequences.d.ts +3 -0
- package/dist/commands/sequences.js +2322 -0
- package/dist/commands/session.d.ts +3 -0
- package/dist/commands/session.js +109 -0
- package/dist/commands/signals.d.ts +3 -0
- package/dist/commands/signals.js +289 -0
- package/dist/commands/signup-leads.d.ts +3 -0
- package/dist/commands/signup-leads.js +46 -0
- package/dist/commands/skills.d.ts +3 -0
- package/dist/commands/skills.js +50 -0
- package/dist/commands/sourcing.d.ts +3 -0
- package/dist/commands/sourcing.js +27 -0
- package/dist/commands/status.d.ts +3 -0
- package/dist/commands/status.js +51 -0
- package/dist/commands/supabase.d.ts +3 -0
- package/dist/commands/supabase.js +128 -0
- package/dist/commands/support.d.ts +3 -0
- package/dist/commands/support.js +651 -0
- package/dist/commands/suppressions.d.ts +3 -0
- package/dist/commands/suppressions.js +577 -0
- package/dist/commands/table-ingestions.d.ts +3 -0
- package/dist/commands/table-ingestions.js +145 -0
- package/dist/commands/table-runs.d.ts +3 -0
- package/dist/commands/table-runs.js +277 -0
- package/dist/commands/tables.d.ts +3 -0
- package/dist/commands/tables.js +3505 -0
- package/dist/commands/tags.d.ts +3 -0
- package/dist/commands/tags.js +147 -0
- package/dist/commands/tools.d.ts +3 -0
- package/dist/commands/tools.js +246 -0
- package/dist/commands/update.d.ts +3 -0
- package/dist/commands/update.js +106 -0
- package/dist/commands/verify.d.ts +3 -0
- package/dist/commands/verify.js +27 -0
- package/dist/commands/viewers.d.ts +3 -0
- package/dist/commands/viewers.js +72 -0
- package/dist/commands/voice.d.ts +3 -0
- package/dist/commands/voice.js +351 -0
- package/dist/commands/whatsapp.d.ts +3 -0
- package/dist/commands/whatsapp.js +226 -0
- package/dist/commands/whoami.d.ts +3 -0
- package/dist/commands/whoami.js +110 -0
- package/dist/commands/worker.d.ts +3 -0
- package/dist/commands/worker.js +60 -0
- package/dist/commands/workflows.d.ts +3 -0
- package/dist/commands/workflows.js +1752 -0
- package/dist/commands/workspace.d.ts +3 -0
- package/dist/commands/workspace.js +94 -0
- package/dist/compact-list-flags.d.ts +7 -0
- package/dist/compact-list-flags.js +12 -0
- package/dist/crm-support-flags.js +14 -5
- package/dist/customer-operation-commands.js +8 -6
- package/dist/diagnostic-uploader.d.ts +3 -1
- package/dist/diagnostic-uploader.js +29 -8
- package/dist/help.d.ts +5 -0
- package/dist/help.js +22 -0
- package/dist/index.d.ts +98 -1
- package/dist/index.js +331 -30719
- package/dist/local-module-loader.d.ts +4 -0
- package/dist/local-module-loader.js +156 -0
- package/dist/visual-commands.js +9 -5
- package/node_modules/@oxygen/cli-ugc/dist/commands.js +2 -2
- package/node_modules/@oxygen/shared/dist/agent-session-schedule.d.ts +36 -0
- package/node_modules/@oxygen/shared/dist/agent-session-schedule.js +69 -0
- package/node_modules/@oxygen/shared/dist/agent-sessions.d.ts +14 -5
- package/node_modules/@oxygen/shared/dist/agent-sessions.js +14 -3
- package/node_modules/@oxygen/shared/dist/capability-discovery.js +175 -14
- package/node_modules/@oxygen/shared/dist/compact-list-result.d.ts +6 -0
- package/node_modules/@oxygen/shared/dist/compact-list-result.js +27 -0
- package/node_modules/@oxygen/shared/dist/contracts/agent-sessions.d.ts +12 -1
- package/node_modules/@oxygen/shared/dist/contracts/agent-sessions.js +5 -2
- package/node_modules/@oxygen/shared/dist/contracts/browser-operations.js +4 -2
- package/node_modules/@oxygen/shared/dist/contracts/compact-list-reads.d.ts +57 -0
- package/node_modules/@oxygen/shared/dist/contracts/compact-list-reads.js +23 -0
- package/node_modules/@oxygen/shared/dist/contracts/crm-support.d.ts +52 -0
- package/node_modules/@oxygen/shared/dist/contracts/crm-support.js +43 -2
- package/node_modules/@oxygen/shared/dist/contracts/customer-operations.js +20 -16
- package/node_modules/@oxygen/shared/dist/contracts/knowledge-content-reads.js +1 -1
- package/node_modules/@oxygen/shared/dist/contracts/knowledge-proposal-reads.d.ts +49 -0
- package/node_modules/@oxygen/shared/dist/contracts/knowledge-proposal-reads.js +14 -0
- package/node_modules/@oxygen/shared/dist/contracts/knowledge-sync.js +1 -1
- package/node_modules/@oxygen/shared/dist/contracts/limits-read.js +60 -7
- package/node_modules/@oxygen/shared/dist/contracts/operations.d.ts +9 -1
- package/node_modules/@oxygen/shared/dist/contracts/operations.js +13 -5
- package/node_modules/@oxygen/shared/dist/contracts/publishing-reads.js +1 -1
- package/node_modules/@oxygen/shared/dist/contracts/teammates.d.ts +16 -0
- package/node_modules/@oxygen/shared/dist/contracts/teammates.js +115 -0
- package/node_modules/@oxygen/shared/dist/cost-estimate-view.js +21 -3
- package/node_modules/@oxygen/shared/dist/cost-estimate.d.ts +31 -1
- package/node_modules/@oxygen/shared/dist/cost-estimate.js +56 -15
- package/node_modules/@oxygen/shared/dist/diagnostic-credentials.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/diagnostic-credentials.js +2 -0
- package/node_modules/@oxygen/shared/dist/diagnostic-lock.js +71 -44
- package/node_modules/@oxygen/shared/dist/diagnostic-spool.d.ts +18 -0
- package/node_modules/@oxygen/shared/dist/diagnostic-spool.js +348 -0
- package/node_modules/@oxygen/shared/dist/diagnostic-storage-failure.d.ts +10 -0
- package/node_modules/@oxygen/shared/dist/diagnostic-storage-failure.js +42 -0
- package/node_modules/@oxygen/shared/dist/diagnostic-storage.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/diagnostic-storage.js +59 -213
- package/node_modules/@oxygen/shared/dist/diagnostics.js +3 -2
- package/node_modules/@oxygen/shared/dist/email-dsn.d.ts +16 -5
- package/node_modules/@oxygen/shared/dist/email-dsn.js +97 -1
- package/node_modules/@oxygen/shared/dist/emailguard-placement-probe.js +5 -0
- package/node_modules/@oxygen/shared/dist/enrichment-intents.js +4 -5
- package/node_modules/@oxygen/shared/dist/file-import.d.ts +8 -0
- package/node_modules/@oxygen/shared/dist/file-import.js +21 -2
- package/node_modules/@oxygen/shared/dist/hosted-ai.d.ts +30 -0
- package/node_modules/@oxygen/shared/dist/hosted-ai.js +20 -0
- package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.d.ts +6 -2
- package/node_modules/@oxygen/shared/dist/knowledge-seed-content.d.ts +17 -7
- package/node_modules/@oxygen/shared/dist/knowledge-seed-content.js +81 -17
- package/node_modules/@oxygen/shared/dist/langfuse.js +8 -4
- package/node_modules/@oxygen/shared/dist/mailbox-egress-ownership.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/mailbox-import.d.ts +6 -3
- package/node_modules/@oxygen/shared/dist/mailbox-import.js +9 -9
- package/node_modules/@oxygen/shared/dist/mcp-server-info.d.ts +8 -0
- package/node_modules/@oxygen/shared/dist/mcp-server-info.js +10 -0
- package/node_modules/@oxygen/shared/dist/object-storage.d.ts +10 -0
- package/node_modules/@oxygen/shared/dist/object-storage.js +14 -0
- package/node_modules/@oxygen/shared/dist/plan-limits-view.d.ts +5 -0
- package/node_modules/@oxygen/shared/dist/plan-limits-view.js +17 -1
- package/node_modules/@oxygen/shared/dist/pricing-sheet.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/pricing-sheet.js +1 -1
- package/node_modules/@oxygen/shared/dist/provider-selection.d.ts +15 -0
- package/node_modules/@oxygen/shared/dist/provider-selection.js +3 -0
- package/node_modules/@oxygen/shared/dist/repricing.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/repricing.js +2 -2
- package/node_modules/@oxygen/shared/dist/schedule-label.d.ts +6 -0
- package/node_modules/@oxygen/shared/dist/schedule-label.js +12 -0
- package/node_modules/@oxygen/shared/dist/sequence-failures.d.ts +1 -0
- package/node_modules/@oxygen/shared/dist/sequence-failures.js +20 -6
- package/node_modules/@oxygen/shared/dist/teammate-groups.d.ts +55 -0
- package/node_modules/@oxygen/shared/dist/teammate-groups.js +74 -0
- package/node_modules/@oxygen/shared/dist/teammate-messages.d.ts +52 -0
- package/node_modules/@oxygen/shared/dist/teammate-messages.js +88 -0
- package/node_modules/@oxygen/shared/dist/teammate-notes.d.ts +58 -0
- package/node_modules/@oxygen/shared/dist/teammate-notes.js +73 -0
- package/node_modules/@oxygen/shared/dist/teammate-routines.d.ts +95 -0
- package/node_modules/@oxygen/shared/dist/teammate-routines.js +266 -0
- package/node_modules/@oxygen/shared/dist/teammate-tasks.d.ts +56 -0
- package/node_modules/@oxygen/shared/dist/teammate-tasks.js +114 -0
- package/node_modules/@oxygen/shared/dist/teammates.d.ts +78 -0
- package/node_modules/@oxygen/shared/dist/teammates.js +140 -0
- package/node_modules/@oxygen/shared/dist/user-capability-routing.js +120 -0
- package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
- package/node_modules/@oxygen/shared/dist/visual-render.d.ts +9 -1
- package/node_modules/@oxygen/shared/dist/visual-render.js +12 -5
- package/node_modules/@oxygen/shared/dist/warmup-metrics.d.ts +5 -0
- package/node_modules/@oxygen/shared/dist/warmup-metrics.js +44 -0
- package/package.json +1 -1
|
@@ -0,0 +1,2322 @@
|
|
|
1
|
+
import { asPayloadRecord, emitCliFailure, emitSuccess, writeBillingNotices, writeCreditsReceipt, writeJson } from "../cli-output.js";
|
|
2
|
+
import { parseJsonObject, readNonNegativeInt, readPositiveInt } from "../cli-values.js";
|
|
3
|
+
import { ansi, collectRepeatable, handleReadActionWithLens, parseJsonValue, readCreditCeilingOrZero, readCsvOption, readJsonFileValue, readPositiveNumber, readPublishingPostText, renderTextTable, splitCommaList } from "../command-support.js";
|
|
4
|
+
import { addContractFlags, readContractFlags } from "../contract-flags.js";
|
|
5
|
+
import { requestOxygen } from "../http-client.js";
|
|
6
|
+
import { parseWorkingDaysOption } from "../sender-limits-flags.js";
|
|
7
|
+
import { isRecord, readOption } from "../util.js";
|
|
8
|
+
import { OxygenError, success } from "@oxygen/shared/cli-result";
|
|
9
|
+
import { COMPANY_REPLY_PAUSE_DEFAULT_STATUSES, COMPANY_REPLY_PAUSE_LEGACY_STATUSES, COMPANY_REPLY_PAUSE_STATUS_KEY_PATTERN } from "@oxygen/shared/company-reply-pause";
|
|
10
|
+
import { SEQUENCE_ANALYTICS_READ_CONTRACT } from "@oxygen/shared/contracts/sequence-analytics-read";
|
|
11
|
+
import { SEQUENCE_LIST_PROJECTION_CONTRACT } from "@oxygen/shared/contracts/sequence-list";
|
|
12
|
+
import { SEQUENCE_LIFECYCLES, sequenceDisplayStatus } from "@oxygen/shared/sequence-lifecycle";
|
|
13
|
+
import { Command, Option } from "commander";
|
|
14
|
+
import { readFileSync } from "node:fs";
|
|
15
|
+
import { resolve } from "node:path";
|
|
16
|
+
import { stdout as output } from "node:process";
|
|
17
|
+
// `--suppress-list <comma ids or @file>`: a comma-separated list of lead provider
|
|
18
|
+
// ids, or `@<path>` to a file of ids (comma/newline/whitespace separated). Returns
|
|
19
|
+
// the trimmed, de-duplicated, blank-free ids; an absent value yields an empty list.
|
|
20
|
+
function parseSuppressListOption(value) {
|
|
21
|
+
const raw = readOption(value);
|
|
22
|
+
if (!raw)
|
|
23
|
+
return [];
|
|
24
|
+
const text = raw.startsWith("@") ? readFileSync(resolve(raw.slice(1)), "utf8") : raw;
|
|
25
|
+
return [
|
|
26
|
+
...new Set(text
|
|
27
|
+
.split(/[\s,]+/)
|
|
28
|
+
.map((id) => id.trim())
|
|
29
|
+
.filter((id) => id.length > 0)),
|
|
30
|
+
];
|
|
31
|
+
}
|
|
32
|
+
function buildSequenceDraftBody(options) {
|
|
33
|
+
const goal = readOption(options.goal);
|
|
34
|
+
if (!goal)
|
|
35
|
+
throw new Error("--goal is required.");
|
|
36
|
+
const name = readOption(options.name);
|
|
37
|
+
const slug = readOption(options.slug);
|
|
38
|
+
if (options.create && (!name || !slug)) {
|
|
39
|
+
throw new Error("--create requires --name and --slug.");
|
|
40
|
+
}
|
|
41
|
+
const audience = readOption(options.audience);
|
|
42
|
+
const table = readOption(options.table);
|
|
43
|
+
const steps = readPositiveInt(options.steps);
|
|
44
|
+
const variants = readPositiveInt(options.variants);
|
|
45
|
+
return {
|
|
46
|
+
goal,
|
|
47
|
+
max_credits: readPositiveNumber(options.maxCredits),
|
|
48
|
+
...(audience ? { audience } : {}),
|
|
49
|
+
...(table ? { source_table: table } : {}),
|
|
50
|
+
...(steps !== undefined ? { steps } : {}),
|
|
51
|
+
...(variants !== undefined ? { variants } : {}),
|
|
52
|
+
...(options.create ? { create: true } : {}),
|
|
53
|
+
...(name ? { name } : {}),
|
|
54
|
+
...(slug ? { slug } : {}),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
// Human-legible preview for `sequences draft` (stdout stays the machine JSON, like
|
|
58
|
+
// every other command): the drafted emails with their subjects + wait gaps, the
|
|
59
|
+
// Knowledge Graph pages that grounded the copy, and the next step. Written to
|
|
60
|
+
// stderr (the same lane as the credits receipt) so scripts still parse stdout.
|
|
61
|
+
function writeSequenceDraftPreview(data) {
|
|
62
|
+
if (!data || typeof data !== "object" || Array.isArray(data))
|
|
63
|
+
return;
|
|
64
|
+
const record = data;
|
|
65
|
+
const definition = record.definition;
|
|
66
|
+
const steps = Array.isArray(record.steps)
|
|
67
|
+
? record.steps
|
|
68
|
+
: Array.isArray(definition?.steps)
|
|
69
|
+
? definition.steps
|
|
70
|
+
: [];
|
|
71
|
+
const sequence = record.sequence;
|
|
72
|
+
const lines = [];
|
|
73
|
+
if (record.created && sequence) {
|
|
74
|
+
lines.push(`Saved draft sequence "${String(sequence.name ?? "")}" (${String(sequence.slug ?? "")}).`);
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
lines.push("Drafted email sequence (preview — not saved):");
|
|
78
|
+
}
|
|
79
|
+
let emailNumber = 0;
|
|
80
|
+
// The subject the next threaded email continues under. A drafted follow-up
|
|
81
|
+
// deliberately has no subject of its own — that is what keeps it in the first
|
|
82
|
+
// email's thread — so show what it actually goes out under rather than the
|
|
83
|
+
// "(no subject)" that reads like a drafting failure.
|
|
84
|
+
let threadSubject = null;
|
|
85
|
+
for (const step of steps) {
|
|
86
|
+
if (!step || typeof step !== "object" || Array.isArray(step))
|
|
87
|
+
continue;
|
|
88
|
+
const entry = step;
|
|
89
|
+
if (entry.kind === "email_send") {
|
|
90
|
+
emailNumber += 1;
|
|
91
|
+
const own = typeof entry.subject_template === "string" && entry.subject_template.trim()
|
|
92
|
+
? entry.subject_template.trim()
|
|
93
|
+
: null;
|
|
94
|
+
if (own)
|
|
95
|
+
threadSubject = own;
|
|
96
|
+
const subject = own ?? (threadSubject ? `Re: ${threadSubject} (same thread)` : "(no subject)");
|
|
97
|
+
const variantCount = Array.isArray(entry.variants) ? entry.variants.length : 0;
|
|
98
|
+
lines.push(` Email ${emailNumber}: ${subject}${variantCount > 0 ? ` (+${variantCount} A/B variant${variantCount === 1 ? "" : "s"})` : ""}`);
|
|
99
|
+
}
|
|
100
|
+
else if (entry.kind === "wait") {
|
|
101
|
+
const days = typeof entry.days === "number" ? entry.days : 0;
|
|
102
|
+
lines.push(` wait ${days} day${days === 1 ? "" : "s"}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const provenance = record.provenance;
|
|
106
|
+
const pages = Array.isArray(provenance?.pages) ? provenance.pages : [];
|
|
107
|
+
if (pages.length > 0) {
|
|
108
|
+
const names = pages
|
|
109
|
+
.map((page) => (typeof page.title === "string" && page.title.trim() ? page.title.trim() : typeof page.slug === "string" ? page.slug : ""))
|
|
110
|
+
.filter((name) => name.length > 0)
|
|
111
|
+
.slice(0, 5);
|
|
112
|
+
lines.push(` Grounded on ${pages.length} Knowledge Graph page${pages.length === 1 ? "" : "s"}${names.length > 0 ? `: ${names.join(", ")}` : ""}.`);
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
lines.push(" Grounded on the workspace voice (no Knowledge Graph pages retrieved).");
|
|
116
|
+
}
|
|
117
|
+
if (!record.created) {
|
|
118
|
+
lines.push(" Re-run with --create --name <name> --slug <slug> to save it as a draft sequence.");
|
|
119
|
+
}
|
|
120
|
+
process.stderr.write(`${lines.join("\n")}\n`);
|
|
121
|
+
}
|
|
122
|
+
// `sequences duplicate` is deliberately CLIENT-SIDE composition (get + create):
|
|
123
|
+
// it adds a real workflow convenience (Instantly/Lemlist/HeyReach all have
|
|
124
|
+
// campaign duplication) without growing the API/MCP surface — an MCP agent
|
|
125
|
+
// composes the same two calls, and the tools/list payload is at its size
|
|
126
|
+
// budget. The Instantly email BINDING is never copied: two campaigns must not
|
|
127
|
+
// share one provider campaign id; the create-side validation tells the user
|
|
128
|
+
// exactly that if the copied steps require a binding.
|
|
129
|
+
async function handleSequenceDuplicateAction(sequence, options) {
|
|
130
|
+
try {
|
|
131
|
+
const name = readOption(options.name);
|
|
132
|
+
const slug = readOption(options.slug);
|
|
133
|
+
const created = await requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/duplicate`, {
|
|
134
|
+
method: "POST",
|
|
135
|
+
body: {
|
|
136
|
+
...(name ? { name } : {}),
|
|
137
|
+
...(slug ? { slug } : {}),
|
|
138
|
+
...(options.senders === false ? { include_senders: false } : {}),
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
if (options.json) {
|
|
142
|
+
writeJson(success("sequences duplicate", created));
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const link = created.deepLink ?? created.web_url;
|
|
146
|
+
const notCopied = created.not_copied?.length ? ` Not copied: ${created.not_copied.join("; ")}.` : "";
|
|
147
|
+
process.stdout.write(`Duplicated '${sequence}' as draft '${created.sequence?.slug ?? slug ?? "copy"}'.${notCopied}${link ? ` ${link}` : ""}\n`);
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
emitCliFailure("sequences duplicate", error);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
async function handleSequenceRenderAction(sequence, options) {
|
|
154
|
+
try {
|
|
155
|
+
const params = new URLSearchParams();
|
|
156
|
+
const step = readOption(options.step);
|
|
157
|
+
if (step)
|
|
158
|
+
params.set("step", step);
|
|
159
|
+
const variant = readOption(options.variant);
|
|
160
|
+
if (variant)
|
|
161
|
+
params.set("variant", variant);
|
|
162
|
+
const rows = readCsvOption(options.row);
|
|
163
|
+
if (rows.length > 0)
|
|
164
|
+
params.set("row", rows.join(","));
|
|
165
|
+
const sender = readOption(options.sender);
|
|
166
|
+
if (sender)
|
|
167
|
+
params.set("sender", sender);
|
|
168
|
+
const limit = readPositiveInt(options.limit);
|
|
169
|
+
if (limit !== undefined)
|
|
170
|
+
params.set("limit", String(limit));
|
|
171
|
+
const query = params.toString();
|
|
172
|
+
const data = await requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/render${query ? `?${query}` : ""}`);
|
|
173
|
+
if (options.json) {
|
|
174
|
+
writeJson(success("sequences render", data));
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
process.stdout.write(formatSequenceRender(data));
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
emitCliFailure("sequences render", error);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
function formatSequenceRender(data) {
|
|
184
|
+
const lines = [];
|
|
185
|
+
for (const step of data.steps ?? []) {
|
|
186
|
+
const signer = step.sender ? ` · from ${step.sender.name}` : "";
|
|
187
|
+
lines.push(`Step ${step.step_index + 1} ${step.step_id} (${step.kind}${step.threaded ? ", continues the thread" : ""})${signer}`);
|
|
188
|
+
if (step.note)
|
|
189
|
+
lines.push(` ${step.note}`);
|
|
190
|
+
for (const variant of step.variants) {
|
|
191
|
+
lines.push(` Variant ${variant.variant_id}${variant.status === "paused" ? " (paused)" : ""}`);
|
|
192
|
+
for (const render of variant.renders) {
|
|
193
|
+
lines.push(` ${render.lead}`);
|
|
194
|
+
for (const field of render.fields) {
|
|
195
|
+
const limit = field.character_limit !== null && field.character_count > field.character_limit
|
|
196
|
+
? ` [${field.character_count}/${field.character_limit} characters]`
|
|
197
|
+
: "";
|
|
198
|
+
const missing = field.missing_variables.length > 0
|
|
199
|
+
? ` [missing ${field.missing_variables.map((key) => `{{${key}}}`).join(", ")}]`
|
|
200
|
+
: "";
|
|
201
|
+
const text = field.text.replace(/\n/g, "\n ") || "(empty)";
|
|
202
|
+
lines.push(` ${field.key}: ${text}${limit}${missing}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (lines.length === 0)
|
|
208
|
+
lines.push("No step has copy to render.");
|
|
209
|
+
if (data.note)
|
|
210
|
+
lines.push("", data.note);
|
|
211
|
+
const link = data.deepLink ?? data.web_url;
|
|
212
|
+
if (link)
|
|
213
|
+
lines.push(link);
|
|
214
|
+
return `${lines.join("\n")}\n`;
|
|
215
|
+
}
|
|
216
|
+
async function handleSequenceSignalAction(sequence, options) {
|
|
217
|
+
try {
|
|
218
|
+
const signal = readOption(options.signal);
|
|
219
|
+
if (!signal)
|
|
220
|
+
throw new Error("--signal is required.");
|
|
221
|
+
const enrollmentId = readOption(options.enrollment);
|
|
222
|
+
const leadProviderId = readOption(options.lead);
|
|
223
|
+
if (!enrollmentId && !leadProviderId) {
|
|
224
|
+
throw new Error("Provide a target enrollment with --enrollment <id> and/or --lead <provider_id>.");
|
|
225
|
+
}
|
|
226
|
+
const data = await requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/signal`, {
|
|
227
|
+
method: "POST",
|
|
228
|
+
body: {
|
|
229
|
+
signal,
|
|
230
|
+
...(enrollmentId ? { enrollment_id: enrollmentId } : {}),
|
|
231
|
+
...(leadProviderId ? { lead_provider_id: leadProviderId } : {}),
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
if (options.json) {
|
|
235
|
+
writeJson(success("sequences signal", data));
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
const updated = typeof data.enrollments_updated === "number" ? data.enrollments_updated : 0;
|
|
239
|
+
const recordedSignal = data.signal ?? signal;
|
|
240
|
+
const link = data.deepLink ?? data.web_url;
|
|
241
|
+
process.stdout.write(`Recorded ${recordedSignal} on ${updated} enrollment(s).${link ? ` ${link}` : ""}\n`);
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
emitCliFailure("sequences signal", error);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
// `sequences events` reads the per-sequence activity feed (Instantly "Sending
|
|
248
|
+
// Activities" parity). Read-only: it forwards the filters as query params and
|
|
249
|
+
// renders one line per event, with a keyset `next:` hint when a further page
|
|
250
|
+
// exists.
|
|
251
|
+
async function handleSequenceEventsAction(sequence, options) {
|
|
252
|
+
try {
|
|
253
|
+
const params = new URLSearchParams();
|
|
254
|
+
const enrollment = readOption(options.enrollment);
|
|
255
|
+
if (enrollment)
|
|
256
|
+
params.set("enrollment", enrollment);
|
|
257
|
+
const kinds = readCsvOption(options.kind);
|
|
258
|
+
if (kinds.length > 0)
|
|
259
|
+
params.set("kinds", kinds.join(","));
|
|
260
|
+
if (options.includeBots)
|
|
261
|
+
params.set("include_bots", "true");
|
|
262
|
+
const limit = readOption(options.limit);
|
|
263
|
+
if (limit)
|
|
264
|
+
params.set("limit", limit);
|
|
265
|
+
const before = readOption(options.before);
|
|
266
|
+
if (before)
|
|
267
|
+
params.set("before", before);
|
|
268
|
+
const suffix = params.toString();
|
|
269
|
+
const data = await requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/events${suffix ? `?${suffix}` : ""}`);
|
|
270
|
+
if (options.json) {
|
|
271
|
+
writeJson(success("sequences events", data));
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
process.stdout.write(formatSequenceEvents(data));
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
emitCliFailure("sequences events", error);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
const SEQUENCE_EVENT_KIND_WIDTH = 12;
|
|
281
|
+
// Render occurred_at as a compact minute-precision UTC stamp for the terminal
|
|
282
|
+
// (the full-precision ISO stays in the JSON envelope and the --before cursor).
|
|
283
|
+
function formatEventTime(iso) {
|
|
284
|
+
if (!iso)
|
|
285
|
+
return "—";
|
|
286
|
+
const date = new Date(iso);
|
|
287
|
+
if (Number.isNaN(date.getTime()))
|
|
288
|
+
return iso;
|
|
289
|
+
return `${date.toISOString().slice(0, 16)}Z`;
|
|
290
|
+
}
|
|
291
|
+
function formatSequenceEvents(data) {
|
|
292
|
+
const events = Array.isArray(data.events) ? data.events : [];
|
|
293
|
+
if (events.length === 0)
|
|
294
|
+
return "No events yet.\n";
|
|
295
|
+
const lines = events.map((event) => {
|
|
296
|
+
const time = formatEventTime(event.occurredAt);
|
|
297
|
+
const kind = (event.kind ?? "—").padEnd(SEQUENCE_EVENT_KIND_WIDTH);
|
|
298
|
+
const segments = [];
|
|
299
|
+
if (event.stepIndex != null) {
|
|
300
|
+
segments.push(`step ${event.stepIndex}${event.variantId ? ` (${event.variantId})` : ""}`);
|
|
301
|
+
}
|
|
302
|
+
if (event.actionKind)
|
|
303
|
+
segments.push(event.actionKind);
|
|
304
|
+
if (event.mailboxEmail)
|
|
305
|
+
segments.push(`via ${event.mailboxEmail}`);
|
|
306
|
+
else if (event.mailboxId)
|
|
307
|
+
segments.push(`via mailbox ${event.mailboxId}`);
|
|
308
|
+
if (event.url)
|
|
309
|
+
segments.push(event.url);
|
|
310
|
+
if (event.detail)
|
|
311
|
+
segments.push(`— ${event.detail}`);
|
|
312
|
+
const tail = segments.join(" ");
|
|
313
|
+
return [time, kind, tail].filter((part) => part.trim().length > 0).join(" ").replace(/\s+$/, "");
|
|
314
|
+
});
|
|
315
|
+
if (data.next_before)
|
|
316
|
+
lines.push(`next: --before ${data.next_before}`);
|
|
317
|
+
return `${lines.join("\n")}\n`;
|
|
318
|
+
}
|
|
319
|
+
async function handleSequenceVariantsAction(sequence, options) {
|
|
320
|
+
try {
|
|
321
|
+
const path = `/api/cli/sequences/${encodeURIComponent(sequence)}/variants`;
|
|
322
|
+
// One change per call: extra flags used to be dropped without a word, and a
|
|
323
|
+
// lone --step read the whole scoreboard.
|
|
324
|
+
const actions = [
|
|
325
|
+
options.autoOptimize ? "--auto-optimize" : null,
|
|
326
|
+
options.pause !== undefined ? "--pause" : null,
|
|
327
|
+
options.activate !== undefined ? "--activate" : null,
|
|
328
|
+
options.reset !== undefined ? "--reset" : null,
|
|
329
|
+
].filter((flag) => flag !== null);
|
|
330
|
+
if (actions.length > 1) {
|
|
331
|
+
throw new OxygenError("invalid_request", `Pass one of ${actions.join(", ")} per call.`, { details: { flags: actions }, exitCode: 2 });
|
|
332
|
+
}
|
|
333
|
+
if (options.step && (actions.length === 0 || options.autoOptimize)) {
|
|
334
|
+
throw new OxygenError("invalid_request", options.autoOptimize
|
|
335
|
+
? "--auto-optimize runs on the whole sequence; drop --step."
|
|
336
|
+
: "--step scopes --pause, --activate or --reset; add one of them.", { details: { step: options.step }, exitCode: 2 });
|
|
337
|
+
}
|
|
338
|
+
let body = null;
|
|
339
|
+
if (options.autoOptimize)
|
|
340
|
+
body = { action: "auto_optimize" };
|
|
341
|
+
else if (options.pause !== undefined)
|
|
342
|
+
body = { action: "pause", step_id: options.step, variant: options.pause };
|
|
343
|
+
else if (options.activate !== undefined)
|
|
344
|
+
body = { action: "activate", step_id: options.step, variant: options.activate };
|
|
345
|
+
else if (options.reset !== undefined)
|
|
346
|
+
body = {
|
|
347
|
+
action: "reset",
|
|
348
|
+
...(options.step ? { step_id: options.step } : {}),
|
|
349
|
+
...(typeof options.reset === "string" ? { variant: options.reset } : {}),
|
|
350
|
+
};
|
|
351
|
+
const data = body
|
|
352
|
+
? await requestOxygen(path, { method: "POST", body })
|
|
353
|
+
: await requestOxygen(path);
|
|
354
|
+
if (options.json) {
|
|
355
|
+
writeJson(success("sequences variants", data));
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
if (body) {
|
|
359
|
+
const link = data.deepLink ?? data.web_url;
|
|
360
|
+
process.stdout.write(`${data.note ?? "Done."}${link ? ` ${link}` : ""}\n`);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
process.stdout.write(formatSequenceVariants(data));
|
|
364
|
+
}
|
|
365
|
+
catch (error) {
|
|
366
|
+
emitCliFailure("sequences variants", error);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
function formatVariantCell(value) {
|
|
370
|
+
if (value === null || value === undefined)
|
|
371
|
+
return "—";
|
|
372
|
+
return String(value);
|
|
373
|
+
}
|
|
374
|
+
function formatRatePercent(value) {
|
|
375
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
376
|
+
return "—";
|
|
377
|
+
// Rates arrive as a 0–1 fraction; render as a one-decimal percentage.
|
|
378
|
+
return `${(value * 100).toFixed(1)}%`;
|
|
379
|
+
}
|
|
380
|
+
function formatReplyTypes(value) {
|
|
381
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
382
|
+
return "—";
|
|
383
|
+
const entries = Object.entries(value)
|
|
384
|
+
.map(([key, count]) => [key, Number(count)])
|
|
385
|
+
.filter(([, count]) => Number.isFinite(count) && count > 0)
|
|
386
|
+
.sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]));
|
|
387
|
+
return entries.length > 0 ? entries.map(([key, count]) => `${key}:${count}`).join(", ") : "—";
|
|
388
|
+
}
|
|
389
|
+
// Sequence state is derived only by the shared API. These helpers render those
|
|
390
|
+
// facts; they never infer flowing/blocked/live client-side. The one label drawn
|
|
391
|
+
// from them, completed, is the server's `lifecycle` (the shared helper covers a
|
|
392
|
+
// server that predates it). JSON remains the untouched API data inside the
|
|
393
|
+
// standard CLI envelope.
|
|
394
|
+
const SEQUENCE_OPERATIONAL_STATE_ORDER = ["empty", "drained", "never_started", "blocked", "degraded", "flowing", "paced", "paused"];
|
|
395
|
+
/**
|
|
396
|
+
* A state-changing sequencer command that prints a readable block instead of the
|
|
397
|
+
* raw envelope. Same shape as `handleReadActionWithLens`, plus the billing
|
|
398
|
+
* notices every write path owes the operator.
|
|
399
|
+
*/
|
|
400
|
+
async function handleSequenceWriteAction(command, options, action, render) {
|
|
401
|
+
try {
|
|
402
|
+
const data = await action();
|
|
403
|
+
writeBillingNotices(command, data);
|
|
404
|
+
if (options.json) {
|
|
405
|
+
writeJson(success(command, data));
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
process.stdout.write(isRecord(data) ? render(data) : `${JSON.stringify(data, null, 2)}\n`);
|
|
409
|
+
writeCreditsReceipt(data);
|
|
410
|
+
}
|
|
411
|
+
catch (error) {
|
|
412
|
+
emitCliFailure(command, error);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* The external-campaign push, in the shape an operator reads before approving:
|
|
417
|
+
* what campaign it resolved, what is mapped onto which column, how many rows
|
|
418
|
+
* each gate drops, and — when something is in the way — the one command that
|
|
419
|
+
* clears it. A dry run that is blocked still prints the row counts, because
|
|
420
|
+
* "you are not connected" and "your table has no emails" are different problems
|
|
421
|
+
* and the operator should learn both in one pass.
|
|
422
|
+
*/
|
|
423
|
+
function formatSequencePushExternal(data) {
|
|
424
|
+
const lines = [];
|
|
425
|
+
const provider = stringValue(data.provider) ?? "provider";
|
|
426
|
+
const campaign = recordValue(data.campaign);
|
|
427
|
+
const resolved = recordValue(campaign.resolved);
|
|
428
|
+
const table = recordValue(data.table);
|
|
429
|
+
const rows = recordValue(data.rows);
|
|
430
|
+
const live = stringValue(data.mode) === "live";
|
|
431
|
+
const campaignName = stringValue(resolved.name) ?? stringValue(campaign.name);
|
|
432
|
+
const campaignId = stringValue(resolved.id) ?? stringValue(campaign.id);
|
|
433
|
+
lines.push(campaignName
|
|
434
|
+
? `${live ? "Pushing to" : "Preview: push to"} ${provider} campaign "${campaignName}"${campaignId ? ` (${campaignId})` : ""}`
|
|
435
|
+
: `${live ? "Pushing to" : "Preview: push to"} ${provider} campaign "${stringValue(campaign.requested) ?? "?"}" — NOT RESOLVED (${stringValue(campaign.reason) ?? "unknown"})`);
|
|
436
|
+
lines.push(`Table: ${stringValue(table.display_name) ?? stringValue(table.slug) ?? stringValue(table.id) ?? "?"}`);
|
|
437
|
+
const connection = recordValue(data.connection);
|
|
438
|
+
if (stringValue(connection.status) === "missing") {
|
|
439
|
+
lines.push(`Connection: NOT CONNECTED — ${stringValue(connection.connect_command) ?? `oxygen integrations connect ${provider} --api-key <key>`}`);
|
|
440
|
+
}
|
|
441
|
+
else if (stringValue(connection.connection_id)) {
|
|
442
|
+
lines.push(`Connection: ${stringValue(connection.connection_id)}`);
|
|
443
|
+
}
|
|
444
|
+
const identityField = stringValue(rows.identity_field) ?? "email";
|
|
445
|
+
lines.push([
|
|
446
|
+
`Rows: ${numericValue(rows.scoped) ?? 0} scoped`,
|
|
447
|
+
`${numericValue(rows.with_identity) ?? 0} with ${identityField}`,
|
|
448
|
+
`${numericValue(rows.missing_identity) ?? 0} missing ${identityField}`,
|
|
449
|
+
`${numericValue(rows.duplicate_identity) ?? 0} duplicate`,
|
|
450
|
+
`${numericValue(rows.already_pushed) ?? 0} already pushed`,
|
|
451
|
+
`${numericValue(rows.to_push) ?? 0} to push`,
|
|
452
|
+
].join(" | "));
|
|
453
|
+
if (rows.has_more === true) {
|
|
454
|
+
lines.push("More rows match than one push covers — re-run after this one to continue.");
|
|
455
|
+
}
|
|
456
|
+
const mapping = recordValue(data.field_mapping);
|
|
457
|
+
const mapped = Object.entries(mapping).map(([field, column]) => `${field}=${String(column)}`);
|
|
458
|
+
lines.push(`Mapping: ${mapped.length > 0 ? mapped.join(", ") : "none detected"}`);
|
|
459
|
+
const column = recordValue(data.column);
|
|
460
|
+
if (stringValue(column.key)) {
|
|
461
|
+
lines.push(`Provenance column: ${stringValue(column.key)}${column.exists === true ? "" : " (will be created)"}`);
|
|
462
|
+
}
|
|
463
|
+
const samples = Array.isArray(data.sample_leads) ? data.sample_leads : [];
|
|
464
|
+
if (samples.length > 0) {
|
|
465
|
+
lines.push("Sample leads (exactly as they will be sent):");
|
|
466
|
+
for (const sample of samples)
|
|
467
|
+
lines.push(` ${JSON.stringify(sample)}`);
|
|
468
|
+
}
|
|
469
|
+
if (stringValue(data.dedupe))
|
|
470
|
+
lines.push(`Dedupe: ${stringValue(data.dedupe)}`);
|
|
471
|
+
const candidates = Array.isArray(campaign.candidates) ? campaign.candidates.filter(isRecord) : [];
|
|
472
|
+
if (candidates.length > 0) {
|
|
473
|
+
lines.push("Campaigns on this connection:");
|
|
474
|
+
for (const entry of candidates) {
|
|
475
|
+
lines.push(` ${stringValue(entry.name) ?? "?"} (${stringValue(entry.id) ?? "?"}${stringValue(entry.status) ? `, ${stringValue(entry.status)}` : ""})`);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
const blockedBy = Array.isArray(data.blocked_by) ? data.blocked_by.filter((entry) => typeof entry === "string") : [];
|
|
479
|
+
if (blockedBy.length > 0)
|
|
480
|
+
lines.push(`Blocked: ${blockedBy.join(", ")}`);
|
|
481
|
+
if (stringValue(data.note))
|
|
482
|
+
lines.push(stringValue(data.note));
|
|
483
|
+
const run = recordValue(data.run);
|
|
484
|
+
if (stringValue(run.id))
|
|
485
|
+
lines.push(`Run: ${stringValue(run.id)} (${stringValue(run.status) ?? "queued"})`);
|
|
486
|
+
if (stringValue(data.next_action))
|
|
487
|
+
lines.push(`Next: ${stringValue(data.next_action)}`);
|
|
488
|
+
const link = stringValue(data.web_url) ?? stringValue(data.deepLink);
|
|
489
|
+
if (link)
|
|
490
|
+
lines.push(link);
|
|
491
|
+
return `${lines.join("\n")}\n`;
|
|
492
|
+
}
|
|
493
|
+
function formatSequenceListHealth(data) {
|
|
494
|
+
const sequences = Array.isArray(data.sequences) ? data.sequences.filter(isRecord) : [];
|
|
495
|
+
const lines = [...formatSequenceFleetSummary(data.fleet), ""];
|
|
496
|
+
if (sequences.length === 0) {
|
|
497
|
+
lines.push("No sequences match these filters.");
|
|
498
|
+
}
|
|
499
|
+
else {
|
|
500
|
+
lines.push(...renderTextTable(["SEQUENCE", "PERSISTED", "OPERATIONAL", "OPEN WORK", "SENT / FAILED", "REASONS"], sequences.map((sequence) => {
|
|
501
|
+
// Warnings print below the table, so the row points at them: a reader
|
|
502
|
+
// skimming the rows missed the deleted-row warning (T-220 acceptance).
|
|
503
|
+
const row = sequenceHealthRow(sequence);
|
|
504
|
+
const warningCount = sequenceRowWarnings([sequence]).length;
|
|
505
|
+
if (warningCount > 0)
|
|
506
|
+
row[row.length - 1] = `${row[row.length - 1]}; ${warningCount} warning${warningCount === 1 ? "" : "s"} below`;
|
|
507
|
+
return row;
|
|
508
|
+
})));
|
|
509
|
+
const warnings = sequenceRowWarnings(sequences);
|
|
510
|
+
if (warnings.length > 0)
|
|
511
|
+
lines.push("", ...warnings);
|
|
512
|
+
lines.push("", ...sequenceInspectionLinks(sequences));
|
|
513
|
+
}
|
|
514
|
+
const listLink = stringValue(data.web_url) ?? stringValue(data.deepLink);
|
|
515
|
+
if (listLink)
|
|
516
|
+
lines.push(listLink);
|
|
517
|
+
return `${lines.join("\n")}\n`;
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Render one funnel metric.
|
|
521
|
+
*
|
|
522
|
+
* `numberText` defaults a missing value to "0", which is exactly the lie this
|
|
523
|
+
* whole contract exists to stop: a campaign whose workspace has no verified
|
|
524
|
+
* tracking domain would print "0" opens, indistinguishable from "nobody opened
|
|
525
|
+
* it". An unmeasured metric carries a coverage entry and prints what it is.
|
|
526
|
+
*/
|
|
527
|
+
function funnelMetricText(funnel, metric) {
|
|
528
|
+
const value = numericValue(funnel[metric]);
|
|
529
|
+
if (value !== null)
|
|
530
|
+
return String(value);
|
|
531
|
+
const coverage = recordValue(recordValue(funnel.coverage)[metric]);
|
|
532
|
+
const state = stringValue(coverage.state);
|
|
533
|
+
if (state === "not_supported")
|
|
534
|
+
return "n/a";
|
|
535
|
+
if (state)
|
|
536
|
+
return "not tracked";
|
|
537
|
+
return "—";
|
|
538
|
+
}
|
|
539
|
+
function funnelRateText(funnel, rate, metric) {
|
|
540
|
+
const value = numericValue(funnel[rate]);
|
|
541
|
+
if (value !== null)
|
|
542
|
+
return `${(value * 100).toFixed(1)}%`;
|
|
543
|
+
const coverage = recordValue(recordValue(funnel.coverage)[metric]);
|
|
544
|
+
const state = stringValue(coverage.state);
|
|
545
|
+
if (state === "not_supported")
|
|
546
|
+
return "n/a";
|
|
547
|
+
if (state)
|
|
548
|
+
return "not tracked";
|
|
549
|
+
return "—";
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* The per-channel funnel block, shared by `sequences stats` and `sequences
|
|
553
|
+
* analytics`.
|
|
554
|
+
*
|
|
555
|
+
* `sequences analytics` printed no funnel at all before this, only operational
|
|
556
|
+
* health. A blind user eval run against dev asked exactly the question this
|
|
557
|
+
* answers -- "which channel got the most replies, and what is my reply rate on
|
|
558
|
+
* each" -- and had to open every campaign's stats block one by one and hand-sum
|
|
559
|
+
* the channels in prose, because no command grouped them.
|
|
560
|
+
*/
|
|
561
|
+
function formatSequenceChannelFunnels(funnels, replies) {
|
|
562
|
+
const byChannel = recordValue(funnels);
|
|
563
|
+
const channels = ["linkedin", "email", "whatsapp"].filter((channel) => isRecord(byChannel[channel]));
|
|
564
|
+
if (channels.length === 0)
|
|
565
|
+
return [];
|
|
566
|
+
const rows = channels.map((channel) => {
|
|
567
|
+
const funnel = recordValue(byChannel[channel]);
|
|
568
|
+
const invites = recordValue(funnel.invites);
|
|
569
|
+
return [
|
|
570
|
+
channel,
|
|
571
|
+
numberText(funnel.recipients),
|
|
572
|
+
numberText(funnel.sends),
|
|
573
|
+
numericValue(invites.sent) === null ? "n/a" : `${numberText(invites.accepted)}/${numberText(invites.sent)}`,
|
|
574
|
+
funnelMetricText(funnel, "delivered"),
|
|
575
|
+
funnelMetricText(funnel, "opened"),
|
|
576
|
+
funnelMetricText(funnel, "replied"),
|
|
577
|
+
funnelRateText(funnel, "replyRate", "replied"),
|
|
578
|
+
funnelMetricText(funnel, "positiveReplied"),
|
|
579
|
+
];
|
|
580
|
+
});
|
|
581
|
+
const lines = ["", ...renderTextTable(["CHANNEL", "PEOPLE", "SENDS", "ACCEPTED", "DELIVERED", "OPENED", "REPLIED", "REPLY RATE", "POSITIVE"], rows)];
|
|
582
|
+
// Name the remedy once per distinct gap rather than once per row.
|
|
583
|
+
const remedies = new Map();
|
|
584
|
+
for (const channel of channels) {
|
|
585
|
+
const coverage = recordValue(recordValue(byChannel[channel]).coverage);
|
|
586
|
+
for (const entry of Object.values(coverage)) {
|
|
587
|
+
const record = recordValue(entry);
|
|
588
|
+
if (stringValue(record.state) === "not_supported")
|
|
589
|
+
continue;
|
|
590
|
+
const detail = stringValue(record.detail);
|
|
591
|
+
const command = stringValue(recordValue(record.remedy).command);
|
|
592
|
+
if (detail)
|
|
593
|
+
remedies.set(detail, command ?? "");
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
for (const [detail, command] of remedies) {
|
|
597
|
+
lines.push(command ? `Not tracked: ${detail} Turn it on with: ${command}` : `Not tracked: ${detail}`);
|
|
598
|
+
}
|
|
599
|
+
// A reply can exist on a channel the table has no row for: an invite-only
|
|
600
|
+
// campaign whose lead answered the invite note replied on LinkedIn without ever
|
|
601
|
+
// being MESSAGED, so it is not in that channel's rate denominator. Left
|
|
602
|
+
// unexplained, the headline ("linkedin 19") disagrees with the table (18) and
|
|
603
|
+
// the reader has to guess -- a blind user eval hit exactly this and recorded it
|
|
604
|
+
// as plausible but unverified.
|
|
605
|
+
const byChannelReplies = recordValue(recordValue(replies).byChannel);
|
|
606
|
+
for (const channel of ["linkedin", "email", "whatsapp"]) {
|
|
607
|
+
const counted = numericValue(byChannelReplies[channel]) ?? 0;
|
|
608
|
+
const inTable = numericValue(recordValue(byChannel[channel]).replied) ?? 0;
|
|
609
|
+
const outside = counted - inTable;
|
|
610
|
+
if (outside <= 0)
|
|
611
|
+
continue;
|
|
612
|
+
lines.push(`${channel}: ${outside} more ${outside === 1 ? "reply" : "replies"} came from a campaign with no message step `
|
|
613
|
+
+ "(an invite note, say), so they are counted in the total above but have no reply rate here.");
|
|
614
|
+
}
|
|
615
|
+
return lines;
|
|
616
|
+
}
|
|
617
|
+
/** `Replies: 4 (linkedin 2 · email 1 · unattributed 1)` -- the one-line answer. */
|
|
618
|
+
function formatSequenceReplyRollup(replies) {
|
|
619
|
+
const rollup = recordValue(replies);
|
|
620
|
+
const total = numericValue(rollup.total);
|
|
621
|
+
if (total === null)
|
|
622
|
+
return null;
|
|
623
|
+
const byChannel = recordValue(rollup.byChannel);
|
|
624
|
+
const parts = ["linkedin", "email", "whatsapp"]
|
|
625
|
+
.map((channel) => [channel, numericValue(byChannel[channel])])
|
|
626
|
+
.filter((entry) => entry[1] !== null && entry[1] > 0)
|
|
627
|
+
.map(([channel, value]) => `${channel} ${value}`);
|
|
628
|
+
const unattributed = numericValue(rollup.unattributed) ?? 0;
|
|
629
|
+
// An unattributed reply is a recording gap made visible, never folded into a
|
|
630
|
+
// channel and never dropped.
|
|
631
|
+
if (unattributed > 0)
|
|
632
|
+
parts.push(`unattributed ${unattributed}`);
|
|
633
|
+
return parts.length > 0 ? `Replies: ${total} (${parts.join(" · ")})` : `Replies: ${total}`;
|
|
634
|
+
}
|
|
635
|
+
function formatSequenceStatsHealth(data) {
|
|
636
|
+
const stats = recordValue(data.stats);
|
|
637
|
+
const operational = recordValue(stats.operational);
|
|
638
|
+
const budget = recordValue(data.daily_budget);
|
|
639
|
+
const lines = [
|
|
640
|
+
stringValue(data.name) ?? stringValue(data.sequence_id) ?? "Sequence",
|
|
641
|
+
`Persisted status: ${stringValue(data.status) ?? "unknown"}`,
|
|
642
|
+
`Operational state: ${operationalStateText(data, operational) ?? "unknown (legacy response)"}`,
|
|
643
|
+
`Open work: ${formatSequenceOpenWork(operational.openWork)}`,
|
|
644
|
+
`Reasons: ${formatSequenceReasons(operational)}`,
|
|
645
|
+
`${formatFailureWindow(operational.failureAnalytics)} outcomes: ${formatSequenceFailureTotals(operational.failureAnalytics)}`,
|
|
646
|
+
];
|
|
647
|
+
const sentToday = numericValue(budget.sends_used);
|
|
648
|
+
if (sentToday !== null) {
|
|
649
|
+
const reserved = numericValue(budget.sends_reserved);
|
|
650
|
+
const remaining = numericValue(budget.sends_remaining);
|
|
651
|
+
lines.push(`Today${stringValue(budget.day) ? ` (${budget.day} UTC)` : ""}: ${sentToday} sent`
|
|
652
|
+
+ (reserved === null ? "" : ` · ${reserved} reserved`)
|
|
653
|
+
+ ` · ${remaining === null ? "unlimited" : remaining} available`);
|
|
654
|
+
}
|
|
655
|
+
const failureMix = formatCountRecord(recordValue(recordValue(operational.failureAnalytics).byFailureClass));
|
|
656
|
+
if (failureMix !== "—")
|
|
657
|
+
lines.push(`Failure mix: ${failureMix}`);
|
|
658
|
+
lines.push(...formatActionKindBreakdown(operational.failureAnalytics));
|
|
659
|
+
// Every lead ever enrolled, removed and finished included, so name the split
|
|
660
|
+
// beside it: "Enrolled: 6" next to 4 in progress read as a miscount (T-220 acceptance).
|
|
661
|
+
const byStatus = formatCountRecord(recordValue(stats.byStatus));
|
|
662
|
+
lines.push("", `Enrolled (all time): ${numberText(stats.enrolled)}${byStatus === "—" ? "" : ` — ${byStatus}`}`);
|
|
663
|
+
const replyLine = formatSequenceReplyRollup(stats.replies);
|
|
664
|
+
if (replyLine)
|
|
665
|
+
lines.push(replyLine);
|
|
666
|
+
const funnelLines = formatSequenceChannelFunnels(stats.funnels, stats.replies);
|
|
667
|
+
if (funnelLines.length > 0) {
|
|
668
|
+
lines.push(...funnelLines);
|
|
669
|
+
}
|
|
670
|
+
else {
|
|
671
|
+
lines.push("", ...renderTextTable(["FUNNEL", "COUNT"], [
|
|
672
|
+
["Invites sent", numberText(stats.invitesSent ?? stats.connectionRequestsSent)],
|
|
673
|
+
["Connected", numberText(stats.connected ?? stats.connectionRequestsAccepted)],
|
|
674
|
+
["Messages sent", numberText(stats.messagesSent)],
|
|
675
|
+
["Replies", numberText(stats.totalReplies ?? stats.replied)],
|
|
676
|
+
]));
|
|
677
|
+
}
|
|
678
|
+
const link = stringValue(data.web_url) ?? stringValue(data.deepLink);
|
|
679
|
+
if (link)
|
|
680
|
+
lines.push("", link);
|
|
681
|
+
return `${lines.join("\n")}\n`;
|
|
682
|
+
}
|
|
683
|
+
function formatSequenceAnalyticsHealth(data) {
|
|
684
|
+
const analytics = recordValue(data.analytics);
|
|
685
|
+
const summary = recordValue(analytics.operationalSummary);
|
|
686
|
+
const failureAnalytics = summary.failureAnalytics;
|
|
687
|
+
const sequences = Array.isArray(analytics.sequences) ? analytics.sequences.filter(isRecord) : [];
|
|
688
|
+
const range = recordValue(analytics.range);
|
|
689
|
+
const from = stringValue(range.from);
|
|
690
|
+
const to = stringValue(range.to);
|
|
691
|
+
const rangeLabel = from || to ? `${from ?? "start"} → ${to ?? "now"}` : stringValue(range.preset) ?? "selected window";
|
|
692
|
+
const senderOptions = Array.isArray(analytics.senders) ? analytics.senders.filter(isRecord) : [];
|
|
693
|
+
const senderFilter = Array.isArray(analytics.senderFilter)
|
|
694
|
+
? analytics.senderFilter.filter((key) => typeof key === "string")
|
|
695
|
+
: null;
|
|
696
|
+
const senderName = (key) => stringValue(senderOptions.find((option) => option.key === key)?.name) ?? key;
|
|
697
|
+
const lines = [
|
|
698
|
+
`Sequencer analytics (${rangeLabel})`,
|
|
699
|
+
...(senderFilter ? [`Senders: ${senderFilter.map(senderName).join(", ")}`] : []),
|
|
700
|
+
...formatSequenceFleetSummary(analytics.fleet),
|
|
701
|
+
`Open work: ${formatSequenceOpenWork(summary.openWork)}`,
|
|
702
|
+
`Selected-window outcomes: ${formatSequenceFailureTotals(failureAnalytics)}`,
|
|
703
|
+
`Failure mix: ${formatCountRecord(recordValue(recordValue(failureAnalytics).byFailureClass))}`,
|
|
704
|
+
...formatActionKindBreakdown(failureAnalytics),
|
|
705
|
+
];
|
|
706
|
+
const totals = recordValue(analytics.totals);
|
|
707
|
+
const replyLine = formatSequenceReplyRollup(totals.replies);
|
|
708
|
+
if (replyLine)
|
|
709
|
+
lines.push(replyLine);
|
|
710
|
+
lines.push(...formatSequenceChannelFunnels(totals.funnels, totals.replies));
|
|
711
|
+
lines.push("");
|
|
712
|
+
if (sequences.length === 0) {
|
|
713
|
+
lines.push("No sequences in this analytics scope.");
|
|
714
|
+
}
|
|
715
|
+
else {
|
|
716
|
+
lines.push(...renderTextTable(["SEQUENCE", "PERSISTED", "OPERATIONAL", "OPEN WORK", "SENT / FAILED", "REASONS"], sequences.map((sequence) => sequenceHealthRow(sequence))));
|
|
717
|
+
lines.push("", ...sequenceInspectionLinks(sequences));
|
|
718
|
+
}
|
|
719
|
+
const link = stringValue(data.web_url) ?? stringValue(data.deepLink);
|
|
720
|
+
if (link)
|
|
721
|
+
lines.push(link);
|
|
722
|
+
const firstSender = stringValue(senderOptions[0]?.key);
|
|
723
|
+
if (!senderFilter && senderOptions.length > 1 && firstSender) {
|
|
724
|
+
lines.push(`Scope to one sender: add --sender ${firstSender} (${senderName(firstSender)}); all ${senderOptions.length} are under analytics.senders with --json.`);
|
|
725
|
+
}
|
|
726
|
+
return `${lines.join("\n")}\n`;
|
|
727
|
+
}
|
|
728
|
+
function sequenceHealthRow(sequence) {
|
|
729
|
+
const operational = recordValue(sequence.operational);
|
|
730
|
+
return [
|
|
731
|
+
stringValue(sequence.name) ?? stringValue(sequence.slug) ?? "—",
|
|
732
|
+
stringValue(sequence.status) ?? "unknown",
|
|
733
|
+
operationalStateText(sequence, operational) ?? "unknown",
|
|
734
|
+
formatSequenceOpenWork(operational.openWork),
|
|
735
|
+
formatSequenceFailureTotals(operational.failureAnalytics),
|
|
736
|
+
formatSequenceReasons(operational),
|
|
737
|
+
];
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* The operational state, naming the lifecycle a person reads when the two
|
|
741
|
+
* disagree. A campaign that contacted every lead keeps its persisted `active`,
|
|
742
|
+
* and a blind user eval (2026-09-29) had to infer it was done from `drained`
|
|
743
|
+
* plus zero open work. The web lists the same Sequence as Completed.
|
|
744
|
+
*/
|
|
745
|
+
function operationalStateText(sequence, operational) {
|
|
746
|
+
const state = stringValue(operational.operationalState);
|
|
747
|
+
if (!state)
|
|
748
|
+
return null;
|
|
749
|
+
// The server's own `lifecycle`; the shared helper covers a server that predates it.
|
|
750
|
+
const lifecycle = stringValue(sequence.lifecycle) ?? sequenceDisplayStatus(stringValue(sequence.status) ?? "", state);
|
|
751
|
+
return lifecycle === "completed" ? `${state} (completed)` : state;
|
|
752
|
+
}
|
|
753
|
+
/** A listed Sequence's launch warnings (e.g. two sending schedules), one line each. */
|
|
754
|
+
function sequenceRowWarnings(sequences) {
|
|
755
|
+
return sequences.flatMap((sequence) => {
|
|
756
|
+
const label = stringValue(sequence.name) ?? stringValue(sequence.slug) ?? "Sequence";
|
|
757
|
+
const warnings = Array.isArray(sequence.warnings) ? sequence.warnings.filter(isRecord) : [];
|
|
758
|
+
return warnings.flatMap((warning) => {
|
|
759
|
+
const message = stringValue(warning.message);
|
|
760
|
+
return message ? [`Warning (${label}): ${message}`] : [];
|
|
761
|
+
});
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
function sequenceInspectionLinks(sequences) {
|
|
765
|
+
return sequences.flatMap((sequence) => {
|
|
766
|
+
const exactLink = stringValue(sequence.web_url) ?? stringValue(sequence.deepLink);
|
|
767
|
+
return exactLink ? [`Inspect ${stringValue(sequence.name) ?? stringValue(sequence.slug) ?? "Sequence"}: ${exactLink}`] : [];
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
function formatSequenceFleetSummary(value) {
|
|
771
|
+
const fleet = recordValue(value);
|
|
772
|
+
if (Object.keys(fleet).length === 0)
|
|
773
|
+
return ["Fleet counts: unavailable (legacy API response)"];
|
|
774
|
+
const states = recordValue(fleet.byOperationalState);
|
|
775
|
+
// Only when there is something to act on: a workspace with no parked drafts
|
|
776
|
+
// should not read a line of zeroes every time it lists its sequences.
|
|
777
|
+
const neverStarted = typeof fleet.neverStarted === "number" ? fleet.neverStarted : 0;
|
|
778
|
+
const parkedLeads = typeof fleet.neverStartedEnrollments === "number" ? fleet.neverStartedEnrollments : 0;
|
|
779
|
+
const completed = recordValue(fleet.byLifecycle).completed;
|
|
780
|
+
const completedText = typeof completed === "number" ? ` · ${numberText(completed)} completed` : "";
|
|
781
|
+
return [
|
|
782
|
+
`Fleet: ${numberText(fleet.markedActive)} marked active · ${numberText(fleet.operationallyLive)} operationally live${completedText} · ${numberText(fleet.total)} total`,
|
|
783
|
+
`States: ${SEQUENCE_OPERATIONAL_STATE_ORDER.map((state) => `${state} ${numberText(states[state])}`).join(" · ")}`,
|
|
784
|
+
...(neverStarted > 0
|
|
785
|
+
? [`Never started: ${numberText(neverStarted)} draft${neverStarted === 1 ? "" : "s"} holding ${numberText(parkedLeads)} enrolled lead${parkedLeads === 1 ? "" : "s"} — these send nothing until launched. List them with \`oxygen sequences list --operational-state never_started\`, then check one with \`oxygen sequences get <sequence> --readiness\` (read-only).`]
|
|
786
|
+
: []),
|
|
787
|
+
];
|
|
788
|
+
}
|
|
789
|
+
/**
|
|
790
|
+
* Spelled out because a blind user eval (2026-09-29) read `enr 352` as 352
|
|
791
|
+
* enrolled when it meant 352 leads still in progress, and told the founder the
|
|
792
|
+
* wrong count. Only the first two counts have a unit: ready, in flight, paced
|
|
793
|
+
* and blocked add leads with no open action to open actions.
|
|
794
|
+
*/
|
|
795
|
+
function formatSequenceOpenWork(value) {
|
|
796
|
+
const work = recordValue(value);
|
|
797
|
+
if (Object.keys(work).length === 0)
|
|
798
|
+
return "unknown";
|
|
799
|
+
const counts = [
|
|
800
|
+
["leads in progress", work.enrollments],
|
|
801
|
+
["open actions", work.actions],
|
|
802
|
+
["ready now", work.actionableNow],
|
|
803
|
+
["in flight", work.inFlight],
|
|
804
|
+
["paced", work.paced],
|
|
805
|
+
["blocked", work.blocked],
|
|
806
|
+
["unresolved failures", work.unresolvedFailures],
|
|
807
|
+
];
|
|
808
|
+
if (counts.every(([, count]) => count === 0))
|
|
809
|
+
return "none";
|
|
810
|
+
return counts.map(([label, count]) => `${label} ${numberText(count)}`).join(" · ");
|
|
811
|
+
}
|
|
812
|
+
function formatSequenceFailureTotals(value) {
|
|
813
|
+
const analytics = recordValue(value);
|
|
814
|
+
if (Object.keys(analytics).length === 0)
|
|
815
|
+
return "unknown";
|
|
816
|
+
return `sent ${numberText(analytics.sent)} · failed ${numberText(analytics.terminalFailed)} · deferred ${numberText(analytics.deferrals)}`;
|
|
817
|
+
}
|
|
818
|
+
function formatFailureWindow(value) {
|
|
819
|
+
const windowValue = recordValue(recordValue(value).window);
|
|
820
|
+
const from = stringValue(windowValue.from);
|
|
821
|
+
const to = stringValue(windowValue.to);
|
|
822
|
+
return from || to ? `Selected-window ${from ?? "start"} → ${to ?? "now"}` : "Lifetime";
|
|
823
|
+
}
|
|
824
|
+
function formatSequenceReasons(operational) {
|
|
825
|
+
const reasonCodes = Array.isArray(operational.reasonCodes)
|
|
826
|
+
? operational.reasonCodes.filter((reason) => typeof reason === "string")
|
|
827
|
+
: [];
|
|
828
|
+
if (reasonCodes.length > 0)
|
|
829
|
+
return reasonCodes.join(", ");
|
|
830
|
+
return stringValue(operational.operationalState) ? "none reported" : "unknown";
|
|
831
|
+
}
|
|
832
|
+
function formatActionKindBreakdown(value) {
|
|
833
|
+
const byActionKind = recordValue(recordValue(value).byActionKind);
|
|
834
|
+
const entries = Object.entries(byActionKind).filter((entry) => isRecord(entry[1])).sort(([left], [right]) => left.localeCompare(right));
|
|
835
|
+
if (entries.length === 0)
|
|
836
|
+
return [];
|
|
837
|
+
return ["By action kind:", ...entries.map(([kind, bucket]) => {
|
|
838
|
+
const mix = formatCountRecord(recordValue(bucket.byFailureClass));
|
|
839
|
+
return ` ${kind}: ${formatSequenceFailureTotals(bucket)}${mix === "—" ? "" : ` · failures ${mix}`}`;
|
|
840
|
+
})];
|
|
841
|
+
}
|
|
842
|
+
function formatCountRecord(value) {
|
|
843
|
+
const entries = Object.entries(value)
|
|
844
|
+
.map(([key, count]) => [key, numericValue(count)])
|
|
845
|
+
.filter((entry) => entry[1] !== null && entry[1] > 0)
|
|
846
|
+
.sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]));
|
|
847
|
+
return entries.length > 0 ? entries.map(([key, count]) => `${key} ${count}`).join(" · ") : "—";
|
|
848
|
+
}
|
|
849
|
+
function recordValue(value) {
|
|
850
|
+
return isRecord(value) ? value : {};
|
|
851
|
+
}
|
|
852
|
+
function stringValue(value) {
|
|
853
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
854
|
+
}
|
|
855
|
+
function numericValue(value) {
|
|
856
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
857
|
+
}
|
|
858
|
+
function numberText(value) {
|
|
859
|
+
return String(numericValue(value) ?? 0);
|
|
860
|
+
}
|
|
861
|
+
function formatSequenceVariants(data) {
|
|
862
|
+
const styles = ansi(output.isTTY === true && !process.env.NO_COLOR);
|
|
863
|
+
const byVariant = Array.isArray(data.by_variant) ? data.by_variant : [];
|
|
864
|
+
const byMailbox = Array.isArray(data.by_mailbox) ? data.by_mailbox : [];
|
|
865
|
+
const byDomain = Array.isArray(data.by_domain) ? data.by_domain : [];
|
|
866
|
+
const unattributed = data.unattributed;
|
|
867
|
+
const lines = ["", styles.bold("By variant")];
|
|
868
|
+
if (byVariant.length === 0) {
|
|
869
|
+
lines.push(` ${styles.dim("No variant analytics yet.")}`);
|
|
870
|
+
}
|
|
871
|
+
else {
|
|
872
|
+
const headers = ["STEP", "VARIANT", "SENT", "REPLIED", "REPLY RATE", "BOUNCED", "CREDITS"];
|
|
873
|
+
const rows = byVariant.map((row) => [
|
|
874
|
+
formatVariantCell(row.step_index),
|
|
875
|
+
formatVariantCell(row.variant_id),
|
|
876
|
+
formatVariantCell(row.sent),
|
|
877
|
+
formatVariantCell(row.replied),
|
|
878
|
+
formatRatePercent(row.reply_rate),
|
|
879
|
+
formatVariantCell(row.bounced),
|
|
880
|
+
formatVariantCell(row.credits_used),
|
|
881
|
+
]);
|
|
882
|
+
lines.push(...renderTextTable(headers, rows));
|
|
883
|
+
}
|
|
884
|
+
lines.push("", styles.bold("By mailbox"));
|
|
885
|
+
if (byMailbox.length === 0) {
|
|
886
|
+
lines.push(` ${styles.dim("No mailbox analytics yet.")}`);
|
|
887
|
+
}
|
|
888
|
+
else {
|
|
889
|
+
const headers = ["MAILBOX", "CAMPAIGNS", "SENT", "REPLIED", "REPLY RATE", "BOUNCED", "BOUNCE RATE", "REPLY TYPES", "FAILED"];
|
|
890
|
+
const rows = byMailbox.map((row) => [
|
|
891
|
+
formatVariantCell(row.email_address ?? row.emailAddress),
|
|
892
|
+
formatVariantCell(row.campaign_count ?? row.campaignCount),
|
|
893
|
+
formatVariantCell(row.sent),
|
|
894
|
+
formatVariantCell(row.replied),
|
|
895
|
+
formatRatePercent(row.reply_rate ?? row.replyRate),
|
|
896
|
+
formatVariantCell(row.bounced),
|
|
897
|
+
formatRatePercent(row.bounce_rate ?? row.bounceRate),
|
|
898
|
+
formatReplyTypes(row.reply_types ?? row.replyTypes),
|
|
899
|
+
formatVariantCell(row.failed),
|
|
900
|
+
]);
|
|
901
|
+
lines.push(...renderTextTable(headers, rows));
|
|
902
|
+
}
|
|
903
|
+
lines.push("", styles.bold("By sending domain"));
|
|
904
|
+
if (byDomain.length === 0) {
|
|
905
|
+
lines.push(` ${styles.dim("No sending-domain analytics yet.")}`);
|
|
906
|
+
}
|
|
907
|
+
else {
|
|
908
|
+
const headers = ["DOMAIN", "MAILBOXES", "CAMPAIGNS", "SENT", "REPLIED", "REPLY RATE", "BOUNCED", "BOUNCE RATE", "REPLY TYPES"];
|
|
909
|
+
const rows = byDomain.map((row) => [
|
|
910
|
+
formatVariantCell(row.sending_domain ?? row.sendingDomain),
|
|
911
|
+
formatVariantCell(row.mailbox_count ?? row.mailboxCount),
|
|
912
|
+
formatVariantCell(row.campaign_count ?? row.campaignCount),
|
|
913
|
+
formatVariantCell(row.sent),
|
|
914
|
+
formatVariantCell(row.replied),
|
|
915
|
+
formatRatePercent(row.reply_rate ?? row.replyRate),
|
|
916
|
+
formatVariantCell(row.bounced),
|
|
917
|
+
formatRatePercent(row.bounce_rate ?? row.bounceRate),
|
|
918
|
+
formatReplyTypes(row.reply_types ?? row.replyTypes),
|
|
919
|
+
]);
|
|
920
|
+
lines.push(...renderTextTable(headers, rows));
|
|
921
|
+
}
|
|
922
|
+
const hasUnattributed = unattributed
|
|
923
|
+
&& [unattributed.sent, unattributed.replied, unattributed.bounced, unattributed.failed]
|
|
924
|
+
.some((value) => typeof value === "number" && value > 0);
|
|
925
|
+
if (hasUnattributed && unattributed) {
|
|
926
|
+
lines.push("", styles.bold("Unattributed sending identity"));
|
|
927
|
+
lines.push(...renderTextTable(["SENT", "REPLIED", "POSITIVE", "BOUNCED", "FAILED", "REPLY TYPES"], [[
|
|
928
|
+
formatVariantCell(unattributed.sent),
|
|
929
|
+
formatVariantCell(unattributed.replied),
|
|
930
|
+
formatVariantCell(unattributed.positive_replies ?? unattributed.positiveReplies),
|
|
931
|
+
formatVariantCell(unattributed.bounced),
|
|
932
|
+
formatVariantCell(unattributed.failed),
|
|
933
|
+
formatReplyTypes(unattributed.reply_types ?? unattributed.replyTypes),
|
|
934
|
+
]]));
|
|
935
|
+
lines.push(` ${styles.dim("Campaign attribution is preserved; mailbox/domain is left unassigned instead of guessed.")}`);
|
|
936
|
+
}
|
|
937
|
+
lines.push("");
|
|
938
|
+
return lines.join("\n");
|
|
939
|
+
}
|
|
940
|
+
// Assemble the optional campaign email binding from the --email-* flags. The
|
|
941
|
+
// content spec (--email-definition-file) is the author-provided email sequence
|
|
942
|
+
// that the API compiles to an Instantly campaign on start; provider/connection
|
|
943
|
+
// pick which Instantly account to bind. Returns undefined when no email flags
|
|
944
|
+
// are set (a LinkedIn-only campaign).
|
|
945
|
+
function readCampaignEmailBinding(options) {
|
|
946
|
+
const provider = readOption(options.emailProvider);
|
|
947
|
+
const connectionId = readOption(options.emailConnection);
|
|
948
|
+
const definitionPath = readOption(options.emailDefinitionFile);
|
|
949
|
+
if (!provider && !connectionId && !definitionPath)
|
|
950
|
+
return undefined;
|
|
951
|
+
const definition = definitionPath
|
|
952
|
+
? readJsonFileValue(resolve(definitionPath), "--email-definition-file")
|
|
953
|
+
: undefined;
|
|
954
|
+
return {
|
|
955
|
+
...(provider ? { provider } : {}),
|
|
956
|
+
...(connectionId ? { connection_id: connectionId } : {}),
|
|
957
|
+
...(definition !== undefined ? { definition } : {}),
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
// The four --linkedin-* flags write settings.schedule, the per-sequence window
|
|
961
|
+
// the LinkedIn/WhatsApp dispatcher enforces on top of each sender's working
|
|
962
|
+
// hours. The server stores the window whole, so the four flags go together.
|
|
963
|
+
function readLinkedinScheduleOptions(options) {
|
|
964
|
+
const timezone = readOption(options.linkedinTimezone);
|
|
965
|
+
const days = readOption(options.linkedinDays);
|
|
966
|
+
const start = readOption(options.linkedinHoursStart);
|
|
967
|
+
const end = readOption(options.linkedinHoursEnd);
|
|
968
|
+
if (!timezone && !days && !start && !end)
|
|
969
|
+
return undefined;
|
|
970
|
+
if (!timezone || !days || !start || !end) {
|
|
971
|
+
throw new OxygenError("invalid_linkedin_schedule", "Set the LinkedIn sending schedule with all four flags: --linkedin-timezone, --linkedin-days, --linkedin-hours-start and --linkedin-hours-end.", { exitCode: 1 });
|
|
972
|
+
}
|
|
973
|
+
return { timezone, days: parseWorkingDaysOption(days), start, end };
|
|
974
|
+
}
|
|
975
|
+
// Launch's one sending schedule and one unsubscribe switch. The server writes
|
|
976
|
+
// `sending_schedule` to the email window and the LinkedIn/WhatsApp window, and
|
|
977
|
+
// `include_unsubscribe` to the visible link and the one-click headers.
|
|
978
|
+
function readUnifiedSequenceBody(options) {
|
|
979
|
+
const timezone = readOption(options.scheduleTimezone);
|
|
980
|
+
const days = readOption(options.scheduleDays);
|
|
981
|
+
const start = readOption(options.scheduleHoursStart);
|
|
982
|
+
const end = readOption(options.scheduleHoursEnd);
|
|
983
|
+
const body = {};
|
|
984
|
+
if (timezone || days || start || end) {
|
|
985
|
+
if (!timezone || !days || !start || !end) {
|
|
986
|
+
throw new OxygenError("invalid_sending_schedule", "Set the sending schedule with all four flags: --schedule-timezone, --schedule-days, --schedule-hours-start and --schedule-hours-end.", { exitCode: 1 });
|
|
987
|
+
}
|
|
988
|
+
body.sending_schedule = { timezone, days: parseWorkingDaysOption(days), start, end };
|
|
989
|
+
}
|
|
990
|
+
if (options.includeUnsubscribe !== undefined)
|
|
991
|
+
body.include_unsubscribe = options.includeUnsubscribe;
|
|
992
|
+
return body;
|
|
993
|
+
}
|
|
994
|
+
function countCompanyIdentityOptions(options) {
|
|
995
|
+
return [options.domain, options.email, options.linkedinCompany, options.record]
|
|
996
|
+
.filter((value) => readOption(value) !== null)
|
|
997
|
+
.length;
|
|
998
|
+
}
|
|
999
|
+
// Exactly one company identity flag, mapped onto the route's body fields. The
|
|
1000
|
+
// server re-checks (and resolves record ids); refusing locally names the flags.
|
|
1001
|
+
function readCompanyIdentityOptions(options, verb) {
|
|
1002
|
+
const fields = [
|
|
1003
|
+
["domain", readOption(options.domain)],
|
|
1004
|
+
["email", readOption(options.email)],
|
|
1005
|
+
["linkedin_company_url", readOption(options.linkedinCompany)],
|
|
1006
|
+
["record_id", readOption(options.record)],
|
|
1007
|
+
];
|
|
1008
|
+
const provided = fields.filter((entry) => entry[1] !== null);
|
|
1009
|
+
if (provided.length !== 1) {
|
|
1010
|
+
const which = verb === "resume" ? "--hold, or exactly one of " : "exactly one of ";
|
|
1011
|
+
throw new OxygenError(provided.length === 0 ? "company_identity_required" : "company_identity_ambiguous", `Name the company with ${which}--domain, --email, --linkedin-company, or --record.`, { exitCode: 2 });
|
|
1012
|
+
}
|
|
1013
|
+
return Object.fromEntries(provided);
|
|
1014
|
+
}
|
|
1015
|
+
function readRepeatableIds(values) {
|
|
1016
|
+
return [...new Set((values ?? []).flatMap((value) => readCsvOption(value)))];
|
|
1017
|
+
}
|
|
1018
|
+
// `sequences contacts-remove` for a human reader: what the call selects, the
|
|
1019
|
+
// people it names (a --missing-source-rows preview exists so nobody approves a
|
|
1020
|
+
// removal by UUID), and the next step. stderr, like the other summaries.
|
|
1021
|
+
function writeContactsRemoveSummary(data) {
|
|
1022
|
+
const payload = asPayloadRecord(data);
|
|
1023
|
+
if (!payload)
|
|
1024
|
+
return;
|
|
1025
|
+
const count = (value) => (typeof value === "number" ? value : 0);
|
|
1026
|
+
const lines = [];
|
|
1027
|
+
const leads = asPayloadRecord(payload.leads);
|
|
1028
|
+
const approved = payload.approved === true;
|
|
1029
|
+
if (payload.mode === "missing_source_rows") {
|
|
1030
|
+
const total = count(leads?.total);
|
|
1031
|
+
// Counts recorded deletions only, so zero is never read as "none deleted".
|
|
1032
|
+
lines.push(`${total} enrolled lead${total === 1 ? " has" : "s have"} a recorded deletion from this campaign's source table.`);
|
|
1033
|
+
}
|
|
1034
|
+
lines.push(approved
|
|
1035
|
+
? `Removed ${count(payload.removed)} from the campaign.`
|
|
1036
|
+
: `Preview: ${count(payload.removable)} removable, ${count(payload.contacted)} already contacted, ${count(payload.skippedTerminal)} already finished.`);
|
|
1037
|
+
// What approving costs and touches, stated where the reader decides; with
|
|
1038
|
+
// nothing removable there is nothing to approve, so no line invites it.
|
|
1039
|
+
if (!approved && count(payload.removable) > 0 && typeof payload.credits_used === "number") {
|
|
1040
|
+
lines.push(`Cost: ${payload.credits_used} credits. Approving stops only these campaign enrollments: nothing is sent, and table rows are kept.`);
|
|
1041
|
+
}
|
|
1042
|
+
const items = Array.isArray(leads?.items) ? leads.items : [];
|
|
1043
|
+
for (const entry of items) {
|
|
1044
|
+
const lead = asPayloadRecord(entry);
|
|
1045
|
+
if (!lead)
|
|
1046
|
+
continue;
|
|
1047
|
+
const name = typeof lead.name === "string" && lead.name.trim() ? lead.name.trim() : "(unnamed lead)";
|
|
1048
|
+
// After approval the server reports each lead's new state; "stopped" is this removal.
|
|
1049
|
+
const state = approved
|
|
1050
|
+
? lead.status === "stopped" ? "removed" : `not removed (${String(lead.status ?? "unknown")})`
|
|
1051
|
+
: String(lead.status ?? "unknown");
|
|
1052
|
+
lines.push(` ${name} — ${state}${lead.contacted === true ? ", already contacted" : ""}`);
|
|
1053
|
+
}
|
|
1054
|
+
// One call covers at most per_call_limit leads (the oldest); the server's
|
|
1055
|
+
// next_step says to repeat for the rest, so count the unnamed ones in this call.
|
|
1056
|
+
const total = count(leads?.total);
|
|
1057
|
+
const covered = typeof payload.per_call_limit === "number" ? Math.min(total, payload.per_call_limit) : total;
|
|
1058
|
+
const unnamed = covered - items.length;
|
|
1059
|
+
if (unnamed > 0)
|
|
1060
|
+
lines.push(` …and ${unnamed} more${covered < total ? " in this call" : ""}`);
|
|
1061
|
+
if (typeof payload.next_step === "string")
|
|
1062
|
+
lines.push(payload.next_step);
|
|
1063
|
+
if (typeof payload.web_url === "string")
|
|
1064
|
+
lines.push(`Open: ${payload.web_url}`);
|
|
1065
|
+
process.stderr.write(`${lines.join("\n")}\n`);
|
|
1066
|
+
}
|
|
1067
|
+
// Human-mode summary of a company pause/resume/list on stderr (stdout keeps the
|
|
1068
|
+
// machine-readable envelope): what was held or resumed, per campaign, and where
|
|
1069
|
+
// to see it.
|
|
1070
|
+
function writeCompanyPauseSummary(data) {
|
|
1071
|
+
const payload = asPayloadRecord(data);
|
|
1072
|
+
if (!payload)
|
|
1073
|
+
return;
|
|
1074
|
+
const lines = [];
|
|
1075
|
+
if (typeof payload.message === "string")
|
|
1076
|
+
lines.push(payload.message);
|
|
1077
|
+
const affected = Array.isArray(payload.affected) ? payload.affected : [];
|
|
1078
|
+
for (const entry of affected) {
|
|
1079
|
+
const record = asPayloadRecord(entry);
|
|
1080
|
+
if (!record)
|
|
1081
|
+
continue;
|
|
1082
|
+
const count = typeof record.enrollments === "number" ? record.enrollments : 0;
|
|
1083
|
+
lines.push(` ${String(record.sequence_name ?? record.sequence_id)}: ${count} lead${count === 1 ? "" : "s"}`);
|
|
1084
|
+
}
|
|
1085
|
+
// A pause reaching a campaign whose email is sent by Instantly does not stop
|
|
1086
|
+
// those emails; the route says which campaigns, and a human reader must see it.
|
|
1087
|
+
const warnings = Array.isArray(payload.warnings) ? payload.warnings : [];
|
|
1088
|
+
for (const entry of warnings) {
|
|
1089
|
+
const warning = asPayloadRecord(entry);
|
|
1090
|
+
if (warning && typeof warning.message === "string")
|
|
1091
|
+
lines.push(`Warning: ${warning.message}`);
|
|
1092
|
+
}
|
|
1093
|
+
const holds = Array.isArray(payload.holds) && typeof payload.message !== "string" ? payload.holds : [];
|
|
1094
|
+
if (Array.isArray(payload.holds) && typeof payload.message !== "string" && holds.length === 0) {
|
|
1095
|
+
lines.push("No companies are paused.");
|
|
1096
|
+
}
|
|
1097
|
+
for (const entry of holds) {
|
|
1098
|
+
const hold = asPayloadRecord(entry);
|
|
1099
|
+
if (!hold)
|
|
1100
|
+
continue;
|
|
1101
|
+
const scope = typeof hold.sequence_name === "string" ? hold.sequence_name : hold.sequence_id ? String(hold.sequence_id) : "every campaign";
|
|
1102
|
+
const held = typeof hold.held_enrollments === "number" ? hold.held_enrollments : 0;
|
|
1103
|
+
lines.push(` ${String(hold.display_name)} — ${scope} — ${String(hold.state)} — ${held} lead${held === 1 ? "" : "s"} held (hold ${String(hold.id)})`);
|
|
1104
|
+
}
|
|
1105
|
+
if (typeof payload.next_step === "string")
|
|
1106
|
+
lines.push(payload.next_step);
|
|
1107
|
+
if (typeof payload.web_url === "string")
|
|
1108
|
+
lines.push(`Open: ${payload.web_url}`);
|
|
1109
|
+
if (lines.length > 0)
|
|
1110
|
+
process.stderr.write(`${lines.join("\n")}\n`);
|
|
1111
|
+
}
|
|
1112
|
+
// `--company-reply-pause` (and its deprecated alias `--stop-on-reply-scope`) as
|
|
1113
|
+
// the `settings.company_reply_pause` object the server stores. `default` names
|
|
1114
|
+
// the statuses a new campaign gets; the alias maps `company_domain` to exactly
|
|
1115
|
+
// what the legacy scope always stopped on and `lead` to off, so an old script
|
|
1116
|
+
// keeps its behavior while the server stores one key.
|
|
1117
|
+
function readCompanyReplyPauseOption(value, legacyScope) {
|
|
1118
|
+
const raw = readOption(value);
|
|
1119
|
+
const legacy = readOption(legacyScope);
|
|
1120
|
+
if (raw && legacy) {
|
|
1121
|
+
throw new OxygenError("invalid_company_reply_pause", "Pass --company-reply-pause only; --stop-on-reply-scope is its deprecated alias.", { exitCode: 2 });
|
|
1122
|
+
}
|
|
1123
|
+
if (legacy) {
|
|
1124
|
+
if (legacy === "company_domain")
|
|
1125
|
+
return { enabled: true, statuses: [...COMPANY_REPLY_PAUSE_LEGACY_STATUSES] };
|
|
1126
|
+
if (legacy === "lead")
|
|
1127
|
+
return { enabled: false, statuses: [] };
|
|
1128
|
+
throw new OxygenError("invalid_company_reply_pause", "--stop-on-reply-scope must be lead or company_domain (deprecated: use --company-reply-pause).", { details: { value: legacy }, exitCode: 2 });
|
|
1129
|
+
}
|
|
1130
|
+
if (!raw)
|
|
1131
|
+
return undefined;
|
|
1132
|
+
const normalized = raw.toLowerCase();
|
|
1133
|
+
if (normalized === "off")
|
|
1134
|
+
return { enabled: false, statuses: [] };
|
|
1135
|
+
if (normalized === "default" || normalized === "on") {
|
|
1136
|
+
return { enabled: true, statuses: [...COMPANY_REPLY_PAUSE_DEFAULT_STATUSES] };
|
|
1137
|
+
}
|
|
1138
|
+
const statuses = [...new Set(readCsvOption(normalized))];
|
|
1139
|
+
const invalid = statuses.filter((status) => !COMPANY_REPLY_PAUSE_STATUS_KEY_PATTERN.test(status));
|
|
1140
|
+
if (statuses.length === 0 || invalid.length > 0) {
|
|
1141
|
+
throw new OxygenError("invalid_company_reply_pause", "--company-reply-pause takes off, default, or comma-separated Inbox status keys such as interested,not_now.", { details: { invalid }, exitCode: 2 });
|
|
1142
|
+
}
|
|
1143
|
+
return { enabled: true, statuses };
|
|
1144
|
+
}
|
|
1145
|
+
// Folds the sequence-level email send controls (--max-emails-per-mailbox-per-day,
|
|
1146
|
+
// --send-window-file) into a partial `settings` object. Returns undefined when
|
|
1147
|
+
// neither flag is set so the field is omitted from the request body entirely.
|
|
1148
|
+
// The server shallow-merges this fragment over the sequence's current settings
|
|
1149
|
+
// (apps/web .../sequences/[sequenceId]/route.ts), so a throttle-only PATCH
|
|
1150
|
+
// preserves other settings the server already holds, e.g. dispatch_mode/schedule.
|
|
1151
|
+
/**
|
|
1152
|
+
* A sequence daily cap flag: a whole number (0 is a real cap) or `none`, which
|
|
1153
|
+
* sends null to remove the cap, as clearing the web's Sends per day field does.
|
|
1154
|
+
*/
|
|
1155
|
+
function readDailyCapOption(value, flag) {
|
|
1156
|
+
const trimmed = value?.trim();
|
|
1157
|
+
if (trimmed === undefined || trimmed === "")
|
|
1158
|
+
return undefined;
|
|
1159
|
+
if (trimmed.toLowerCase() === "none")
|
|
1160
|
+
return null;
|
|
1161
|
+
const parsed = Number(trimmed);
|
|
1162
|
+
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
|
1163
|
+
throw new OxygenError("invalid_number", `${flag} takes a whole number (0 or more) or none.`, {
|
|
1164
|
+
details: { flag, value },
|
|
1165
|
+
exitCode: 1,
|
|
1166
|
+
});
|
|
1167
|
+
}
|
|
1168
|
+
return parsed;
|
|
1169
|
+
}
|
|
1170
|
+
function readSequenceSettings(options) {
|
|
1171
|
+
const settings = {};
|
|
1172
|
+
// 0 is a real cap, as on the web's Sending limits (0-50).
|
|
1173
|
+
const maxPerMailbox = readNonNegativeInt(options.maxEmailsPerMailboxPerDay);
|
|
1174
|
+
if (maxPerMailbox !== undefined) {
|
|
1175
|
+
settings.max_emails_per_mailbox_per_day = maxPerMailbox;
|
|
1176
|
+
}
|
|
1177
|
+
const windowPath = readOption(options.sendWindowFile);
|
|
1178
|
+
if (windowPath) {
|
|
1179
|
+
settings.email_send_window = readJsonFileValue(resolve(windowPath), "--send-window-file");
|
|
1180
|
+
}
|
|
1181
|
+
const linkedinSchedule = readLinkedinScheduleOptions(options);
|
|
1182
|
+
if (linkedinSchedule) {
|
|
1183
|
+
settings.schedule = linkedinSchedule;
|
|
1184
|
+
}
|
|
1185
|
+
if (options.linkedinInviteSchedule !== undefined)
|
|
1186
|
+
settings.linkedin_invite_schedule = parseJsonValue(options.linkedinInviteSchedule, "--linkedin-invite-schedule");
|
|
1187
|
+
if (options.linkedinMessageSchedule !== undefined)
|
|
1188
|
+
settings.linkedin_message_schedule = parseJsonValue(options.linkedinMessageSchedule, "--linkedin-message-schedule");
|
|
1189
|
+
// Tri-state: `--no-whatsapp-cold-initiate` (update) turns the opt-in off.
|
|
1190
|
+
if (options.whatsappColdInitiate !== undefined) {
|
|
1191
|
+
settings.whatsapp_cold_initiate = options.whatsappColdInitiate;
|
|
1192
|
+
}
|
|
1193
|
+
const whatsappDuplicateScope = readOption(options.whatsappDuplicateScope);
|
|
1194
|
+
if (whatsappDuplicateScope)
|
|
1195
|
+
settings.whatsapp_duplicate_scope = whatsappDuplicateScope;
|
|
1196
|
+
// "" is sent as "": back to auto-detect, as the web's Lead identifiers save it.
|
|
1197
|
+
if (options.phoneColumnKey !== undefined)
|
|
1198
|
+
settings.phone_column_key = options.phoneColumnKey.trim();
|
|
1199
|
+
if (options.emailColumnKey !== undefined)
|
|
1200
|
+
settings.email_column_key = options.emailColumnKey.trim();
|
|
1201
|
+
// stop_on_bounce defaults ON server-side; commander's `--no-stop-on-bounce`
|
|
1202
|
+
// sets this to false, so only send the key when the user explicitly opts OUT.
|
|
1203
|
+
// Leaving it absent keeps the safe default and a settings PATCH clean.
|
|
1204
|
+
if (options.stopOnBounce === false) {
|
|
1205
|
+
settings.stop_on_bounce = false;
|
|
1206
|
+
}
|
|
1207
|
+
// exclude_contacted: sequence-level cross-campaign exclusion default. Tri-state
|
|
1208
|
+
// (--exclude-contacted / --no-exclude-contacted); undefined leaves it unset so a
|
|
1209
|
+
// settings PATCH stays clean and enroll keeps today's per-call-only behavior.
|
|
1210
|
+
if (options.excludeContacted !== undefined) {
|
|
1211
|
+
settings.exclude_contacted = options.excludeContacted;
|
|
1212
|
+
}
|
|
1213
|
+
// A visible footer mutates outbound copy, so it is opt-in and tri-state:
|
|
1214
|
+
// absent leaves the stored value alone; either explicit flag persists the
|
|
1215
|
+
// chosen boolean. Missing/false is interpreted as OFF by the dispatcher.
|
|
1216
|
+
if (options.autoOptimize !== undefined)
|
|
1217
|
+
settings.auto_optimize = options.autoOptimize;
|
|
1218
|
+
if (options.bounceProtection !== undefined)
|
|
1219
|
+
settings.bounce_protection = JSON.parse(options.bounceProtection);
|
|
1220
|
+
if (options.includeUnsubscribeHeaders !== undefined)
|
|
1221
|
+
settings.include_unsubscribe_headers = options.includeUnsubscribeHeaders;
|
|
1222
|
+
if (options.includeUnsubscribeLink !== undefined) {
|
|
1223
|
+
settings.include_unsubscribe_link = options.includeUnsubscribeLink;
|
|
1224
|
+
}
|
|
1225
|
+
const companyReplyPause = readCompanyReplyPauseOption(options.companyReplyPause, options.stopOnReplyScope);
|
|
1226
|
+
if (companyReplyPause)
|
|
1227
|
+
settings.company_reply_pause = companyReplyPause;
|
|
1228
|
+
const maxEmailsPerDay = readDailyCapOption(options.maxEmailsPerDay, "--max-emails-per-day");
|
|
1229
|
+
if (maxEmailsPerDay !== undefined)
|
|
1230
|
+
settings.max_emails_per_day = maxEmailsPerDay;
|
|
1231
|
+
const maxNewEnrollmentsPerDay = readDailyCapOption(options.maxNewEnrollmentsPerDay, "--max-new-enrollments-per-day");
|
|
1232
|
+
if (maxNewEnrollmentsPerDay !== undefined)
|
|
1233
|
+
settings.max_new_enrollments_per_day = maxNewEnrollmentsPerDay;
|
|
1234
|
+
const prioritization = readOption(options.sequencePrioritization);
|
|
1235
|
+
if (prioritization)
|
|
1236
|
+
settings.sequence_prioritization = prioritization;
|
|
1237
|
+
const espMatching = readOption(options.espMatching);
|
|
1238
|
+
if (espMatching)
|
|
1239
|
+
settings.esp_matching = espMatching;
|
|
1240
|
+
// The file is the whole policy: settings merge by top-level key, so a partial
|
|
1241
|
+
// document would silently drop the routes or exclusions it omits.
|
|
1242
|
+
const espRoutingPath = readOption(options.espRoutingFile);
|
|
1243
|
+
if (espRoutingPath) {
|
|
1244
|
+
settings.esp_matching = readJsonFileValue(resolve(espRoutingPath), "--esp-routing-file");
|
|
1245
|
+
}
|
|
1246
|
+
const senderFailover = readOption(options.senderFailover);
|
|
1247
|
+
if (senderFailover)
|
|
1248
|
+
settings.sender_failover = senderFailover;
|
|
1249
|
+
// email_min_gap_minutes accepts 0 ("no extra humanization gap"), which
|
|
1250
|
+
// readPositiveInt rejects (it requires >= 1); treat a literal 0 as valid and
|
|
1251
|
+
// defer any other value to readPositiveInt's positive-integer validation. The
|
|
1252
|
+
// server enforces the 0-720 range (validateSequenceSettings).
|
|
1253
|
+
const emailMinGapRaw = readOption(options.emailMinGapMinutes);
|
|
1254
|
+
if (emailMinGapRaw !== null) {
|
|
1255
|
+
const emailMinGap = Number(emailMinGapRaw) === 0 ? 0 : readPositiveInt(emailMinGapRaw);
|
|
1256
|
+
if (emailMinGap !== undefined)
|
|
1257
|
+
settings.email_min_gap_minutes = emailMinGap;
|
|
1258
|
+
}
|
|
1259
|
+
// opportunity_value is a non-negative dollar amount (may be fractional and may be
|
|
1260
|
+
// 0), so it is parsed as a plain finite number rather than a positive integer;
|
|
1261
|
+
// the server enforces the >= 0 rule (validateSequenceSettings) and reports a clear
|
|
1262
|
+
// error for a negative value, so send any finite number through.
|
|
1263
|
+
const opportunityValueRaw = readOption(options.opportunityValue);
|
|
1264
|
+
if (opportunityValueRaw !== null) {
|
|
1265
|
+
const opportunityValue = Number(opportunityValueRaw);
|
|
1266
|
+
if (Number.isFinite(opportunityValue))
|
|
1267
|
+
settings.opportunity_value = opportunityValue;
|
|
1268
|
+
}
|
|
1269
|
+
// Per-sequence sending-account allowlist (--mailboxes id1,id2). Split + trim +
|
|
1270
|
+
// drop empties client-side (light); the server validates each is a UUID and caps
|
|
1271
|
+
// the count. An all-empty list omits the key so a stray "--mailboxes ," is a no-op.
|
|
1272
|
+
// `--mailboxes ""` sends [] (no allowlist: the whole pool rotates again).
|
|
1273
|
+
if (options.mailboxes !== undefined)
|
|
1274
|
+
settings.mailbox_ids = readCsvOption(options.mailboxes);
|
|
1275
|
+
// Sender profiles (--sender-profiles id1,id2): the sequence sends from these
|
|
1276
|
+
// unified identities. The server resolves them into senderAccountIds + mailbox_ids.
|
|
1277
|
+
const senderProfileIds = readCsvOption(options.senderProfiles);
|
|
1278
|
+
if (options.senderProfiles !== undefined)
|
|
1279
|
+
settings.sender_profile_ids = senderProfileIds;
|
|
1280
|
+
return Object.keys(settings).length > 0 ? settings : undefined;
|
|
1281
|
+
}
|
|
1282
|
+
export function registerSequencesFamily(program, handleAsyncAction) {
|
|
1283
|
+
program.addCommand(new Command("sequences")
|
|
1284
|
+
.description("Multichannel outreach sequences: one enrollment per lead can span LinkedIn, email, WhatsApp, human call tasks, and connected-CRM tasks. LinkedIn steps dispatch natively within sender limits, with zero additional credits per action; connected-account monthly charges remain separate; email steps send natively or place/move/stop the lead in a bound Instantly campaign (BYOK); crm_task can exact-match/upsert/link CRM records and create a mapped external follow-up (HubSpot first), with replay-safe per-operation receipts. A reply always stops that lead, on every channel. To also pause the replier's colleagues, set `--company-reply-pause` on `create`/`update` (pick the reply categories); to pause or resume a company by hand or from a workflow (e.g. a meeting was booked), use `pause-company` / `resume-company` / `paused-companies`. A pause is resumable; a company do-not-contact entry (`suppressions`) is a permanent block.")
|
|
1285
|
+
.addCommand(addContractFlags(new Command("list")
|
|
1286
|
+
.description("List sequences with persisted launch status kept separate from current operational state. Fleet counts report marked-active versus operationally-live (active with nonterminal work), and rows explain flowing, paced, paused, blocked, degraded, drained, or empty work without SQL. An active sequence whose every lead has finished shows drained (completed); leads enrolled into it later are contacted on its next dispatch tick. --stats joins each row's LIFETIME funnel.")
|
|
1287
|
+
.option("--status <status>", "Filter by status: draft, active, paused, or archived.")
|
|
1288
|
+
.addOption(new Option("--operational-state <state>", "Filter by current work state; fleet counts remain unfiltered: empty, drained, never_started (enrolled leads, never launched), blocked, degraded, flowing, paced, or paused (work waiting on a paused sequence).").choices(["empty", "drained", "never_started", "blocked", "degraded", "flowing", "paced", "paused"]))
|
|
1289
|
+
.addOption(new Option("--lifecycle <lifecycle>", "Filter by the lifecycle a person reads; fleet counts remain unfiltered: draft, active, completed (active, every lead finished), paused, or archived.").choices([...SEQUENCE_LIFECYCLES]))
|
|
1290
|
+
.addOption(new Option("--channel <channel>", "Only sequences carrying this channel.").choices(["email", "linkedin", "whatsapp"]))
|
|
1291
|
+
.option("--tag <tag>", "Only sequences carrying this workspace tag (see `oxygen tags list`).")
|
|
1292
|
+
.option("--stats", "Attach lifetime stats per sequence (a stats pass per row — slower on large workspaces)."), SEQUENCE_LIST_PROJECTION_CONTRACT)
|
|
1293
|
+
.option("--json", "Print a JSON envelope.")
|
|
1294
|
+
.action(async (options) => {
|
|
1295
|
+
await handleReadActionWithLens("sequences list", options, () => {
|
|
1296
|
+
const params = new URLSearchParams();
|
|
1297
|
+
const status = readOption(options.status);
|
|
1298
|
+
if (status)
|
|
1299
|
+
params.set("status", status);
|
|
1300
|
+
const operationalState = readOption(options.operationalState);
|
|
1301
|
+
if (operationalState)
|
|
1302
|
+
params.set("operational_state", operationalState);
|
|
1303
|
+
const lifecycle = readOption(options.lifecycle);
|
|
1304
|
+
if (lifecycle)
|
|
1305
|
+
params.set("lifecycle", lifecycle);
|
|
1306
|
+
const channel = readOption(options.channel);
|
|
1307
|
+
if (channel)
|
|
1308
|
+
params.set("channel", channel);
|
|
1309
|
+
const tag = readOption(options.tag);
|
|
1310
|
+
if (tag)
|
|
1311
|
+
params.set("tag", tag);
|
|
1312
|
+
if (options.stats)
|
|
1313
|
+
params.set("include", "stats");
|
|
1314
|
+
const projection = readContractFlags(options, SEQUENCE_LIST_PROJECTION_CONTRACT);
|
|
1315
|
+
const omit = typeof projection.omit === "string" ? readOption(projection.omit) : undefined;
|
|
1316
|
+
if (omit)
|
|
1317
|
+
params.set("omit", omit);
|
|
1318
|
+
const suffix = params.toString();
|
|
1319
|
+
return requestOxygen(`/api/cli/sequences${suffix ? `?${suffix}` : ""}`);
|
|
1320
|
+
}, formatSequenceListHealth);
|
|
1321
|
+
}))
|
|
1322
|
+
.addCommand(new Command("send")
|
|
1323
|
+
.description("Initiate one net-new LinkedIn message through a one-recipient, one-step hosted Sequence. First call without a sequence id: pass --recipient, --text/--text-file, and --sender to create the inert draft and preview (no message, no credits). After showing that preview, re-run with the returned sequence id plus --approved --max-credits N. Existing-thread replies stay in `oxygen inbox send`.")
|
|
1324
|
+
.argument("[sequence]", "Sequence id returned by the preview call. Omit when creating the preview; required with --approved.")
|
|
1325
|
+
.option("--recipient <url-or-id>", "LinkedIn personal-profile URL or provider member id. Preview call only.")
|
|
1326
|
+
.option("--recipient-name <name>", "Optional display name stored on the enrollment. Preview call only.")
|
|
1327
|
+
.option("--text <text>", "Message body. Preview call only; use either --text or --text-file.")
|
|
1328
|
+
.option("--text-file <path>", "Read the message body from a file. Preview call only; use either --text-file or --text.")
|
|
1329
|
+
.option("--sender <ref>", "Connected LinkedIn sender id, connection id, or Unipile account id. List choices with `oxygen senders list`.")
|
|
1330
|
+
.option("--approved", "Approve the previewed Sequence for live dispatch. Requires the returned sequence id and --max-credits.")
|
|
1331
|
+
.option("--max-credits <n>", "Positive credit ceiling for the live LinkedIn dispatch.")
|
|
1332
|
+
.option("--json", "Print a JSON envelope.")
|
|
1333
|
+
.action(async (sequence, options) => {
|
|
1334
|
+
await handleAsyncAction("sequences send", options, () => {
|
|
1335
|
+
const sequenceId = readOption(sequence);
|
|
1336
|
+
const maxCredits = readPositiveNumber(options.maxCredits);
|
|
1337
|
+
if (sequenceId) {
|
|
1338
|
+
if (readOption(options.recipient) || readOption(options.recipientName) || readOption(options.text) || readOption(options.textFile) || readOption(options.sender)) {
|
|
1339
|
+
throw new OxygenError("conflicting_flags", "With a sequence id, pass only --approved and --max-credits; recipient, copy, and sender are fixed by the previewed draft.", { exitCode: 1 });
|
|
1340
|
+
}
|
|
1341
|
+
return requestOxygen("/api/cli/sequences/send", { method: "POST", body: { sequence: sequenceId, ...(options.approved ? { approved: true } : {}), ...(maxCredits !== undefined ? { max_credits: maxCredits } : {}) } });
|
|
1342
|
+
}
|
|
1343
|
+
if (options.approved)
|
|
1344
|
+
throw new OxygenError("sequence_preview_required", "Preview first without --approved; then pass the returned sequence id with --approved --max-credits N.", { exitCode: 1 });
|
|
1345
|
+
const recipient = readOption(options.recipient);
|
|
1346
|
+
const sender = readOption(options.sender);
|
|
1347
|
+
const text = readPublishingPostText(options, true);
|
|
1348
|
+
if (!recipient)
|
|
1349
|
+
throw new OxygenError("invalid_request", "--recipient is required for the preview call.", { exitCode: 1 });
|
|
1350
|
+
if (!sender)
|
|
1351
|
+
throw new OxygenError("invalid_request", "--sender is required for the preview call; list choices with `oxygen senders list`.", { exitCode: 1 });
|
|
1352
|
+
return requestOxygen("/api/cli/sequences/send", { method: "POST", body: { recipient, text, sender, ...(readOption(options.recipientName) ? { recipient_name: readOption(options.recipientName) } : {}) } });
|
|
1353
|
+
});
|
|
1354
|
+
}))
|
|
1355
|
+
.addCommand(new Command("workflows")
|
|
1356
|
+
.description("Compatibility-only maintenance for existing sequencer presets. Do not create new Teams notifications here: use `oxygen workflows` (web: Sequence → Launch → Connected workflows) with a sequence event trigger and Microsoft Teams Send Message.")
|
|
1357
|
+
.addCommand(new Command("list")
|
|
1358
|
+
.description("List the two legacy sequencer presets with armed state, editable config, and ordinary workflow deep-links.")
|
|
1359
|
+
.option("--json", "Print a JSON envelope.")
|
|
1360
|
+
.action(async (options) => {
|
|
1361
|
+
await handleAsyncAction("sequences workflows list", options, () => requestOxygen("/api/cli/sequencer/reply-workflows"));
|
|
1362
|
+
}))
|
|
1363
|
+
.addCommand(new Command("configure")
|
|
1364
|
+
.description("Preview or maintain an existing legacy preset. New Teams notifications must use an ordinary connected workflow; this command remains only for compatibility. Omit --live for a zero-write preview. Live arming requires --armed --approved and a positive per-delivery --max-credits cap.")
|
|
1365
|
+
.argument("<template>", "crm-lead-stage-router or the legacy sequencer-positive-reply-teams preset (existing configurations only).")
|
|
1366
|
+
.option("--config-file <path>", "JSON object containing the managed workflow configuration.")
|
|
1367
|
+
.option("--armed", "Arm the workflow.")
|
|
1368
|
+
.option("--disarmed", "Disarm the workflow.")
|
|
1369
|
+
.option("--live", "Persist the configuration. Omit for a dry-run preview.")
|
|
1370
|
+
.option("--approved", "Explicitly approve standing authority for this exact workflow revision.")
|
|
1371
|
+
.option("--max-credits <n>", "Hard per-delivery credit ceiling (the legacy Teams preset requires at least 0.02).")
|
|
1372
|
+
.option("--json", "Print a JSON envelope.")
|
|
1373
|
+
.action(async (template, options) => {
|
|
1374
|
+
await handleAsyncAction("sequences workflows configure", options, () => {
|
|
1375
|
+
if (options.armed === options.disarmed) {
|
|
1376
|
+
throw new Error("Pass exactly one of --armed or --disarmed.");
|
|
1377
|
+
}
|
|
1378
|
+
const configPath = readOption(options.configFile);
|
|
1379
|
+
const config = configPath
|
|
1380
|
+
? readJsonFileValue(resolve(configPath), "--config-file")
|
|
1381
|
+
: undefined;
|
|
1382
|
+
if (config !== undefined && !isRecord(config)) {
|
|
1383
|
+
throw new Error("--config-file must contain a JSON object.");
|
|
1384
|
+
}
|
|
1385
|
+
const maxCredits = readPositiveNumber(options.maxCredits);
|
|
1386
|
+
return requestOxygen("/api/cli/sequencer/reply-workflows", {
|
|
1387
|
+
method: "POST",
|
|
1388
|
+
body: {
|
|
1389
|
+
template_id: template,
|
|
1390
|
+
armed: options.armed === true,
|
|
1391
|
+
mode: options.live ? "live" : "dry_run",
|
|
1392
|
+
...(config ? { config } : {}),
|
|
1393
|
+
...(options.approved ? { approved: true } : {}),
|
|
1394
|
+
...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
|
|
1395
|
+
},
|
|
1396
|
+
});
|
|
1397
|
+
});
|
|
1398
|
+
})))
|
|
1399
|
+
.addCommand(new Command("hubspot-sync")
|
|
1400
|
+
.description("Project sequence activity, native email/LinkedIn timeline entries, and reply outcomes into the connected HubSpot portal. One workspace setting manages the event and classified-reply workflows across every sequence.")
|
|
1401
|
+
.addCommand(new Command("show")
|
|
1402
|
+
.description("Show the connected portal, live contact schema, activity/status configuration, component health, and enabled state.")
|
|
1403
|
+
.option("--json", "Print a JSON envelope.")
|
|
1404
|
+
.action(async (options) => {
|
|
1405
|
+
await handleAsyncAction("sequences hubspot-sync show", options, () => requestOxygen("/api/cli/sequencer/hubspot-sync"));
|
|
1406
|
+
}))
|
|
1407
|
+
.addCommand(new Command("configure")
|
|
1408
|
+
.description("Preview or save the workspace-wide HubSpot projection. Existing fields need no new grant; creating missing properties requires --approved and HubSpot property-creation access.")
|
|
1409
|
+
.option("--config-file <path>", "Full or partial JSON configuration: mappings, timeline_events, LinkedIn identity property, lead-status property, sequence/reply status mappings, and opt-out status.")
|
|
1410
|
+
.option("--mappings-file <path>", "JSON object mapping every sequencer event key to a HubSpot contact datetime-property internal name; use an empty string to disable an event.")
|
|
1411
|
+
.option("--armed", "Enable future event delivery.")
|
|
1412
|
+
.option("--disarmed", "Disable future event delivery.")
|
|
1413
|
+
.option("--live", "Persist the configuration. Omit for a zero-write dry-run preview.")
|
|
1414
|
+
.option("--approved", "Approve the exact live mapping and any recommended properties shown by the preview.")
|
|
1415
|
+
.option("--json", "Print a JSON envelope.")
|
|
1416
|
+
.action(async (options) => {
|
|
1417
|
+
await handleAsyncAction("sequences hubspot-sync configure", options, () => {
|
|
1418
|
+
if (options.armed === options.disarmed) {
|
|
1419
|
+
throw new Error("Pass exactly one of --armed or --disarmed.");
|
|
1420
|
+
}
|
|
1421
|
+
const configPath = readOption(options.configFile);
|
|
1422
|
+
const configValue = configPath
|
|
1423
|
+
? readJsonFileValue(resolve(configPath), "--config-file")
|
|
1424
|
+
: undefined;
|
|
1425
|
+
if (configValue !== undefined && !isRecord(configValue)) {
|
|
1426
|
+
throw new Error("--config-file must contain a JSON object.");
|
|
1427
|
+
}
|
|
1428
|
+
const mappingsPath = readOption(options.mappingsFile);
|
|
1429
|
+
const mappings = mappingsPath
|
|
1430
|
+
? readJsonFileValue(resolve(mappingsPath), "--mappings-file")
|
|
1431
|
+
: undefined;
|
|
1432
|
+
if (mappings !== undefined && !isRecord(mappings)) {
|
|
1433
|
+
throw new Error("--mappings-file must contain a JSON object.");
|
|
1434
|
+
}
|
|
1435
|
+
const config = {
|
|
1436
|
+
...(configValue ?? {}),
|
|
1437
|
+
...(mappings
|
|
1438
|
+
? {
|
|
1439
|
+
mappings: {
|
|
1440
|
+
...(isRecord(configValue?.mappings)
|
|
1441
|
+
? configValue.mappings
|
|
1442
|
+
: {}),
|
|
1443
|
+
...mappings,
|
|
1444
|
+
},
|
|
1445
|
+
}
|
|
1446
|
+
: {}),
|
|
1447
|
+
};
|
|
1448
|
+
return requestOxygen("/api/cli/sequencer/hubspot-sync", {
|
|
1449
|
+
method: "POST",
|
|
1450
|
+
body: {
|
|
1451
|
+
armed: options.armed === true,
|
|
1452
|
+
mode: options.live ? "live" : "dry_run",
|
|
1453
|
+
...(Object.keys(config).length > 0 ? { config } : {}),
|
|
1454
|
+
...(options.approved ? { approved: true } : {}),
|
|
1455
|
+
},
|
|
1456
|
+
});
|
|
1457
|
+
});
|
|
1458
|
+
})))
|
|
1459
|
+
.addCommand(new Command("hubspot-list-import")
|
|
1460
|
+
.description("Discover saved HubSpot contact lists and configure one bounded DNC-first automatic import into an Oxygen Sequence. Draft and paused recipients stay pending until launch or resume.")
|
|
1461
|
+
.addCommand(new Command("lists")
|
|
1462
|
+
.description("List saved HubSpot contact/company segments and readable identity fields available for a Sequence import. These no-bill definition reads change no members, DNC entries, rows, workflows, or enrollments.")
|
|
1463
|
+
.argument("<sequence>", "Sequence id or slug.")
|
|
1464
|
+
.option("--query <text>", "Optional case-insensitive words to match in the HubSpot list name.")
|
|
1465
|
+
.option("--json", "Print a JSON envelope.")
|
|
1466
|
+
.action(async (sequence, options) => {
|
|
1467
|
+
await handleAsyncAction("sequences hubspot-list-import lists", options, () => {
|
|
1468
|
+
const params = new URLSearchParams({ sequence });
|
|
1469
|
+
const query = readOption(options.query);
|
|
1470
|
+
if (query)
|
|
1471
|
+
params.set("query", query);
|
|
1472
|
+
return requestOxygen(`/api/cli/sequencer/hubspot-list-import?${params.toString()}`);
|
|
1473
|
+
});
|
|
1474
|
+
}))
|
|
1475
|
+
.addCommand(new Command("configure")
|
|
1476
|
+
.description("Preview or start a scheduled HubSpot-list import. Preview first; then pass its reviewed fingerprint with --live --approved. The complete DNC list is applied before leads are read or enrolled.")
|
|
1477
|
+
.argument("<sequence>", "Sequence id or slug.")
|
|
1478
|
+
.requiredOption("--lead-list <id>", "Exact HubSpot ILS contact-list id whose current members should be imported.")
|
|
1479
|
+
.requiredOption("--dnc-list <id>", "Different exact HubSpot ILS contact-list id synchronized additively into Oxygen DNC first.")
|
|
1480
|
+
.option("--company-dnc-list <id>", "Optional HubSpot company-list id synchronized as all-channel company DNC.")
|
|
1481
|
+
.option("--contact-linkedin-property <name>", "Optional contact LinkedIn profile property internal name.")
|
|
1482
|
+
.option("--contact-company-domain-property <name>", "Optional explicit company-domain property on contacts, copied to lead rows.")
|
|
1483
|
+
.option("--contact-company-linkedin-property <name>", "Optional explicit company LinkedIn URL property on contacts.")
|
|
1484
|
+
.option("--contact-properties <list>", "Comma-separated extra HubSpot contact properties to hydrate (max 50).")
|
|
1485
|
+
.option("--company-domain-property <name>", "Company-list domain property (default domain).", "domain")
|
|
1486
|
+
.option("--company-linkedin-property <name>", "Optional company-list LinkedIn URL property.")
|
|
1487
|
+
.option("--cron <expression>", "Standing cron cadence. Defaults to every 15 minutes.")
|
|
1488
|
+
.option("--timezone <iana>", "IANA timezone. Defaults to UTC.")
|
|
1489
|
+
.option("--max-records-per-list <n>", "Hard complete-snapshot cap per list (1-5000).", "5000")
|
|
1490
|
+
.option("--max-credits <n>", "Hard credit ceiling per delivery.", "10")
|
|
1491
|
+
.option("--include-contacted", "Allow leads already contacted by another active Sequence (default excludes them).")
|
|
1492
|
+
.option("--reviewed-fingerprint <hash>", "Fingerprint returned by the exact dry-run preview; required for live activation.")
|
|
1493
|
+
.option("--idempotency-key <key>", "Optional idempotency key for the immediate first durable run.")
|
|
1494
|
+
.option("--live", "Create/bind the Table if needed, arm the Workflow, and enqueue the first cycle.")
|
|
1495
|
+
.option("--approved", "Approve the reviewed recurring HubSpot reads, additive DNC writes, Table upserts, and Sequence enrollment.")
|
|
1496
|
+
.option("--json", "Print a JSON envelope.")
|
|
1497
|
+
.action(async (sequence, options) => {
|
|
1498
|
+
await handleAsyncAction("sequences hubspot-list-import configure", options, () => {
|
|
1499
|
+
const maxRecords = readPositiveInt(options.maxRecordsPerList);
|
|
1500
|
+
const maxCredits = readPositiveNumber(options.maxCredits);
|
|
1501
|
+
if (maxRecords === undefined || maxRecords > 5000) {
|
|
1502
|
+
throw new Error("--max-records-per-list must be between 1 and 5000.");
|
|
1503
|
+
}
|
|
1504
|
+
if (maxCredits === undefined) {
|
|
1505
|
+
throw new Error("--max-credits must be a positive number.");
|
|
1506
|
+
}
|
|
1507
|
+
const reviewedFingerprint = readOption(options.reviewedFingerprint);
|
|
1508
|
+
if (options.live && !reviewedFingerprint) {
|
|
1509
|
+
throw new Error("Live activation requires --reviewed-fingerprint from the exact dry-run preview.");
|
|
1510
|
+
}
|
|
1511
|
+
return requestOxygen("/api/cli/sequencer/hubspot-list-import", {
|
|
1512
|
+
method: "POST",
|
|
1513
|
+
body: {
|
|
1514
|
+
sequence,
|
|
1515
|
+
lead_list_id: options.leadList,
|
|
1516
|
+
dnc_list_id: options.dncList,
|
|
1517
|
+
...(readOption(options.companyDncList) ? { company_dnc_list_id: readOption(options.companyDncList) } : {}),
|
|
1518
|
+
...(readOption(options.contactLinkedinProperty) ? { contact_linkedin_property: readOption(options.contactLinkedinProperty) } : {}),
|
|
1519
|
+
...(readOption(options.contactCompanyDomainProperty) ? { contact_company_domain_property: readOption(options.contactCompanyDomainProperty) } : {}),
|
|
1520
|
+
...(readOption(options.contactCompanyLinkedinProperty) ? { contact_company_linkedin_property: readOption(options.contactCompanyLinkedinProperty) } : {}),
|
|
1521
|
+
...(options.contactProperties !== undefined ? { contact_properties: readCsvOption(options.contactProperties) } : {}),
|
|
1522
|
+
...(readOption(options.companyDomainProperty) ? { company_domain_property: readOption(options.companyDomainProperty) } : {}),
|
|
1523
|
+
...(readOption(options.companyLinkedinProperty) ? { company_linkedin_property: readOption(options.companyLinkedinProperty) } : {}),
|
|
1524
|
+
mode: options.live ? "live" : "dry_run",
|
|
1525
|
+
max_records_per_list: maxRecords,
|
|
1526
|
+
max_credits: maxCredits,
|
|
1527
|
+
exclude_contacted: options.includeContacted !== true,
|
|
1528
|
+
...(readOption(options.cron) ? { cron: readOption(options.cron) } : {}),
|
|
1529
|
+
...(readOption(options.timezone) ? { timezone: readOption(options.timezone) } : {}),
|
|
1530
|
+
...(options.approved ? { approved: true } : {}),
|
|
1531
|
+
...(reviewedFingerprint ? { reviewed_fingerprint: reviewedFingerprint } : {}),
|
|
1532
|
+
...(readOption(options.idempotencyKey) ? { idempotency_key: readOption(options.idempotencyKey) } : {}),
|
|
1533
|
+
},
|
|
1534
|
+
});
|
|
1535
|
+
});
|
|
1536
|
+
})))
|
|
1537
|
+
.addCommand(addContractFlags(new Command("analytics")
|
|
1538
|
+
.description("Show selected-window sequencer analytics with persisted status kept separate from operational state, fleet marked-active versus operationally-live counts, current open work, sent/terminal-failed/deferral totals, failure classes, and action-kind breakdowns. Also includes per-sequence funnels and native email attribution under analytics.emailAttribution: byMailbox, byDomain, and explicit unattributed facts. With --sequence, analytics.senderPerformance groups channel-conditional counts and rates by sending person; analytics.messagePerformance reports each copy-bearing deterministic step variant, target/actual distribution, contacted recipients, and type-specific accepted/reply/positive outcomes (absent for note-less connect-only campaigns). Message-level reply outcomes require preserved step/variant provenance, so older unattributed replies remain in campaign totals without being guessed into a message row. Provider-owned campaigns appear only after their telemetry is normalized into Oxygen's native Sequence ledgers. Also reports whether the reply → CRM automation is armed, with a link to the workflow. Omit --projection for all sections in one read; --projection value --path /analytics selects all analytics sections. value_json is escaped JSON text, paged losslessly with value_cursor.")
|
|
1539
|
+
.option("--sender <id>", "Filter to one sender from `oxygen senders list` or `oxygen senders profiles list`, or a key from this output's senders list. Repeatable.", collectRepeatable, [])
|
|
1540
|
+
.option("--json", "Print a JSON envelope."), SEQUENCE_ANALYTICS_READ_CONTRACT, {
|
|
1541
|
+
range: { valueName: "<preset>", parse: readOption },
|
|
1542
|
+
from: { valueName: "<date>", parse: readOption }, to: { valueName: "<date>", parse: readOption },
|
|
1543
|
+
sequence: { valueName: "<sequence>", parse: readOption },
|
|
1544
|
+
campaigns: { valueName: "<ids>", parse: readOption }, senders: { valueName: "<ids>", parse: readOption },
|
|
1545
|
+
})
|
|
1546
|
+
.action(async (options) => {
|
|
1547
|
+
const fetchAnalytics = () => {
|
|
1548
|
+
const query = readContractFlags(options, SEQUENCE_ANALYTICS_READ_CONTRACT, {
|
|
1549
|
+
range: { parse: readOption }, from: { parse: readOption }, to: { parse: readOption },
|
|
1550
|
+
sequence: { parse: readOption }, campaigns: { parse: readOption }, senders: { parse: readOption },
|
|
1551
|
+
});
|
|
1552
|
+
const senders = [...readCsvOption(typeof query.senders === "string" ? query.senders : undefined), ...(options.sender ?? []).flatMap(value => readCsvOption(value))];
|
|
1553
|
+
if (senders.length > 0)
|
|
1554
|
+
query.senders = senders.join(",");
|
|
1555
|
+
else
|
|
1556
|
+
delete query.senders;
|
|
1557
|
+
const params = new URLSearchParams();
|
|
1558
|
+
for (const [key, value] of Object.entries(query)) {
|
|
1559
|
+
if (value !== undefined && value !== null)
|
|
1560
|
+
params.set(key, String(value));
|
|
1561
|
+
}
|
|
1562
|
+
const qs = params.toString();
|
|
1563
|
+
return requestOxygen(`/api/cli/sequences/analytics${qs ? `?${qs}` : ""}`);
|
|
1564
|
+
};
|
|
1565
|
+
const windowRequested = ["projection", "path", "valueCursor", "maxChars"].some(key => options[key] !== undefined);
|
|
1566
|
+
if (windowRequested)
|
|
1567
|
+
await handleAsyncAction("sequences analytics", options, fetchAnalytics);
|
|
1568
|
+
else
|
|
1569
|
+
await handleReadActionWithLens("sequences analytics", options, fetchAnalytics, formatSequenceAnalyticsHealth);
|
|
1570
|
+
}))
|
|
1571
|
+
.addCommand(new Command("create")
|
|
1572
|
+
.description("Create a draft multichannel sequence from a steps JSON file. Supports LinkedIn, email, WhatsApp, human call_task, and connected-CRM crm_task journeys. Assign LinkedIn senders with --senders (optional at create — a draft can sit senderless, but enroll/start require at least one for LinkedIn journeys). Native email sends through connected Google/Microsoft mailboxes, including Microsoft SMTP transport; optional --email-* flags bind an Instantly campaign. Install/read the oxygen-sequencer skill for per-step senders (references/email.md, references/linkedin.md), the launch ladder and complete mapped CRM task examples (references/calls-and-crm-tasks.md).")
|
|
1573
|
+
.addHelpText("after", "\nSenders: each new lead gets one of the --sender-profiles people, the one with the fewest of this sequence's leads (a lead already talking to one of your LinkedIn accounts keeps that account's person), and its LinkedIn and email steps come from that person (email from one of their inboxes). Per step, in the steps file: email_send.sender_profile_id sends that email from any inbox of one selected person (the normal inbox rotation spreads leads across that person's inboxes and each lead keeps its one; a lead already assigned to that person keeps its own inbox; with none usable the lead waits, never using a colleague's inbox), and email_send.sender_mailbox_id from one exact inbox (set one or neither); a LinkedIn step's sender_profile_id sends it from one selected person. Invite, message, inmail and withdraw_invite steps must share one sender_profile_id or none: that person then owns every lead's LinkedIn thread (LinkedIn allows one of your accounts per conversation). visit_profile, follow, like_post and comment_post may each name anyone selected. An email whose From changes, including the return to the lead's inbox, needs its own subject_template. To check who sends each step, run `sequences start <sequence>` without --approved; nothing changes.\n")
|
|
1574
|
+
.requiredOption("--name <name>", "Human-readable sequence name.")
|
|
1575
|
+
.requiredOption("--slug <slug>", "Unique slug for the sequence.")
|
|
1576
|
+
.requiredOption("--steps-file <path>", "Path to a JSON file: { \"steps\": [...] }. LinkedIn steps (visit_profile | invite | wait_for_connection | message | inmail | follow | like_post | comment_post | withdraw_invite), email steps (email_send | email_enroll | email_move | email_stop), WhatsApp (whatsapp_message), human call tasks (call_task), connected-CRM tasks (crm_task; channel crm; exact identity record_mappings + explicit create/update policy + record_links + dynamic task property_mappings/associations), and control steps (wait | wait_for_signal | branch | stop), each with an `id`. Copy templates support {{column}} interpolation from the lead's row, {{column|fallback}} inline fallbacks, deterministic spintax — {{RANDOM|a|b|c}} or industry-standard bare {Hi|Hey|Hello}, nestable like {Would {Tuesday|Thursday} work|next week?} — and {% if column %}…{% endif %} conditionals; a bare {…} region is spintax only when it contains a top-level |, so literal braces (CSS/JSON) pass through. Every step's copy can also use {{sender_first_name}}, {{sender_last_name}} and {{sender_full_name}} (alias {{sender_name}}): the name of the person who sends that step (their sender profile, else the account or inbox display name), with no table column needed; email steps add {{sender_email}} (the from address). A row column of the same name WINS on collision, and a missing name renders empty. comment_post takes text_template and/or ai_prompt — a KG-grounded comment generated at send time (a paid AI call) that falls back to text_template if generation fails. A `branch` routes on signals (then/else) or the legacy connection_accepted/already_connected/open_profile sugar (then_id/else_id). A signal condition can also branch on the LEAD'S DATA: a data leaf { has_column: \"email\" } is true when that row_values column has a non-empty value (add present:false for \"missing\") — e.g. route leads that have an email down an email arm and the rest down a LinkedIn arm. MINIMAL EMAIL STEP SHAPE: { \"id\": \"s1\", \"channel\": \"email\", \"kind\": \"email_send\", \"subject_template\": \"...\", \"body_template\": \"...\" } — body_template is REQUIRED on email_send; subject_template is OPTIONAL and is the one control that decides threading: give a step its own subject and it goes out as a NEW email, omit it and the step continues the lead's previous email in the same thread, going out as \"Re: <the previous email's subject>\" (what the retired email_reply kind used to be — still accepted on input, rewritten into this shape, and reported under `warnings` in the response). The FIRST email step of a sequence must carry a subject, since it has no earlier thread to continue. MINIMAL WHATSAPP STEP SHAPE: { \"id\": \"s1\", \"channel\": \"whatsapp\", \"kind\": \"whatsapp_message\", \"template\": \"Hi {{first_name|there}} …\" } — template is REQUIRED, and a live WhatsApp start also needs the sequence's --whatsapp-cold-initiate opt-in. MINIMAL WAIT STEP SHAPE: { \"id\": \"w1\", \"kind\": \"wait\", \"days\": 3 } — days and/or hours, at least 1 hour in total. A/B tests use an explicit `variants` array of copy partials on the step (up to 25 alternates, a–z; spintax varies wording INSIDE one variant and is not A/B-tracked). For the complete replay-safe crm_task upsert mapping shape, read `oxygen skills get oxygen-sequencer --file references/calls-and-crm-tasks.md`.")
|
|
1577
|
+
.option("--channels <list>", "Comma-separated channels: linkedin,email,whatsapp,call,crm. Defaults to the channels the journey touches. crm_task currently supports HubSpot.")
|
|
1578
|
+
.option("--whatsapp-cold-initiate", "WhatsApp: allow cold-initiating new chats (no prior conversation). Required to start a WhatsApp sequence live — WhatsApp via Unipile is unofficial WhatsApp Web, so cold-initiating is an explicit ban-risk opt-in.")
|
|
1579
|
+
.addOption(new Option("--whatsapp-duplicate-scope <scope>", "Prevent duplicate WhatsApp copy in this campaign (default) or all campaigns.").choices(["campaign", "workspace"]))
|
|
1580
|
+
.option("--phone-column-key <key>", "WhatsApp: row_values key holding each lead's phone number (else falls back to phone/phone_number/mobile).")
|
|
1581
|
+
.option("--senders <ids>", "Comma-separated LinkedIn or WhatsApp sender account ids (or connection / Unipile ids; `senders list` / `whatsapp accounts` list them). Optional at create; enroll and start require at least one when the journey has LinkedIn or WhatsApp steps (attach later with `sequences update --senders`).")
|
|
1582
|
+
.option("--table <id>", "Source table id or slug whose rows supply {{column}} template values.")
|
|
1583
|
+
.option("--from-table <id>", "Alias of --table: the source table id or slug this sequence draws its leads and {{column}} values from.")
|
|
1584
|
+
.option("--enroll", "After creating the sequence, immediately enroll every row of the bound table (same as running `sequences enroll --from-table` next). Requires --table/--from-table. Enrolling spends no credits and sends nothing.")
|
|
1585
|
+
.option("--url-column <key>", "Column key holding each lead's LinkedIn URL/provider id.")
|
|
1586
|
+
.option("--email-provider <provider>", "Omit for native email through connected Google/Microsoft mailboxes, including Microsoft SMTP transport. Set 'instantly' to bind an Instantly campaign with --email-connection and --email-definition-file. To push rows into a campaign you already run in Smartlead, lemlist, HeyReach or Instantly, use `oxygen sequences push-external`.")
|
|
1587
|
+
.option("--email-connection <id>", "Instantly connection id for the email track. Defaults to the org's active Instantly connection.")
|
|
1588
|
+
.option("--email-definition-file <path>", "Path to a JSON file with the email content spec (subjects/bodies/delays/subsequences) compiled to an Instantly campaign on start.")
|
|
1589
|
+
.option("--max-credits <n>", "Stored credit ceiling (0 or more); 0 is valid for a draft. Omit to leave the ceiling unset. Creating a draft sends nothing; live start has its own approval and recipient limits.")
|
|
1590
|
+
.option("--max-live-sends <n>", "Total live-send and CRM-write ceiling across channels, including LinkedIn invites/messages (positive integer). Required for live email/WhatsApp/CRM-task tracks; optional for LinkedIn.")
|
|
1591
|
+
.option("--max-emails-per-mailbox-per-day <n>", "Per-mailbox daily email cap (positive integer) applied across the sending pool; also sets each mailbox's send pace (send window ÷ cap, catching up when a slot is missed). Daily capacity = this cap × sendable mailboxes; `sequences stats` reports it under daily_budget.")
|
|
1592
|
+
.option("--schedule-timezone <tz>", "One sending schedule for every channel, the same as Launch's Sending schedule: IANA zone, e.g. Europe/Berlin. Give it with --schedule-days, --schedule-hours-start and --schedule-hours-end; it sets the email window and the LinkedIn/WhatsApp window together. Without it, email sends weekdays 09:00-17:00 UTC. To give one channel different hours, or to send email in each recipient's own timezone, run `sequences update` after creating (--send-window-file, --linkedin-*).")
|
|
1593
|
+
.option("--schedule-days <days>", "Sending schedule days: mon-fri, mon,wed,fri, or ISO numbers 1-7 (1 = Monday).")
|
|
1594
|
+
.option("--schedule-hours-start <HH:MM>", "Sending schedule start, local to --schedule-timezone, e.g. 09:00.")
|
|
1595
|
+
.option("--schedule-hours-end <HH:MM>", "Sending schedule end (exclusive), e.g. 17:00. An email step's own send_window (on the step in --steps-file) still overrides it for that step. LinkedIn sends also stay inside each LinkedIn account's working hours (default 07:00-22:00 in the account's timezone; set with `oxygen senders limits set`); WhatsApp accounts and email mailboxes have no hours of their own.")
|
|
1596
|
+
.option("--max-emails-per-day <n>", "Sequence-wide daily live-send cap across all senders/mailboxes. Unset means no additional sequence-wide cap; mailbox caps still apply. Omit on update to keep the current setting.")
|
|
1597
|
+
.option("--max-new-enrollments-per-day <n>", "Daily cap on NEW first-touch leads. Unset means no additional intake cap; sending limits still apply. Omit on update to keep the current setting.")
|
|
1598
|
+
.option("--sequence-prioritization <mode>", "Under a tight daily budget, serve 'followups' or 'new_leads' first.")
|
|
1599
|
+
.option("--esp-matching <mode>", "Native-email ESP matching: 'prefer' (DEFAULT) biases toward a mailbox on the recipient's own provider, falling back to any; 'off' rotates mailboxes freely; 'strict' requires a same-provider mailbox and defers the send when none exists.")
|
|
1600
|
+
.option("--esp-routing-file <path>", "Path to a JSON file holding the WHOLE native-email routing policy: { mode?, routes? }. `routes` is keyed by the RECIPIENT's provider and its values are relative send shares per sending provider, so { \"google\": { \"microsoft\": 100 } } sends Google-hosted recipients from Microsoft inboxes. Replaces --esp-matching and the file is the complete policy, not a patch. To stop sending from a domain, leave its inboxes out with --mailboxes.")
|
|
1601
|
+
.option("--sender-failover <mode>", "What happens when an enrollment's LinkedIn/WhatsApp sender goes unavailable: 'wait' (default) resumes when the sender recovers; 'rebind' moves UNTOUCHED enrollments (no thread, no pending invite, no lead binding) to the least-loaded healthy sender in the pool after ~5h of confirmed outage.")
|
|
1602
|
+
.option("--email-min-gap-minutes <n>", "Minimum same-mailbox gap, default 12 minutes (integer 0-720; 0 disables the extra gap). Sends are spread across the window and may be farther apart. Daily caps and windows still apply.")
|
|
1603
|
+
.option("--opportunity-value <usd>", "Estimated USD value of one positive-reply opportunity (>= 0). Analytics-only: sequence stats multiply it by the positive-reply count to report pipeline $ (stats.opportunities). Never gates a send or spends a credit.")
|
|
1604
|
+
.option("--mailboxes <ids>", "Per-sequence sending-account allowlist for native email (Instantly's 'Accounts to use'): comma-separated mailbox ids. Native email sends rotate ONLY over these mailboxes instead of the whole pool. Omit for the whole pool. Note: a narrow allowlist plus --esp-matching strict can starve sends when no allowed same-provider mailbox exists (surfaced as blocked defer reasons in stats).")
|
|
1605
|
+
.option("--sender-profiles <ids>", "Send from these sender profiles (unified LinkedIn/WhatsApp/inbox identities): comma-separated profile ids (`oxygen senders profiles list` lists them). The server resolves each into its senders + inboxes. Live start is blocked if a selected profile lacks an account for a channel the journey uses.")
|
|
1606
|
+
.option("--no-stop-on-bounce", "Keep a lead's enrollment running after a hard bounce (default: stop it). The bounce is still recorded as an email_bounced signal either way.")
|
|
1607
|
+
.option("--exclude-contacted", "Default every enroll into this sequence to cross-campaign exclusion — skip leads any OTHER active sequence is already contacting. A per-enroll `sequences enroll --exclude-contacted` / `--no-exclude-contacted` always overrides this default. Off by default.")
|
|
1608
|
+
.option("--no-exclude-contacted", "Turn the sequence-level exclude-contacted default back OFF (enrollments then exclude cross-campaign only when a call opts in).")
|
|
1609
|
+
.option("--company-reply-pause <off|default|statuses>", "Pause the replier's company in this campaign when a reply has one of these statuses. Oxygen detects the company from work-email domains or explicit company fields; with no company identity, only the replier stops. New sequences start with this setting off. 'default' = interested,neutral,not_now,not_interested; or comma-separated Inbox status keys (`inbox labels list`); 'off' disables it. Read the saved setting with `sequences get`.")
|
|
1610
|
+
.option("--stop-on-reply-scope <scope>", "(deprecated: use --company-reply-pause) 'company_domain' = --company-reply-pause interested,neutral,not_now,not_interested,wrong_person; 'lead' = off.")
|
|
1611
|
+
.option("--bounce-protection <json>", "Sequence bounce policy JSON (partial updates merge): enabled=true, warning_rate=.02, pause_rate=.03, min_sends=20, window_days=7. Hard bounces / accepted sends in the rolling window; pauses this sequence, not the fleet. Rates are fractions; mailbox protection is separate.")
|
|
1612
|
+
.option("--auto-optimize", "Enable the sequence-wide A/B auto-winner (default on).")
|
|
1613
|
+
.option("--no-auto-optimize", "Disable the sequence-wide A/B auto-winner; manual variant decisions stay available.")
|
|
1614
|
+
.option("--include-unsubscribe", "Add an unsubscribe option to emails, the same as Launch's switch: a visible recipient-bound link (plain text shows the URL, HTML a linked Unsubscribe label) plus one-click headers that mail apps show as an Unsubscribe button, both on first-touch cold emails; in-thread follow-ups carry neither. Zapbox sends (mailboxes imported from Zapmail, `transport: \"zapbox\"` in `mailboxes list --json`) carry the link only. Off by default. There is no separate opt-out text: write any reply-to-stop line into the email copy. `sequences update` can switch on the link or the headers alone.")
|
|
1615
|
+
.option("--no-include-unsubscribe", "Turn off both the visible unsubscribe link and the one-click headers.")
|
|
1616
|
+
.option("--tracking-opens", "Opt in to open tracking (default off). Requires a verified tracking domain and deployment tracking secret.")
|
|
1617
|
+
.option("--no-tracking-opens", "Turn OFF the open pixel for this sequence's native email sends (a deliverability knob — a pixel is spam-filter surface).")
|
|
1618
|
+
.option("--tracking-clicks", "Opt in to click tracking (default off). Requires a verified tracking domain and deployment tracking secret.")
|
|
1619
|
+
.option("--no-tracking-clicks", "Turn OFF click-link rewriting for this sequence's native email sends (links go out untouched).")
|
|
1620
|
+
.option("--tags <csv>", "Comma-separated workspace tags linking this sequence to tables, workflows, and knowledge.")
|
|
1621
|
+
.option("--json", "Print a JSON envelope.")
|
|
1622
|
+
.action(async (options) => {
|
|
1623
|
+
await handleAsyncAction("sequences create", options, () => {
|
|
1624
|
+
const stepsPath = readOption(options.stepsFile);
|
|
1625
|
+
if (!stepsPath)
|
|
1626
|
+
throw new Error("--steps-file is required.");
|
|
1627
|
+
const definition = readJsonFileValue(resolve(stepsPath), "--steps-file");
|
|
1628
|
+
const channels = readCsvOption(options.channels);
|
|
1629
|
+
const senders = readCsvOption(options.senders);
|
|
1630
|
+
const tags = readCsvOption(options.tags);
|
|
1631
|
+
const email = readCampaignEmailBinding(options);
|
|
1632
|
+
const maxCredits = readCreditCeilingOrZero(options.maxCredits);
|
|
1633
|
+
const maxLiveSends = readPositiveInt(options.maxLiveSends);
|
|
1634
|
+
const settings = readSequenceSettings(options);
|
|
1635
|
+
// `--from-table` is the phrase customers already use for enroll,
|
|
1636
|
+
// so it names the same thing here rather than being a second
|
|
1637
|
+
// concept; `--table` stays for every existing script.
|
|
1638
|
+
const sourceTable = readOption(options.fromTable) ?? readOption(options.table);
|
|
1639
|
+
if (options.enroll && !sourceTable) {
|
|
1640
|
+
throw new Error("--enroll needs a source table: pass --from-table <table> (or --table <table>).");
|
|
1641
|
+
}
|
|
1642
|
+
return requestOxygen("/api/cli/sequences", {
|
|
1643
|
+
method: "POST",
|
|
1644
|
+
body: {
|
|
1645
|
+
name: readOption(options.name),
|
|
1646
|
+
slug: readOption(options.slug),
|
|
1647
|
+
definition,
|
|
1648
|
+
...(channels.length > 0 ? { channels } : {}),
|
|
1649
|
+
...(senders.length > 0 ? { senders } : {}),
|
|
1650
|
+
...(tags.length > 0 ? { tags } : {}),
|
|
1651
|
+
...(sourceTable ? { source_table_id: sourceTable } : {}),
|
|
1652
|
+
...(options.enroll ? { enroll_from_table: true } : {}),
|
|
1653
|
+
...(readOption(options.urlColumn) ? { linkedin_url_column_key: readOption(options.urlColumn) } : {}),
|
|
1654
|
+
...(email ? { email } : {}),
|
|
1655
|
+
...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
|
|
1656
|
+
...(maxLiveSends !== undefined ? { max_live_sends: maxLiveSends } : {}),
|
|
1657
|
+
...(options.trackingOpens !== undefined ? { tracking_opens: options.trackingOpens } : {}),
|
|
1658
|
+
...(options.trackingClicks !== undefined ? { tracking_clicks: options.trackingClicks } : {}),
|
|
1659
|
+
...(settings ? { settings } : {}),
|
|
1660
|
+
...readUnifiedSequenceBody(options),
|
|
1661
|
+
},
|
|
1662
|
+
});
|
|
1663
|
+
});
|
|
1664
|
+
}))
|
|
1665
|
+
.addCommand(new Command("draft")
|
|
1666
|
+
.description("Draft an email outreach sequence with AI, grounded in the workspace Knowledge Graph (voice, positioning, campaign learnings) — Instantly's AI sequence generator, with provenance. PAID (one AI call, typically ~15 credits [low tier]; an under-cap call errors BEFORE spending and prints the live estimate) — requires --max-credits. Prints the drafted steps + the KG pages that grounded them; nothing is saved until you re-run with --create (which needs --name and --slug). Even created, it lands as a DRAFT — enrolling leads and starting it stay approval-gated.")
|
|
1667
|
+
.requiredOption("--goal <goal>", "Plain-language goal for the sequence (e.g. \"book demos with RevOps leaders at Series A SaaS\"). The trusted brief the copy is written to.")
|
|
1668
|
+
.option("--audience <audience>", "Who the sequence targets (e.g. \"RevOps leaders at 20-100 person B2B SaaS\"). Sharpens the Knowledge Graph retrieval.")
|
|
1669
|
+
.option("--table <id>", "Source table id whose column keys become the {{column|fallback}} personalization tokens the copy may use.")
|
|
1670
|
+
.option("--steps <n>", "How many emails in the journey (1-7). Defaults to 4.")
|
|
1671
|
+
.option("--variants <n>", "Copy variants per email for A/B testing (1-3). Defaults to 1.")
|
|
1672
|
+
.requiredOption("--max-credits <n>", "Credit cap for the AI call (required — this is a paid action).")
|
|
1673
|
+
.option("--create", "Save the draft as a DRAFT sequence (requires --name and --slug). Without this, the draft is only previewed.")
|
|
1674
|
+
.option("--name <name>", "Human-readable sequence name (required with --create).")
|
|
1675
|
+
.option("--slug <slug>", "Unique slug for the sequence (required with --create).")
|
|
1676
|
+
.option("--json", "Print a JSON envelope.")
|
|
1677
|
+
.action(async (options) => {
|
|
1678
|
+
try {
|
|
1679
|
+
const data = await requestOxygen("/api/cli/sequences/draft", {
|
|
1680
|
+
method: "POST",
|
|
1681
|
+
body: buildSequenceDraftBody(options),
|
|
1682
|
+
});
|
|
1683
|
+
emitSuccess("sequences draft", data, options);
|
|
1684
|
+
if (!options.json)
|
|
1685
|
+
writeSequenceDraftPreview(data);
|
|
1686
|
+
writeCreditsReceipt(data);
|
|
1687
|
+
}
|
|
1688
|
+
catch (error) {
|
|
1689
|
+
emitCliFailure("sequences draft", error);
|
|
1690
|
+
}
|
|
1691
|
+
}))
|
|
1692
|
+
.addCommand(new Command("update")
|
|
1693
|
+
.description("Update a sequence. Journey structure/channels/email binding are draft-only so revised scope receives a fresh launch approval; a started sequence may only re-time waits it already has (clear a delay by zeroing it, never by deleting the step — enrollments track position by index); sender pools can change while draft or paused; launch caps change through `sequences start` after first start. Name, throttles, the sending schedule, tags, the unsubscribe option (off by default), and open/click tracking toggles remain editable as allowed by the server. Pass only the fields you want to change.")
|
|
1694
|
+
.addHelpText("after", "\nSenders: email_send.sender_profile_id sends one email from any inbox of a selected person (a lead already assigned to that person keeps its own inbox), and email_send.sender_mailbox_id from one exact inbox of a selected sender; a LinkedIn step's sender_profile_id sends it from a selected person (invite, message, inmail and withdraw_invite share one or none). Omit both to use the lead's assigned person. An email whose From changes needs its own subject_template. `sequences start <sequence>` without --approved shows who sends each step; nothing changes.\n")
|
|
1695
|
+
.argument("<sequence>", "Sequence id or slug.")
|
|
1696
|
+
.option("--name <name>", "New human-readable sequence name.")
|
|
1697
|
+
.option("--steps-file <path>", "Path to a JSON file: { \"steps\": [...] } replacing the journey. Once started, only wait timing may change: same steps, same order (zero a delay, never delete its step). Copy supports {{column}} interpolation; every step also takes {{sender_first_name}} / {{sender_last_name}} / {{sender_full_name}} for the person who sends it, and email adds {{sender_email}} (a same-named row column wins). A `branch` can also route on the lead's data with a data leaf { has_column: \"email\" } (true when that row_values column is non-empty; present:false for \"missing\").")
|
|
1698
|
+
.option("--channels <list>", "Draft only: comma-separated channels: linkedin,email,whatsapp,call,crm.")
|
|
1699
|
+
.option("--whatsapp-cold-initiate", "WhatsApp: allow cold-initiating new chats (no prior conversation). Required to start a WhatsApp sequence live (draft-editable).")
|
|
1700
|
+
.option("--no-whatsapp-cold-initiate", "WhatsApp: stop allowing cold chats; a live WhatsApp start then refuses.")
|
|
1701
|
+
.addOption(new Option("--whatsapp-duplicate-scope <scope>", "Prevent duplicate WhatsApp copy in this campaign (default) or all campaigns.").choices(["campaign", "workspace"]))
|
|
1702
|
+
.option("--phone-column-key <key>", "WhatsApp: row_values key holding each lead's phone number (else falls back to phone/phone_number/mobile). \"\" clears it back to auto-detect.")
|
|
1703
|
+
.option("--email-column-key <key>", "Native email: row_values key holding each lead's email (else falls back to email/email_address/work_email). Maps a non-English header (e.g. E-Mail). \"\" clears it back to auto-detect.")
|
|
1704
|
+
.option("--linkedin-url-column-key <key>", "LinkedIn: row_values key holding each lead's profile URL (else falls back to linkedin_url/linkedinUrl/linkedin/profile_url). \"\" clears it back to auto-detect.")
|
|
1705
|
+
.option("--senders <ids>", "LinkedIn or WhatsApp accounts attached directly, besides the --sender-profiles people's own accounts: comma-separated sender account ids (or connection / Unipile ids; `senders list` / `whatsapp accounts` list them). Replaces the directly attached set; \"\" removes them all and keeps the selected senders. `sequences get` lists both under sender_pool. Draft/paused only.")
|
|
1706
|
+
.option("--source-table <idOrSlug>", "Bind the table this sequence enrolls leads from (id or slug). Only lands while the sequence has no source table yet (first-writer-wins); its rows become enrollable leads. Draft/paused only.")
|
|
1707
|
+
.option("--email-provider <provider>", "Optional linked-campaign provider: 'instantly'. Omit to preserve the existing binding; --clear-email removes it on a draft. Native email needs no provider binding and sends through connected Google/Microsoft mailboxes, including Microsoft SMTP transport.")
|
|
1708
|
+
.option("--email-connection <id>", "Instantly connection id for the email track.")
|
|
1709
|
+
.option("--email-definition-file <path>", "Path to a JSON file with the email content spec (subjects/bodies/delays/subsequences).")
|
|
1710
|
+
.option("--clear-email", "Remove the email binding from the sequence (draft only).")
|
|
1711
|
+
.option("--max-credits <n>", "Stored draft credit ceiling (0 or more); 0 is valid. Changing a started sequence's cap requires its normal live-start approval.")
|
|
1712
|
+
.option("--max-live-sends <n>", "Draft-time total live-send and CRM-write ceiling across channels, including LinkedIn invites/messages (positive integer). After first start, change it through `sequences start`.")
|
|
1713
|
+
.option("--max-emails-per-mailbox-per-day <n>", "Per-mailbox daily email cap (0 or more) applied across the sending pool; also sets each mailbox's send pace (send window ÷ cap, catching up when a slot is missed). Daily capacity = this cap × sendable mailboxes; `sequences stats` reports it under daily_budget.")
|
|
1714
|
+
.option("--schedule-timezone <tz>", "One sending schedule for every channel, the same as Launch's Sending schedule: IANA zone, e.g. Europe/Berlin. Give it with --schedule-days, --schedule-hours-start and --schedule-hours-end; it sets the email window and the LinkedIn/WhatsApp window together. Use it instead of --send-window-file and the --linkedin-* flags. Email set to send in each recipient's own timezone keeps doing so, with this zone for leads that have none.")
|
|
1715
|
+
.option("--schedule-days <days>", "Sending schedule days: mon-fri, mon,wed,fri, or ISO numbers 1-7 (1 = Monday).")
|
|
1716
|
+
.option("--schedule-hours-start <HH:MM>", "Sending schedule start, local to --schedule-timezone, e.g. 09:00.")
|
|
1717
|
+
.option("--schedule-hours-end <HH:MM>", "Sending schedule end (exclusive), e.g. 17:00. An email step's own send_window (on the step in --steps-file) still overrides it for that step. LinkedIn sends also stay inside each LinkedIn account's working hours (default 07:00-22:00 in the account's timezone; set with `oxygen senders limits set`); WhatsApp accounts and email mailboxes have no hours of their own.")
|
|
1718
|
+
.option("--send-window-file <path>", "Email-only window JSON: { timezone, days?, start, end, timezone_mode?, recipient_timezone_column? }; days are ISO 1-7. New sequences default to weekdays 09:00-17:00 UTC. Use it to keep email on different hours from LinkedIn/WhatsApp, or to send in each recipient's own timezone (timezone_mode recipient, which --schedule-* then keeps); for one schedule across channels, use --schedule-* instead.")
|
|
1719
|
+
.option("--linkedin-invite-schedule <json>", 'LinkedIn invites only: { "timezone": "Europe/Berlin", "days": [1,2,3,4,5,6,7], "start": "09:00", "end": "17:00" }. Replaces the general Sequence schedule for invites; account working hours still apply. null restores the general schedule.')
|
|
1720
|
+
.option("--linkedin-message-schedule <json>", 'LinkedIn messages and InMail only: { "timezone": "Europe/Berlin", "days": [1,2,3,4,5], "start": "10:00", "end": "16:00" }. Replaces the general Sequence schedule for messages; account working hours still apply. null restores the general schedule.')
|
|
1721
|
+
.option("--linkedin-timezone <tz>", "LinkedIn/WhatsApp-only sending schedule: IANA zone, e.g. Europe/Zurich. Set with --linkedin-days, --linkedin-hours-start and --linkedin-hours-end; for LinkedIn the window narrows each account's working hours, for WhatsApp it is the only hours limit. For one schedule across channels, use --schedule-* instead.")
|
|
1722
|
+
.option("--linkedin-days <days>", "LinkedIn sending schedule days: mon-fri, mon,wed,fri, or ISO numbers 1-7 (1 = Monday).")
|
|
1723
|
+
.option("--linkedin-hours-start <HH:MM>", "LinkedIn sending schedule start, local to --linkedin-timezone, e.g. 06:45.")
|
|
1724
|
+
.option("--linkedin-hours-end <HH:MM>", "LinkedIn sending schedule end (exclusive), e.g. 18:30. Must be after the start. For LinkedIn the window only narrows each account's working hours (default 07:00-22:00, see senders limits get), never widens them.")
|
|
1725
|
+
.option("--max-emails-per-day <n|none>", "Sequence-wide daily cap on live sends across every channel (email, LinkedIn, WhatsApp) and sender; 0 sends nothing. `none` removes the cap; mailbox and account caps still apply. Omit to keep the current setting.")
|
|
1726
|
+
.option("--max-new-enrollments-per-day <n|none>", "Daily cap on NEW first-touch leads (0 or more). `none` removes it; sending limits still apply. Omit to keep the current setting.")
|
|
1727
|
+
.option("--sequence-prioritization <mode>", "Under a tight daily budget, serve 'followups' or 'new_leads' first.")
|
|
1728
|
+
.option("--esp-matching <mode>", "Native-email ESP matching: 'prefer' (DEFAULT) biases toward a mailbox on the recipient's own provider, falling back to any; 'off' rotates mailboxes freely; 'strict' requires a same-provider mailbox and defers the send when none exists. Changes only the mode: routes saved by --esp-routing-file or the web stay.")
|
|
1729
|
+
.option("--esp-routing-file <path>", "Path to a JSON file holding the WHOLE native-email routing policy: { mode?, routes? }. `routes` is keyed by the RECIPIENT's provider and its values are relative send shares per sending provider, so { \"google\": { \"microsoft\": 100 } } sends Google-hosted recipients from Microsoft inboxes. Replaces --esp-matching and the file is the complete policy, not a patch. To stop sending from a domain, leave its inboxes out with --mailboxes.")
|
|
1730
|
+
.option("--sender-failover <mode>", "What happens when an enrollment's LinkedIn/WhatsApp sender goes unavailable: 'wait' (default) resumes when the sender recovers; 'rebind' moves UNTOUCHED enrollments (no thread, no pending invite, no lead binding) to the least-loaded healthy sender in the pool after ~5h of confirmed outage.")
|
|
1731
|
+
.option("--email-min-gap-minutes <n>", "Minimum same-mailbox gap, default 12 minutes (integer 0-720; 0 disables the extra gap). Sends are spread across the window and may be farther apart. Daily caps and windows still apply.")
|
|
1732
|
+
.option("--opportunity-value <usd>", "Estimated USD value of one positive-reply opportunity (>= 0). Analytics-only: sequence stats multiply it by the positive-reply count to report pipeline $ (stats.opportunities). Never gates a send or spends a credit.")
|
|
1733
|
+
.option("--mailboxes <ids>", "Per-sequence sending-account allowlist for native email (Instantly's 'Accounts to use'): comma-separated mailbox ids. Native email sends rotate ONLY over these mailboxes instead of the whole pool. Note: a narrow allowlist plus --esp-matching strict can starve sends when no allowed same-provider mailbox exists (surfaced as blocked defer reasons in stats). \"\" removes the allowlist.")
|
|
1734
|
+
.option("--sender-profiles <ids>", "Send from these sender profiles (unified LinkedIn/WhatsApp/inbox identities): comma-separated profile ids (`oxygen senders profiles list` lists them). The server resolves each into its senders + inboxes. Live start is blocked if a selected profile lacks an account for a channel the journey uses.")
|
|
1735
|
+
.option("--no-stop-on-bounce", "Keep a lead's enrollment running after a hard bounce (default: stop it). The bounce is still recorded as an email_bounced signal either way.")
|
|
1736
|
+
.option("--exclude-contacted", "Default every enroll into this sequence to cross-campaign exclusion — skip leads any OTHER active sequence is already contacting. A per-enroll `sequences enroll --exclude-contacted` / `--no-exclude-contacted` always overrides this default. Off by default.")
|
|
1737
|
+
.option("--no-exclude-contacted", "Turn the sequence-level exclude-contacted default back OFF (enrollments then exclude cross-campaign only when a call opts in).")
|
|
1738
|
+
.option("--company-reply-pause <off|default|statuses>", "Pause the replier's company in this campaign when a reply has one of these statuses. Oxygen detects the company from work-email domains or explicit company fields; with no company identity, only the replier stops. New sequences start with this setting off. 'default' = interested,neutral,not_now,not_interested; or comma-separated Inbox status keys (`inbox labels list`); 'off' disables it. Read the saved setting with `sequences get`.")
|
|
1739
|
+
.option("--stop-on-reply-scope <scope>", "(deprecated: use --company-reply-pause) 'company_domain' = --company-reply-pause interested,neutral,not_now,not_interested,wrong_person; 'lead' = off.")
|
|
1740
|
+
.option("--bounce-protection <json>", "Sequence bounce policy JSON (partial updates merge): enabled=true, warning_rate=.02, pause_rate=.03, min_sends=20, window_days=7. Hard bounces / accepted sends in the rolling window; pauses this sequence, not the fleet. Rates are fractions; mailbox protection is separate.")
|
|
1741
|
+
.option("--auto-optimize", "Turn the A/B auto-winner on (the default): every step with variants is judged on replies, pausing losers once each variant has 100 sends and 5 replies, unless the step sets its own auto_optimize.")
|
|
1742
|
+
.option("--no-auto-optimize", "Disable the sequence-wide A/B auto-winner; manual variant decisions stay available.")
|
|
1743
|
+
.option("--include-unsubscribe", "Add an unsubscribe option to emails, the same as Launch's switch: a visible recipient-bound link (plain text shows the URL, HTML a linked Unsubscribe label) plus one-click headers that mail apps show as an Unsubscribe button, both on first-touch cold emails; in-thread follow-ups carry neither. Zapbox sends (mailboxes imported from Zapmail, `transport: \"zapbox\"` in `mailboxes list --json`) carry the link only. Off by default. There is no separate opt-out text: write any reply-to-stop line into the email copy.")
|
|
1744
|
+
.option("--no-include-unsubscribe", "Turn off both the visible unsubscribe link and the one-click headers.")
|
|
1745
|
+
.option("--include-unsubscribe-headers", "Only the one-click unsubscribe headers (default off); --include-unsubscribe turns on the headers and the visible link together.")
|
|
1746
|
+
.option("--no-include-unsubscribe-headers", "Omit one-click headers.")
|
|
1747
|
+
.option("--include-unsubscribe-link", "Only the visible recipient-bound unsubscribe footer on first-touch cold emails (default off; --include-unsubscribe adds the one-click headers too). Plain-text messages show the URL, while HTML messages show a linked Unsubscribe label.")
|
|
1748
|
+
.option("--no-include-unsubscribe-link", "Turn the visible unsubscribe footer OFF. Future first-touch emails send the authored body unchanged; existing suppression records remain enforced.")
|
|
1749
|
+
.option("--tracking-opens", "Opt in to open tracking (default off). Requires a verified tracking domain and deployment tracking secret.")
|
|
1750
|
+
.option("--no-tracking-opens", "Turn OFF the open pixel for this sequence's native email sends (a deliverability knob — a pixel is spam-filter surface).")
|
|
1751
|
+
.option("--tracking-clicks", "Opt in to click tracking (default off). Requires a verified tracking domain and deployment tracking secret.")
|
|
1752
|
+
.option("--no-tracking-clicks", "Turn OFF click-link rewriting for this sequence's native email sends (links go out untouched).")
|
|
1753
|
+
.option("--tags <csv>", "Replace the sequence's workspace tags (comma-separated; \"\" clears them). The ONE field an ARCHIVED sequence still accepts — tag finished campaigns to link their learnings across the workspace.")
|
|
1754
|
+
.option("--json", "Print a JSON envelope.")
|
|
1755
|
+
.action(async (sequence, options) => {
|
|
1756
|
+
await handleAsyncAction("sequences update", options, () => {
|
|
1757
|
+
const body = {};
|
|
1758
|
+
const name = readOption(options.name);
|
|
1759
|
+
if (name)
|
|
1760
|
+
body.name = name;
|
|
1761
|
+
// Whole-set replace; `--tags ""` clears. Distinct from the
|
|
1762
|
+
// omitted flag (undefined), which leaves tags untouched.
|
|
1763
|
+
if (options.tags !== undefined)
|
|
1764
|
+
body.tags = readCsvOption(options.tags);
|
|
1765
|
+
const stepsPath = readOption(options.stepsFile);
|
|
1766
|
+
if (stepsPath) {
|
|
1767
|
+
body.definition = readJsonFileValue(resolve(stepsPath), "--steps-file");
|
|
1768
|
+
}
|
|
1769
|
+
const channels = readCsvOption(options.channels);
|
|
1770
|
+
if (channels.length > 0)
|
|
1771
|
+
body.channels = channels;
|
|
1772
|
+
// The directly attached accounts, replaced as a set; `--senders ""`
|
|
1773
|
+
// sends [] to remove them (the selected senders keep theirs).
|
|
1774
|
+
if (options.senders !== undefined)
|
|
1775
|
+
body.senders = readCsvOption(options.senders);
|
|
1776
|
+
const sourceTable = readOption(options.sourceTable);
|
|
1777
|
+
if (sourceTable)
|
|
1778
|
+
body.source_table_id = sourceTable;
|
|
1779
|
+
// Explicit "" clears the LinkedIn mapping back to auto-detect, so
|
|
1780
|
+
// send whenever the flag was passed (not only when truthy).
|
|
1781
|
+
if (options.linkedinUrlColumnKey !== undefined) {
|
|
1782
|
+
body.linkedin_url_column_key = readOption(options.linkedinUrlColumnKey) ?? "";
|
|
1783
|
+
}
|
|
1784
|
+
const maxCredits = readCreditCeilingOrZero(options.maxCredits);
|
|
1785
|
+
if (maxCredits !== undefined)
|
|
1786
|
+
body.max_credits = maxCredits;
|
|
1787
|
+
const maxLiveSends = readPositiveInt(options.maxLiveSends);
|
|
1788
|
+
if (maxLiveSends !== undefined)
|
|
1789
|
+
body.max_live_sends = maxLiveSends;
|
|
1790
|
+
const settings = readSequenceSettings(options);
|
|
1791
|
+
if (settings)
|
|
1792
|
+
body.settings = settings;
|
|
1793
|
+
Object.assign(body, readUnifiedSequenceBody(options));
|
|
1794
|
+
// Tri-state tracking toggles: only send the key when a flag was
|
|
1795
|
+
// explicitly passed, so an unrelated update never flips tracking.
|
|
1796
|
+
if (options.trackingOpens !== undefined)
|
|
1797
|
+
body.tracking_opens = options.trackingOpens;
|
|
1798
|
+
if (options.trackingClicks !== undefined)
|
|
1799
|
+
body.tracking_clicks = options.trackingClicks;
|
|
1800
|
+
if (options.clearEmail) {
|
|
1801
|
+
body.email = null;
|
|
1802
|
+
}
|
|
1803
|
+
else {
|
|
1804
|
+
const email = readCampaignEmailBinding(options);
|
|
1805
|
+
if (email)
|
|
1806
|
+
body.email = email;
|
|
1807
|
+
}
|
|
1808
|
+
if (Object.keys(body).length === 0) {
|
|
1809
|
+
throw new Error("Provide at least one field to update (--name, --steps-file, --source-table, --channels, --senders, --tags, --email-*, --clear-email, --max-credits, --max-live-sends, --max-emails-per-mailbox-per-day, --schedule-* sending schedule, --send-window-file, --linkedin-* schedule, --company-reply-pause, --[no-]include-unsubscribe, --[no-]include-unsubscribe-link, --no-stop-on-bounce, --[no-]exclude-contacted, or --[no-]tracking-opens / --[no-]tracking-clicks).");
|
|
1810
|
+
}
|
|
1811
|
+
return requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}`, {
|
|
1812
|
+
method: "PATCH",
|
|
1813
|
+
body,
|
|
1814
|
+
});
|
|
1815
|
+
});
|
|
1816
|
+
}))
|
|
1817
|
+
.addCommand(new Command("get")
|
|
1818
|
+
.description("Get a sequence's definition, senders, status, and credit usage. --readiness adds the launch check — whether it CAN go live, why not, and the mailbox pool it would send from — without touching it; `sequences preview <sequence>` (a read; 0 credits) shows who sends each step (sender_journey).")
|
|
1819
|
+
.argument("<sequence>", "Sequence id or slug.")
|
|
1820
|
+
.option("--readiness", "Add launch_readiness: blockers (e.g. a missing send cap), warnings, and the resolved sending mailboxes. Read-only — sends nothing, changes nothing, spends nothing. Skips the LinkedIn rendered-copy scan, which stays in `sequences start` without --approved.")
|
|
1821
|
+
.option("--json", "Print a JSON envelope.")
|
|
1822
|
+
.action(async (sequence, options) => {
|
|
1823
|
+
await handleAsyncAction("sequences get", options, () => requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}${options.readiness ? "?include=readiness" : ""}`));
|
|
1824
|
+
}))
|
|
1825
|
+
.addCommand(new Command("enroll")
|
|
1826
|
+
.description("Enroll leads into a sequence. Enrolling spends no credits. An active sequence contacts the new leads on its next dispatch tick; a draft or paused one holds them until `sequences start`. --from-table enrolls every not-yet-enrolled row of the sequence's bound source table (up to 500 per run; the response's from_table.has_more says whether to run again) — for a LinkedIn sequence the profile is read from the bound table's URL column, where a full /in/ URL and a bare public handle carrying a hyphen or digit (ada-lovelace) both work, so no pre-resolved provider ids are needed; each handle Oxygen canonicalized is echoed in linkedin_handles_canonicalized (value + url) to check before start, and every row that still cannot be resolved is named in unresolved_linkedin_lead_details (row, value, reason) rather than only counted in unresolved_linkedin_leads. In a LinkedIn + email sequence a row with no usable profile but a recipient email is still enrolled for the email steps (its LinkedIn steps are skipped as no_linkedin_profile, a connection wait passes at once and a connection branch takes its not-connected path) and is listed in linkedin_unavailable_lead_details instead; unresolved_linkedin_leads names only rows that were not enrolled. --leads-file enrolls an explicit JSON list { leads: [...] }: for an email/WhatsApp sequence a lead identified only by an email (row_values.email) or phone (row_values.phone) is auto-filed as a row in the sequence's leads table (auto-creating and binding one if there is none), deduped by email/phone, so no table has to exist first (the table is returned as leads_table with a web_url); a LinkedIn + email sequence auto-files an email-only lead the same way; a LinkedIn lead needs a lead_provider_id, a lead_profile_url (a /in/ URL or a bare public handle with a hyphen or digit), or a table_row_id whose row carries one of those. A CRM-only lead needs a stable table_row_id or lead_provider_id in addition to mapped row_values; a HubSpot contact id used in crm_task associations identifies the destination record, not the Oxygen enrollment. 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 (and per email/phone for auto-filed leads). WhatsApp-only campaigns also enroll each normalized number once across all source rows and enrollment statuses; mixed campaigns keep other channels and skip duplicate WhatsApp actions. Duplicate protection defaults to this campaign; --whatsapp-duplicate-scope on create/update can additionally block identical copy across all campaigns. The org do-not-contact list is always enforced; --exclude-contacted and --suppress-list add further opt-in skips (reported under skipped_by_reason). Leads already owned by a sender account (from an earlier real send) are routed back to that same account; a lead owned by a sender NOT on this sequence is skipped (bound_to_other_sender) unless --ignore-sender-bindings. This enrolls into an OXYGEN Sequence; to put the same rows into a campaign you already run in Smartlead, Instantly, lemlist or HeyReach, use `sequences push-external`. esp.pool_scope identifies the active workspace inventory used for routing and strict_deferred_leads estimates; these estimates omit sequence sender restrictions, readiness and daily limits. A zero estimate does not guarantee readiness. Review sequences preview before launch.")
|
|
1827
|
+
.argument("<sequence>", "Sequence id or slug.")
|
|
1828
|
+
.option("--leads-file <path>", "Path to a JSON file: { \"leads\": [{ row_values: { email }, lead_name }] } for cold email; { lead_provider_id, lead_name, table_row_id, row_values } for LinkedIn/existing rows; CRM-only leads require table_row_id or a stable lead_provider_id alongside mapped row_values. A CRM association object id is not the enrollment identity. Exactly one of --leads-file or --from-table.")
|
|
1829
|
+
.option("--from-table", "Enroll every not-yet-enrolled row of the sequence's bound source table (up to 500 per run; re-run to continue). Exactly one of --leads-file or --from-table.")
|
|
1830
|
+
.option("--exclude-contacted", "Also skip leads any OTHER active sequence is already contacting (cross-campaign exclusion), for THIS enroll. Off by default; forces exclusion on even if the sequence's exclude_contacted default is off.")
|
|
1831
|
+
.option("--no-exclude-contacted", "Force-INCLUDE cross-campaign leads for this enroll even when the sequence's exclude_contacted default is on (per-call override).")
|
|
1832
|
+
.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).")
|
|
1833
|
+
.option("--ignore-sender-bindings", "Enroll leads even when they are already owned by a sender account outside this sequence's pool (overrides the one-person-one-sender guarantee). Off by default.")
|
|
1834
|
+
.option("--json", "Print a JSON envelope.")
|
|
1835
|
+
.action(async (sequence, options) => {
|
|
1836
|
+
await handleAsyncAction("sequences enroll", options, () => {
|
|
1837
|
+
const leadsPath = readOption(options.leadsFile);
|
|
1838
|
+
if (options.fromTable && leadsPath) {
|
|
1839
|
+
throw new Error("Provide exactly one of --leads-file or --from-table.");
|
|
1840
|
+
}
|
|
1841
|
+
if (!options.fromTable && !leadsPath) {
|
|
1842
|
+
throw new Error("Provide exactly one of --leads-file or --from-table.");
|
|
1843
|
+
}
|
|
1844
|
+
const suppressList = parseSuppressListOption(options.suppressList);
|
|
1845
|
+
const sharedFlags = {
|
|
1846
|
+
// Tri-state: --exclude-contacted (true) / --no-exclude-contacted (false)
|
|
1847
|
+
// / absent (undefined → the route falls back to the sequence's default).
|
|
1848
|
+
...(options.excludeContacted === undefined ? {} : { exclude_contacted: options.excludeContacted }),
|
|
1849
|
+
...(suppressList.length > 0 ? { suppress_list: suppressList } : {}),
|
|
1850
|
+
...(options.ignoreSenderBindings ? { ignore_sender_bindings: true } : {}),
|
|
1851
|
+
};
|
|
1852
|
+
if (options.fromTable) {
|
|
1853
|
+
return requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/enroll`, {
|
|
1854
|
+
method: "POST",
|
|
1855
|
+
body: { from_table: true, ...sharedFlags },
|
|
1856
|
+
});
|
|
1857
|
+
}
|
|
1858
|
+
const parsed = readJsonFileValue(resolve(leadsPath), "--leads-file");
|
|
1859
|
+
return requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/enroll`, {
|
|
1860
|
+
method: "POST",
|
|
1861
|
+
body: {
|
|
1862
|
+
leads: parsed.leads ?? [],
|
|
1863
|
+
...sharedFlags,
|
|
1864
|
+
},
|
|
1865
|
+
});
|
|
1866
|
+
});
|
|
1867
|
+
}))
|
|
1868
|
+
.addCommand(new Command("push-external")
|
|
1869
|
+
.description("Inspect a possible push from an Oxygen table into an existing Smartlead, Instantly, lemlist, or HeyReach campaign. The preview shows field mapping, sample leads, row counts, and whether the live push is available. An own-key campaign lookup carries Oxygen's quoted per-call platform fee after repricing, so an unapproved preview does not call the provider. When live_executable is false, use the provider's own importer or `oxygen sequences create --from-table`.")
|
|
1870
|
+
.requiredOption("--provider <provider>", "External sequencer: smartlead, instantly, lemlist, or heyreach.")
|
|
1871
|
+
.requiredOption("--campaign <name-or-id>", "The campaign that already exists in that tool. A name is resolved against your connected account (exact id, then exact name, then a unique partial match).")
|
|
1872
|
+
.requiredOption("--from-table <table>", "Table id or slug whose rows are pushed.")
|
|
1873
|
+
.option("--rows <row_ids>", "Comma-separated row ids to push. Defaults to the whole table.")
|
|
1874
|
+
.option("--excluded-rows <row_ids>", "Comma-separated row ids omitted from the whole-table/filter selection; cannot combine with --rows.")
|
|
1875
|
+
.option("--filter-json <json>", "Filter object or array selecting the rows, e.g. '{\"column\":\"email\",\"op\":\"is_not_null\"}'.")
|
|
1876
|
+
.option("--mode <mode>", "dry_run (default) or live.")
|
|
1877
|
+
.option("--approved", "Required for --mode live: confirms the external write after inspecting the dry run.")
|
|
1878
|
+
.option("--field-mapping-json <json>", "Override the detected column mapping, e.g. '{\"email\":\"work_email\",\"first_name\":\"fname\"}'. Keys: email, first_name, last_name, company, website, linkedin_url, phone, job_title.")
|
|
1879
|
+
.option("--sender-account <id>", "HeyReach only: the LinkedIn sender account id the leads are added against (list them with `oxygen tools run heyreach.linkedin_accounts_get_all --input-json '{}' --credential-mode user_api_key`).")
|
|
1880
|
+
.option("--connection-id <id>", "Specific integration connection id. Defaults to the workspace's active connection for that provider.")
|
|
1881
|
+
.option("--force", "Push rows again even when they already carry a successful push to this campaign.")
|
|
1882
|
+
.option("--json", "Print a JSON envelope.")
|
|
1883
|
+
.action(async (options) => {
|
|
1884
|
+
await handleSequenceWriteAction("sequences push-external", options, () => {
|
|
1885
|
+
const rows = readCsvOption(options.rows);
|
|
1886
|
+
const filterJson = readOption(options.filterJson);
|
|
1887
|
+
if (rows.length > 0 && filterJson) {
|
|
1888
|
+
throw new Error("Pass either --rows or --filter-json, not both.");
|
|
1889
|
+
}
|
|
1890
|
+
const excludedRowIds = splitCommaList(options.excludedRows);
|
|
1891
|
+
if (rows.length > 0 && excludedRowIds.length > 0)
|
|
1892
|
+
throw new OxygenError("invalid_selection", "--excluded-rows cannot combine with --rows.", { exitCode: 2 });
|
|
1893
|
+
const selection = rows.length > 0
|
|
1894
|
+
? { mode: "row_ids", row_ids: rows }
|
|
1895
|
+
: filterJson || excludedRowIds.length > 0
|
|
1896
|
+
? { mode: "all_matching", filters: filterJson ? JSON.parse(filterJson) : [], ...(excludedRowIds.length > 0 ? { excluded_row_ids: excludedRowIds } : {}) }
|
|
1897
|
+
: null;
|
|
1898
|
+
const fieldMappingJson = readOption(options.fieldMappingJson);
|
|
1899
|
+
return requestOxygen("/api/cli/sequences/push-external", {
|
|
1900
|
+
method: "POST",
|
|
1901
|
+
body: {
|
|
1902
|
+
provider: readOption(options.provider),
|
|
1903
|
+
campaign: readOption(options.campaign),
|
|
1904
|
+
table: readOption(options.fromTable),
|
|
1905
|
+
mode: readOption(options.mode) ?? "dry_run",
|
|
1906
|
+
...(selection ? { selection } : {}),
|
|
1907
|
+
...(options.approved ? { approved: true } : {}),
|
|
1908
|
+
...(options.force ? { force: true } : {}),
|
|
1909
|
+
...(readOption(options.connectionId) ? { connection_id: readOption(options.connectionId) } : {}),
|
|
1910
|
+
...(readOption(options.senderAccount) ? { sender_account_id: readOption(options.senderAccount) } : {}),
|
|
1911
|
+
...(fieldMappingJson ? { field_mapping: parseJsonObject(fieldMappingJson) } : {}),
|
|
1912
|
+
},
|
|
1913
|
+
});
|
|
1914
|
+
}, formatSequencePushExternal);
|
|
1915
|
+
}))
|
|
1916
|
+
.addCommand(new Command("start")
|
|
1917
|
+
.description("Preview or start a sequence. Without --approved this is the launch check and changes nothing: it returns launch_readiness (blockers and warnings), sender_journey (each step's From, candidate inboxes and assignment; no leads needed), rendered pending-lead LinkedIn/WhatsApp copy, character counts/limits, copy blockers, per-kind scope, sender/mailbox capacity, CRM readiness and safety caps. Email copy is not rendered; inspect its saved templates and unsubscribe settings with sequences get. Live start requires --approved; --max-credits is optional. Email/WhatsApp/CRM-task journeys additionally require --max-live-sends because their external actions cost 0 Oxygen credits. --dry-run starts the sequence in simulated mode (it leaves draft), with no send, provider campaign, CRM task or credits. Live start enables the standing reply → CRM automation only if it has never been configured. Planned counts include only leads each step's channel can reach; skipped_actions_by_reason and channel_coverage show the rest.")
|
|
1918
|
+
.argument("<sequence>", "Sequence id or slug.")
|
|
1919
|
+
.option("--approved", "Approve and activate live. Without this flag, returns a preview only, including copy_preview rendered samples and blockers.")
|
|
1920
|
+
.option("--max-credits <n>", "Optional positive credit ceiling. Omit for the free launch preview. Live start uses the saved ceiling when omitted; an unset ceiling means unbounded credits, not recipient approval.")
|
|
1921
|
+
.option("--max-live-sends <n>", "Total live-send and CRM-write ceiling across channels, including LinkedIn invites/messages (positive integer). Required for email, WhatsApp, or crm_task steps; optional for LinkedIn.")
|
|
1922
|
+
.option("--dry-run", "Activate the sequence (status draft → active, startedAt set) in dry-run mode: every step advances with simulated actions — no sends, CRM writes, provider calls, or credits. This is NOT the preview: `sequences start` with no flags is the side-effect-free launch preview. A later live start (--approved) restarts those leads from the first step so they are contacted for real, except deleted source rows, a person another live campaign contacts under exclude_contacted, and older duplicates of a person.")
|
|
1923
|
+
.option("--json", "Print a JSON envelope.")
|
|
1924
|
+
.action(async (sequence, options) => {
|
|
1925
|
+
await handleAsyncAction("sequences start", options, () => {
|
|
1926
|
+
const maxCredits = readPositiveNumber(options.maxCredits, "Omit --max-credits for the free launch preview. To set a live credit ceiling, pass a positive value after reviewing the preview; recipient approval remains separate.");
|
|
1927
|
+
const maxLiveSends = readPositiveInt(options.maxLiveSends);
|
|
1928
|
+
return requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/start`, {
|
|
1929
|
+
method: "POST",
|
|
1930
|
+
body: {
|
|
1931
|
+
...(options.dryRun ? { dry_run: true } : {}),
|
|
1932
|
+
...(options.approved ? { approved: true } : {}),
|
|
1933
|
+
...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
|
|
1934
|
+
...(maxLiveSends !== undefined ? { max_live_sends: maxLiveSends } : {}),
|
|
1935
|
+
},
|
|
1936
|
+
});
|
|
1937
|
+
});
|
|
1938
|
+
}))
|
|
1939
|
+
.addCommand(new Command("preview")
|
|
1940
|
+
.description("The free launch check, as a read: the same preview sequences start returns without --approved. It returns launch_readiness (blockers and warnings), sender_journey (who sends each step and how an inbox is picked), copy_preview (rendered pending-lead LinkedIn/WhatsApp copy, character counts and scan), caps and estimated credits. Email copy is not rendered here: `sequences render` shows it, and every A/B variant, for leads of the bound table. Changes nothing and costs 0 credits; it never starts, resumes or dry-runs the sequence. Start with sequences start <sequence> --approved after review. Planned counts include only leads each step's channel can reach; skipped_actions_by_reason, channel_coverage and copy_preview.skipped_leads show the rest.")
|
|
1941
|
+
.argument("<sequence>", "Sequence id or slug.")
|
|
1942
|
+
.option("--json", "Print a JSON envelope.")
|
|
1943
|
+
.action(async (sequence, options) => {
|
|
1944
|
+
await handleAsyncAction("sequences preview", options, () =>
|
|
1945
|
+
// The start route's preview branch: no approved, no dry_run, no ceilings.
|
|
1946
|
+
requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/start`, { method: "POST", body: {} }));
|
|
1947
|
+
}))
|
|
1948
|
+
.addCommand(new Command("render")
|
|
1949
|
+
.description("Render a sequence's copy as the web step editor's Preview does: every A/B variant of each step (email subject/body, LinkedIn note/message/InMail, WhatsApp, comments) for leads of the bound table, or sample values when it has none, signed by one eligible sender. Works on a draft with nobody enrolled. Read-only, 0 credits. Each lead gets one variant at send; this shows all of them side by side, and spintax shows one pick per lead.")
|
|
1950
|
+
.argument("<sequence>", "Sequence id or slug.")
|
|
1951
|
+
.option("--step <stepId>", "Render only this step.")
|
|
1952
|
+
.option("--variant <id>", "Render only this variant (a is the base step, then b, c, ...).")
|
|
1953
|
+
.option("--row <ids>", "Comma-separated table_row_ids of the bound table to render for (at most 10). Default: its first rows.")
|
|
1954
|
+
.option("--sender <profileId>", "Sign as this sender profile (one the step may send from; the response lists eligible_senders). Default: the first eligible.")
|
|
1955
|
+
.option("--limit <n>", "How many leads to render when --row is not given (1-10, default 3).")
|
|
1956
|
+
.option("--json", "Print a JSON envelope.")
|
|
1957
|
+
.action(async (sequence, options) => {
|
|
1958
|
+
await handleSequenceRenderAction(sequence, options);
|
|
1959
|
+
}))
|
|
1960
|
+
.addCommand(new Command("signal")
|
|
1961
|
+
.description("Record an external GTM signal (e.g. linkedin_connected, email_replied, company_raised_funds, job_change, web_visit, intent) onto a running sequence's enrollment(s) to drive signal-triggered branch/wait control. Target an enrollment by --enrollment or --lead (at least one is required); the server validates the signal name.")
|
|
1962
|
+
.argument("<sequence>", "Sequence id or slug.")
|
|
1963
|
+
.requiredOption("--signal <name>", "Signal to record: linkedin_connected, linkedin_replied, whatsapp_replied, email_sent, email_opened, email_clicked, email_replied, email_bounced, company_hiring, company_raised_funds, job_change, new_hire, web_visit, intent.")
|
|
1964
|
+
.option("--enrollment <id>", "Target enrollment id.")
|
|
1965
|
+
.option("--lead <provider_id>", "Target enrollment by its lead provider id.")
|
|
1966
|
+
.option("--json", "Print a JSON envelope.")
|
|
1967
|
+
.action(async (sequence, options) => {
|
|
1968
|
+
await handleSequenceSignalAction(sequence, options);
|
|
1969
|
+
}))
|
|
1970
|
+
.addCommand(new Command("duplicate")
|
|
1971
|
+
.description("Duplicate a sequence as a new DRAFT: copies the steps, settings, channels, source table, caps, tags and sender pool. Never copies enrollments, stats, A/B decisions or an Instantly email binding (re-bind with `sequences update` if the source used one; native email steps need no binding). The copy is inert until you `sequences start` it; this is how a started journey gets new steps.")
|
|
1972
|
+
.argument("<sequence>", "Source sequence id or slug.")
|
|
1973
|
+
.option("--name <name>", "Name for the copy. Defaults to '<source name> (copy)'.")
|
|
1974
|
+
.option("--slug <slug>", "Slug for the copy. Defaults to the source slug + '-copy' (then -copy-2, ...).")
|
|
1975
|
+
.option("--no-senders", "Do not copy the source's sender pool onto the copy.")
|
|
1976
|
+
.option("--json", "Print a JSON envelope.")
|
|
1977
|
+
.action(async (sequence, options) => {
|
|
1978
|
+
await handleSequenceDuplicateAction(sequence, options);
|
|
1979
|
+
}))
|
|
1980
|
+
.addCommand(new Command("pause")
|
|
1981
|
+
.description("Pause an active sequence (stops new dispatches; enrollments resume on un-pause).")
|
|
1982
|
+
.argument("<sequence>", "Sequence id or slug.")
|
|
1983
|
+
.option("--json", "Print a JSON envelope.")
|
|
1984
|
+
.action(async (sequence, options) => {
|
|
1985
|
+
await handleAsyncAction("sequences pause", options, () => requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/status`, {
|
|
1986
|
+
method: "POST", body: { status: "paused" },
|
|
1987
|
+
}));
|
|
1988
|
+
}))
|
|
1989
|
+
.addCommand(new Command("resume")
|
|
1990
|
+
.description("Resume a paused sequence. A campaign paused at its live send cap pauses again at once: raise the cap and resume in one step with `sequences start <sequence> --max-live-sends <n>` (preview, then --approved).")
|
|
1991
|
+
.argument("<sequence>", "Sequence id or slug.")
|
|
1992
|
+
.option("--json", "Print a JSON envelope.")
|
|
1993
|
+
.action(async (sequence, options) => {
|
|
1994
|
+
await handleAsyncAction("sequences resume", options, () => requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/status`, {
|
|
1995
|
+
method: "POST", body: { status: "active" },
|
|
1996
|
+
}));
|
|
1997
|
+
}))
|
|
1998
|
+
.addCommand(new Command("delete")
|
|
1999
|
+
.description("Permanently delete a sequence and everything it owns (enrollments, actions, sender links, schedules). To stop dispatch without deleting, use `sequences pause`; active sequences must be paused first. Runs without --approved to preview the blast radius; pass --approved to purge. The current CLI does not create new archived sequences; legacy archived sequences remain readable and taggable.")
|
|
2000
|
+
.argument("<sequence>", "Sequence id or slug.")
|
|
2001
|
+
.option("--approved", "Confirm the previewed permanent deletion.")
|
|
2002
|
+
.option("--json", "Print a JSON envelope.")
|
|
2003
|
+
.action(async (sequence, options) => {
|
|
2004
|
+
await handleAsyncAction("sequences delete", options, () => requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}${options.approved ? "?approved=true" : ""}`, { method: "DELETE" }));
|
|
2005
|
+
}))
|
|
2006
|
+
.addCommand(new Command("enrollments")
|
|
2007
|
+
.description("List per-lead enrollment state plus the latest action status, error, attempts, and retry time. --view compact answers 'when is each lead's next message due' per lead: lead, status, invite (accepted, pending, not_invited), accepted_at, current_step and next_due. next_due.summary says it in words, at_local/gives_up_at_local give the time in the campaign's timezone (its schedule's, else UTC) and at/gives_up_at in UTC. Reasons: step_scheduled (planned send time; held_by names what holds it, e.g. send_cap_reached), planner_due (when the planner next looks at the lead), not_started (begins when the campaign is started or resumed), nothing_scheduled (enrolled, no step will run), waiting_for_connection / waiting_for_signal (no send time; acceptance makes the next step due at once; gives_up_at is when the wait ends, and on_timeout says whether the lead then leaves or moves on), finished. A failed lead carries failure: the cause in words (cause_recorded false when OXYGEN never recorded one) and provider_contact (made; none = the step never started; nothing_sent; unknown). missing_source_row is true for a lead whose source-table row was deleted: still enrolled and contacted (remove with `contacts-remove --missing-source-rows`); null when the campaign has no live source table to check against, or the check failed. channels says which of the sequence's channels reach each lead: usable, and unavailable with a reason (no_linkedin_profile = enrolled for email only, its LinkedIn steps are skipped; no_recipient_email; no_phone_number).")
|
|
2008
|
+
.argument("<sequence>", "Sequence id or slug.")
|
|
2009
|
+
.option("--status <status>", "Filter by enrollment status (pending, active, waiting_connection, waiting_signal, replied, completed, stopped, failed, excluded). pending = enrolled, not started (next_due not_started); waiting_connection = invite sent, not accepted yet. Status is the lead's progress, not its invite: use --invite accepted for the leads that accepted.")
|
|
2010
|
+
.option("--step <n>", "Only leads currently on this step (0-based index, as in the sequence's steps).")
|
|
2011
|
+
.option("--invite <state>", "Only leads in this LinkedIn invite state: accepted (invited and connected), pending (invited, not accepted yet) or not_invited (no invite: already connected before, suppressed or email-only).")
|
|
2012
|
+
.option("--view <view>", "compact (default) or full (every enrollment field) (one short row per lead with next_due in words and local time).")
|
|
2013
|
+
.option("--order <order>", "created (default) or due: soonest next_due first, then waiting leads by gives_up_at, then leads with nothing due.")
|
|
2014
|
+
.option("--limit <n>", "Maximum enrollments to return (1-500).")
|
|
2015
|
+
.option("--offset <n>", "Skip this many enrollments; pass next_offset from the previous page to continue.")
|
|
2016
|
+
.option("--full", "Read every enrollment field (same as --view full).")
|
|
2017
|
+
.option("--json", "Print a JSON envelope.")
|
|
2018
|
+
.action(async (sequence, options) => {
|
|
2019
|
+
await handleAsyncAction("sequences enrollments", options, () => {
|
|
2020
|
+
const params = new URLSearchParams();
|
|
2021
|
+
const status = readOption(options.status);
|
|
2022
|
+
if (status)
|
|
2023
|
+
params.set("status", status);
|
|
2024
|
+
const step = readOption(options.step);
|
|
2025
|
+
if (step)
|
|
2026
|
+
params.set("step", step);
|
|
2027
|
+
const invite = readOption(options.invite);
|
|
2028
|
+
if (invite)
|
|
2029
|
+
params.set("invite", invite);
|
|
2030
|
+
const view = options.full ? "full" : readOption(options.view) ?? "compact";
|
|
2031
|
+
if (view)
|
|
2032
|
+
params.set("view", view);
|
|
2033
|
+
const order = readOption(options.order);
|
|
2034
|
+
if (order)
|
|
2035
|
+
params.set("order", order);
|
|
2036
|
+
const limit = readOption(options.limit);
|
|
2037
|
+
if (limit)
|
|
2038
|
+
params.set("limit", limit);
|
|
2039
|
+
const offset = readOption(options.offset);
|
|
2040
|
+
if (offset)
|
|
2041
|
+
params.set("offset", offset);
|
|
2042
|
+
const suffix = params.toString();
|
|
2043
|
+
return requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/enrollments${suffix ? `?${suffix}` : ""}`);
|
|
2044
|
+
});
|
|
2045
|
+
}))
|
|
2046
|
+
.addCommand(new Command("contacts")
|
|
2047
|
+
.description("List a launched campaign's table-backed contacts with full source variables, contact tags, and factual or assigned sender. A contact carries every tag it reached: contacted means an invite, message or email was sent (a profile visit is not), and a positive reply is also replied and contacted. Contacts removed from this campaign are hidden; source rows remain intact. missing_source_row_count (first page only) counts enrolled leads whose source-table row was deleted: still contacted, never listed (remove them with `contacts-remove --missing-source-rows`).")
|
|
2048
|
+
.argument("<sequence>", "Sequence id or slug.")
|
|
2049
|
+
.option("--contact-state <tags>", "Only contacts with any of these tags, comma-separated: not_contacted, contacted, connected, replied, positive_reply; or all.", "all")
|
|
2050
|
+
.option("--exclude-contact-state <tags>", "Hide contacts with any of these tags, comma-separated.")
|
|
2051
|
+
.option("--limit <n>", "Maximum contacts per page (1-500). Page with --cursor (next_cursor from the previous page) or --offset.", "100")
|
|
2052
|
+
.option("--cursor <c>", "Pagination cursor from the previous page's next_cursor.")
|
|
2053
|
+
.option("--offset <n>", "Skip this many contacts before the page (0-based); an absolute alternative to --cursor.")
|
|
2054
|
+
.option("--json", "Print a JSON envelope.")
|
|
2055
|
+
.action(async (sequence, options) => {
|
|
2056
|
+
await handleAsyncAction("sequences contacts", options, () => {
|
|
2057
|
+
const contactTags = ["not_contacted", "contacted", "connected", "replied", "positive_reply"];
|
|
2058
|
+
const readTags = (flag, raw) => {
|
|
2059
|
+
const tags = raw.split(",").map((tag) => tag.trim()).filter(Boolean);
|
|
2060
|
+
if (tags.length === 0 || tags.some((tag) => !contactTags.includes(tag))) {
|
|
2061
|
+
throw new Error(`${flag} takes comma-separated tags: ${contactTags.join(", ")}.`);
|
|
2062
|
+
}
|
|
2063
|
+
return tags.join(",");
|
|
2064
|
+
};
|
|
2065
|
+
const contactStateRaw = readOption(options.contactState) ?? "all";
|
|
2066
|
+
const contactState = contactStateRaw === "all" ? "all" : readTags("--contact-state", contactStateRaw);
|
|
2067
|
+
const excludeRaw = readOption(options.excludeContactState);
|
|
2068
|
+
const excludeContactState = excludeRaw ? readTags("--exclude-contact-state", excludeRaw) : null;
|
|
2069
|
+
const limit = readPositiveInt(options.limit);
|
|
2070
|
+
if (limit === undefined || limit > 500) {
|
|
2071
|
+
throw new Error("--limit must be between 1 and 500.");
|
|
2072
|
+
}
|
|
2073
|
+
const params = new URLSearchParams({
|
|
2074
|
+
contact_state: contactState,
|
|
2075
|
+
limit: String(limit),
|
|
2076
|
+
});
|
|
2077
|
+
if (excludeContactState)
|
|
2078
|
+
params.set("exclude_contact_state", excludeContactState);
|
|
2079
|
+
const cursor = readOption(options.cursor);
|
|
2080
|
+
if (cursor)
|
|
2081
|
+
params.set("cursor", cursor);
|
|
2082
|
+
const offsetRaw = readOption(options.offset);
|
|
2083
|
+
if (offsetRaw !== null) {
|
|
2084
|
+
const offset = Number(offsetRaw);
|
|
2085
|
+
if (!Number.isInteger(offset) || offset < 0) {
|
|
2086
|
+
throw new OxygenError("invalid_offset", "--offset must be a non-negative integer.", {
|
|
2087
|
+
details: { offset: offsetRaw },
|
|
2088
|
+
exitCode: 2,
|
|
2089
|
+
});
|
|
2090
|
+
}
|
|
2091
|
+
params.set("offset", String(offset));
|
|
2092
|
+
}
|
|
2093
|
+
return requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/contacts?${params.toString()}`);
|
|
2094
|
+
});
|
|
2095
|
+
}))
|
|
2096
|
+
.addCommand(new Command("contacts-remove")
|
|
2097
|
+
.description("Remove leads from this campaign only: selected source-table rows (--table-row-ids), or every enrolled lead whose source-table row was deleted (--missing-source-rows; deleting a row never removes its lead). Preview by default and names those leads; --approved stops pending enrollment work and queues delegated-provider cleanup while preserving source rows and historical analytics. Permanent: a removed lead cannot be re-enrolled in this campaign. Finished leads stay as they are, except one a dry run finished while the sequence is still in dry run, which a live start would otherwise restart.")
|
|
2098
|
+
.argument("<sequence>", "Sequence id or slug.")
|
|
2099
|
+
.option("--table-row-ids <ids>", "Comma-separated source table row UUIDs (maximum 500).")
|
|
2100
|
+
.option("--missing-source-rows", "Select enrolled leads whose source-table row was deleted (up to 500 per call, oldest first).")
|
|
2101
|
+
.option("--approved", "Apply the previewed campaign-only removal.")
|
|
2102
|
+
.option("--json", "Print a JSON envelope.")
|
|
2103
|
+
.action(async (sequence, options) => {
|
|
2104
|
+
await handleAsyncAction("sequences contacts-remove", options, async () => {
|
|
2105
|
+
const rowIdsOption = readOption(options.tableRowIds);
|
|
2106
|
+
if (Boolean(rowIdsOption) === (options.missingSourceRows === true)) {
|
|
2107
|
+
throw new OxygenError("invalid_request", "Pass exactly one of --table-row-ids <ids> or --missing-source-rows.", { exitCode: 2 });
|
|
2108
|
+
}
|
|
2109
|
+
let selector = { missing_source_rows: true };
|
|
2110
|
+
if (rowIdsOption) {
|
|
2111
|
+
const tableRowIds = rowIdsOption
|
|
2112
|
+
.split(",")
|
|
2113
|
+
.map((value) => value.trim())
|
|
2114
|
+
.filter(Boolean);
|
|
2115
|
+
if (tableRowIds.length === 0 || tableRowIds.length > 500) {
|
|
2116
|
+
throw new Error("--table-row-ids must contain between 1 and 500 row UUIDs.");
|
|
2117
|
+
}
|
|
2118
|
+
selector = { table_row_ids: tableRowIds };
|
|
2119
|
+
}
|
|
2120
|
+
const data = await requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/contacts`, {
|
|
2121
|
+
method: "POST",
|
|
2122
|
+
body: {
|
|
2123
|
+
...selector,
|
|
2124
|
+
approved: options.approved === true,
|
|
2125
|
+
},
|
|
2126
|
+
});
|
|
2127
|
+
if (!options.json)
|
|
2128
|
+
writeContactsRemoveSummary(data);
|
|
2129
|
+
return data;
|
|
2130
|
+
});
|
|
2131
|
+
}))
|
|
2132
|
+
.addCommand(new Command("pause-company")
|
|
2133
|
+
.description("Pause messaging to a company in your campaigns — e.g. after a meeting is booked — and resume it later. Holds every lead of that company (work-email domain, company domain, or LinkedIn company page) in every campaign, or only the --sequence ones; nothing is sent to them until `sequences resume-company`. --dry-run previews the leads it would hold.")
|
|
2134
|
+
.option("--domain <domain>", "The company's domain, e.g. acme.com.")
|
|
2135
|
+
.option("--email <email>", "Any work email at the company; its domain names the company (personal mail hosts such as gmail.com are refused).")
|
|
2136
|
+
.option("--linkedin-company <url>", "The company's LinkedIn page, e.g. https://www.linkedin.com/company/acme.")
|
|
2137
|
+
.option("--record <id>", "A CRM record id: a company; a person (their work-email domain and every linked company); or any record linked to companies, such as a deal.")
|
|
2138
|
+
.option("--sequence <id>", "Pause only in this campaign (id or slug). Repeat for several; omit to pause in every campaign.", collectRepeatable, [])
|
|
2139
|
+
.option("--note <text>", "Why it is paused, shown with the pause (max 500 characters).")
|
|
2140
|
+
.option("--dry-run", "Preview the campaigns and leads that would be held; change nothing.")
|
|
2141
|
+
.option("--json", "Print a JSON envelope.")
|
|
2142
|
+
.action(async (options) => {
|
|
2143
|
+
await handleAsyncAction("sequences pause-company", options, async () => {
|
|
2144
|
+
const sequenceIds = readRepeatableIds(options.sequence);
|
|
2145
|
+
const note = readOption(options.note);
|
|
2146
|
+
const data = await requestOxygen("/api/cli/sequences/company-pauses", {
|
|
2147
|
+
method: "POST",
|
|
2148
|
+
body: {
|
|
2149
|
+
...readCompanyIdentityOptions(options, "pause"),
|
|
2150
|
+
...(sequenceIds.length > 0 ? { sequence_ids: sequenceIds } : {}),
|
|
2151
|
+
...(note ? { note } : {}),
|
|
2152
|
+
...(options.dryRun ? { dry_run: true } : {}),
|
|
2153
|
+
},
|
|
2154
|
+
});
|
|
2155
|
+
if (!options.json)
|
|
2156
|
+
writeCompanyPauseSummary(data);
|
|
2157
|
+
return data;
|
|
2158
|
+
});
|
|
2159
|
+
}))
|
|
2160
|
+
.addCommand(new Command("resume-company")
|
|
2161
|
+
.description("Resume messaging to a paused company: its held leads continue where they stopped (due steps are spread over the next hour, waits keep their remaining time). Name one pause with --hold, or the company with one identity flag; --sequence narrows the resume to those campaigns.")
|
|
2162
|
+
.option("--hold <id>", "The pause to resume (`id` from `sequences paused-companies`).")
|
|
2163
|
+
.option("--domain <domain>", "The company's domain, e.g. acme.com.")
|
|
2164
|
+
.option("--email <email>", "Any work email at the company.")
|
|
2165
|
+
.option("--linkedin-company <url>", "The company's LinkedIn page URL.")
|
|
2166
|
+
.option("--record <id>", "A CRM company, person or deal record id.")
|
|
2167
|
+
.option("--sequence <id>", "Resume only in this campaign (id or slug). Repeatable.", collectRepeatable, [])
|
|
2168
|
+
.option("--json", "Print a JSON envelope.")
|
|
2169
|
+
.action(async (options) => {
|
|
2170
|
+
await handleAsyncAction("sequences resume-company", options, async () => {
|
|
2171
|
+
const hold = readOption(options.hold);
|
|
2172
|
+
if (hold && countCompanyIdentityOptions(options) > 0) {
|
|
2173
|
+
throw new OxygenError("company_identity_ambiguous", "Name the pause with --hold, or the company with one of --domain, --email, --linkedin-company, or --record — not both.", { exitCode: 2 });
|
|
2174
|
+
}
|
|
2175
|
+
const sequenceIds = readRepeatableIds(options.sequence);
|
|
2176
|
+
const data = await requestOxygen("/api/cli/sequences/company-pauses/resume", {
|
|
2177
|
+
method: "POST",
|
|
2178
|
+
body: {
|
|
2179
|
+
...(hold ? { hold_id: hold } : readCompanyIdentityOptions(options, "resume")),
|
|
2180
|
+
...(sequenceIds.length > 0 ? { sequence_ids: sequenceIds } : {}),
|
|
2181
|
+
},
|
|
2182
|
+
});
|
|
2183
|
+
if (!options.json)
|
|
2184
|
+
writeCompanyPauseSummary(data);
|
|
2185
|
+
return data;
|
|
2186
|
+
});
|
|
2187
|
+
}))
|
|
2188
|
+
.addCommand(new Command("paused-companies")
|
|
2189
|
+
.description("List the companies paused in your campaigns — by a reply (waiting for its classification, or classified into a paused status) or by hand/automation — with the leads each one holds.")
|
|
2190
|
+
.option("--sequence <id>", "Only pauses in this campaign (id or slug).")
|
|
2191
|
+
.option("--all", "Include resumed/released pauses (history).")
|
|
2192
|
+
.option("--limit <n>", "Maximum pauses to return (1-500, default 100).")
|
|
2193
|
+
.option("--json", "Print a JSON envelope.")
|
|
2194
|
+
.action(async (options) => {
|
|
2195
|
+
await handleAsyncAction("sequences paused-companies", options, async () => {
|
|
2196
|
+
const params = new URLSearchParams();
|
|
2197
|
+
const sequence = readOption(options.sequence);
|
|
2198
|
+
if (sequence)
|
|
2199
|
+
params.set("sequence_id", sequence);
|
|
2200
|
+
if (options.all)
|
|
2201
|
+
params.set("include_released", "true");
|
|
2202
|
+
const limitRaw = readOption(options.limit);
|
|
2203
|
+
if (limitRaw !== null) {
|
|
2204
|
+
const limit = readPositiveInt(limitRaw);
|
|
2205
|
+
if (limit === undefined || limit > 500)
|
|
2206
|
+
throw new Error("--limit must be between 1 and 500.");
|
|
2207
|
+
params.set("limit", String(limit));
|
|
2208
|
+
}
|
|
2209
|
+
const suffix = params.toString();
|
|
2210
|
+
const data = await requestOxygen(`/api/cli/sequences/company-pauses${suffix ? `?${suffix}` : ""}`);
|
|
2211
|
+
if (!options.json)
|
|
2212
|
+
writeCompanyPauseSummary(data);
|
|
2213
|
+
return data;
|
|
2214
|
+
});
|
|
2215
|
+
}))
|
|
2216
|
+
.addCommand(new Command("reconcile")
|
|
2217
|
+
.description("Safely reconcile one ambiguous LinkedIn invite/message from provider reads. Zero credits. Preview-only unless --approved; never resends.")
|
|
2218
|
+
.argument("<sequence>", "Sequence id or slug. The sequence must be paused.")
|
|
2219
|
+
.requiredOption("--action <id>", "Failed/canceled live action UUID to reconcile.")
|
|
2220
|
+
.option("--approved", "Apply an exact provider match to local state. No provider write or resend occurs.")
|
|
2221
|
+
.option("--json", "Print a JSON envelope.")
|
|
2222
|
+
.action(async (sequence, options) => {
|
|
2223
|
+
await handleAsyncAction("sequences reconcile", options, () => requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/reconcile`, {
|
|
2224
|
+
method: "POST",
|
|
2225
|
+
body: {
|
|
2226
|
+
action_id: readOption(options.action),
|
|
2227
|
+
approved: options.approved === true,
|
|
2228
|
+
},
|
|
2229
|
+
}));
|
|
2230
|
+
}))
|
|
2231
|
+
.addCommand(new Command("retry-deferred")
|
|
2232
|
+
.description("Preview or re-ready pending actions deferred by LinkedIn working hours or an email send window on a PAUSED sequence. Email actions refresh their snapshotted window from the current campaign settings. Applying never sends, spends credits, resumes the sequence, or bypasses window/spacing/quota/cap checks.")
|
|
2233
|
+
.argument("<sequence>", "Paused sequence id or slug.")
|
|
2234
|
+
.option("--limit <n>", "Maximum actions to preview/retry (1-500, default 100).")
|
|
2235
|
+
.option("--approved", "Apply the bounded local retry. The sequence remains paused.")
|
|
2236
|
+
.option("--json", "Print a JSON envelope.")
|
|
2237
|
+
.action(async (sequence, options) => {
|
|
2238
|
+
await handleAsyncAction("sequences retry-deferred", options, () => requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/retry-deferred`, {
|
|
2239
|
+
method: "POST",
|
|
2240
|
+
body: {
|
|
2241
|
+
approved: options.approved === true,
|
|
2242
|
+
...(readOption(options.limit)
|
|
2243
|
+
? { limit: readPositiveInt(options.limit) }
|
|
2244
|
+
: {}),
|
|
2245
|
+
},
|
|
2246
|
+
}));
|
|
2247
|
+
}))
|
|
2248
|
+
.addCommand(new Command("recover-capacity")
|
|
2249
|
+
.description("Preview definitive no-effect terminal failures that Oxygen can replay safely: provider-capacity denials and exact allowlisted request-format defects. Recover the signed scope only on a PAUSED Sequence. Recovery restores local action/enrollment state; it never sends, calls a provider, spends credits, or resumes dispatch.")
|
|
2250
|
+
.argument("<sequence>", "Sequence id or slug. Preview may run while active; apply requires paused.")
|
|
2251
|
+
.option("--limit <n>", "Maximum candidate failure rows to inspect (1-5000, default 500). A truncated preview cannot be approved.")
|
|
2252
|
+
.option("--approval-token <token>", "Exact unexpired token returned by the preview.")
|
|
2253
|
+
.option("--approved", "Apply the exact signed scope. Requires --approval-token; the Sequence remains paused.")
|
|
2254
|
+
.option("--json", "Print a JSON envelope.")
|
|
2255
|
+
.action(async (sequence, options) => {
|
|
2256
|
+
await handleAsyncAction("sequences recover-capacity", options, () => {
|
|
2257
|
+
const approvalToken = readOption(options.approvalToken);
|
|
2258
|
+
if (options.approved === true && !approvalToken) {
|
|
2259
|
+
throw new Error("--approval-token is required with --approved.");
|
|
2260
|
+
}
|
|
2261
|
+
if (options.approved !== true && approvalToken) {
|
|
2262
|
+
throw new Error("--approval-token may only be used with --approved.");
|
|
2263
|
+
}
|
|
2264
|
+
const limit = readOption(options.limit)
|
|
2265
|
+
? readPositiveInt(options.limit)
|
|
2266
|
+
: undefined;
|
|
2267
|
+
if (limit !== undefined && limit > 5_000) {
|
|
2268
|
+
throw new Error("--limit must be between 1 and 5000.");
|
|
2269
|
+
}
|
|
2270
|
+
return requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/recover-capacity`, {
|
|
2271
|
+
method: "POST",
|
|
2272
|
+
body: {
|
|
2273
|
+
approved: options.approved === true,
|
|
2274
|
+
...(approvalToken ? { approval_token: approvalToken } : {}),
|
|
2275
|
+
...(limit === undefined ? {} : { limit }),
|
|
2276
|
+
},
|
|
2277
|
+
});
|
|
2278
|
+
});
|
|
2279
|
+
}))
|
|
2280
|
+
.addCommand(new Command("stats")
|
|
2281
|
+
.description("Show one sequence's persisted launch status and current operational state separately, including reasons, open work, lifetime sent/terminal-failed/deferral counts, and the funnel. Today's completed sends, reservations and remaining capacity appear in the default output and daily_budget. operational.reasons carries why work is waiting (outside_send_window, mailbox_spacing, daily_send_cap_reached, ...). For a per-day send series use `sequences analytics --sequence <id> --range <window>`.")
|
|
2282
|
+
.argument("<sequence>", "Sequence id or slug.")
|
|
2283
|
+
.option("--json", "Print a JSON envelope.")
|
|
2284
|
+
.action(async (sequence, options) => {
|
|
2285
|
+
await handleReadActionWithLens("sequences stats", options, () => requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/stats`), formatSequenceStatsHealth);
|
|
2286
|
+
}))
|
|
2287
|
+
.addCommand(new Command("esp")
|
|
2288
|
+
.description("ESP breakdown of this sequence's email sending scope: which provider each enrolled lead's mail routes through (google/microsoft, plus named gateways like Proofpoint/Mimecast and non-matchable hosts), folded against your sending mailbox pool. Shows how many leads the CURRENT --esp-matching mode would defer before you turn it on. Resolves domains from MX + SPF (0 credits, DNS only) and caches the result, warming the send path. Preview before flipping strict/prefer.")
|
|
2289
|
+
.argument("<sequence>", "Sequence id or slug.")
|
|
2290
|
+
.option("--max-lookups <n>", "Cap uncached domains resolved over DNS this call (0-2000). The remainder is reported as domains_unresolved and resolves on the next call.")
|
|
2291
|
+
.option("--json", "Print a JSON envelope.")
|
|
2292
|
+
.action(async (sequence, options) => {
|
|
2293
|
+
await handleAsyncAction("sequences esp", options, () => {
|
|
2294
|
+
const query = options.maxLookups ? `?max_lookups=${encodeURIComponent(options.maxLookups)}` : "";
|
|
2295
|
+
return requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/esp${query}`);
|
|
2296
|
+
});
|
|
2297
|
+
}))
|
|
2298
|
+
.addCommand(new Command("events")
|
|
2299
|
+
.description("Chronological activity feed for a sequence (like Instantly's Sending Activities): sends, failures, deferrals, opens, clicks, replies, bounces, unsubscribes, and positive reply-status transitions, newest first. Email events include mailboxEmail and sendingDomain when durable evidence preserved them; missing identity remains null instead of being guessed. Keyset-paginated via --before.")
|
|
2300
|
+
.argument("<sequence>", "Sequence id or slug.")
|
|
2301
|
+
.option("--enrollment <id>", "Scope the feed to one enrollment id.")
|
|
2302
|
+
.option("--kind <list>", "Comma-separated event kinds to include (sent,failed,skipped,open,click,replied,bounced,unsubscribed,positive).")
|
|
2303
|
+
.option("--include-bots", "Include bot-attributed opens/clicks (excluded by default).")
|
|
2304
|
+
.option("--limit <n>", "Maximum events to return (1-200, default 50).")
|
|
2305
|
+
.option("--before <iso>", "Keyset cursor: only events strictly before this ISO timestamp (pass the previous page's next_before).")
|
|
2306
|
+
.option("--json", "Print a JSON envelope.")
|
|
2307
|
+
.action(async (sequence, options) => {
|
|
2308
|
+
await handleSequenceEventsAction(sequence, options);
|
|
2309
|
+
}))
|
|
2310
|
+
.addCommand(new Command("variants")
|
|
2311
|
+
.description("Generic per-step action/base-variant scoreboard for one sequence, plus native email by_mailbox, by_domain, reply types, rates, and explicit unattributed facts. This rollup is not the copy-only analytics.messagePerformance view; base rows can represent non-message actions, and campaign replies may lack step/variant provenance. Provider-owned campaigns appear only after normalization into Oxygen's native Sequence ledgers. Also returns reversible auto-winner state (paused variants + evidence); flags run or override/reset that state.")
|
|
2312
|
+
.argument("<sequence>", "Sequence id or slug.")
|
|
2313
|
+
.option("--auto-optimize", "Run the auto-winner now on every step with variants (the whole sequence): once each active variant clears its thresholds (default 100 sends and 5 replies), pause the variants converting below the leader, on the step's metric — reply by default, or the click/open its own auto_optimize names (click/open need native open/click tracking; opens are soft signals under Apple Mail Privacy Protection). Nothing is evaluated while the sequence's auto-winner is off. Reversible.")
|
|
2314
|
+
.option("--pause <variant>", "Manually pause one variant (requires --step). Overrides the auto-winner.")
|
|
2315
|
+
.option("--activate <variant>", "Manually reactivate one paused variant (requires --step).")
|
|
2316
|
+
.option("--reset [variant]", "Clear an auto/manual decision: one variant (with --step), a whole step (--step only), or the entire sequence (no value). Reactivates the affected variants.")
|
|
2317
|
+
.option("--step <stepId>", "Step id to scope --pause/--activate/--reset to.")
|
|
2318
|
+
.option("--json", "Print a JSON envelope.")
|
|
2319
|
+
.action(async (sequence, options) => {
|
|
2320
|
+
await handleSequenceVariantsAction(sequence, options);
|
|
2321
|
+
})));
|
|
2322
|
+
}
|