@dench.com/cli 2.8.3 → 2.8.4-staging.154
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/apps.ts +31 -3
- package/crm.ts +609 -86
- package/dench.ts +126 -21
- package/email.ts +255 -41
- package/lib/cli-args.ts +41 -0
- package/lib/command-registry.ts +13 -3
- package/lib/levenshtein.ts +52 -0
- package/package.json +1 -1
- package/tools.ts +98 -8
package/dench.ts
CHANGED
|
@@ -715,6 +715,10 @@ Auth:
|
|
|
715
715
|
Notes:
|
|
716
716
|
dench integrations is an alias for dench tool status.
|
|
717
717
|
dench tool connect prints the OAuth redirect URL for the human to open.
|
|
718
|
+
Slack is Dench's IN-HOUSE integration (never Composio): \`dench tool connect
|
|
719
|
+
slack\` prints the first-party install URL, and SLACK_* tools (send with
|
|
720
|
+
Block Kit blocks/buttons, read history, list channels/users) run through
|
|
721
|
+
the org's own Slack app and can post to / read any channel.
|
|
718
722
|
dench tool run output is redacted for display; pass --json for raw JSON.
|
|
719
723
|
Override the gateway base with DENCH_GATEWAY_URL or GATEWAY_URL.
|
|
720
724
|
`);
|
|
@@ -890,17 +894,20 @@ Usage:
|
|
|
890
894
|
|
|
891
895
|
Lists every app connected to this organization, from both brokers:
|
|
892
896
|
|
|
893
|
-
- Composio apps (
|
|
897
|
+
- Composio apps (GitHub, Notion, HubSpot, …), each with a
|
|
894
898
|
\`connectedAccountId\` you can pass to \`dench tool run --account\`.
|
|
895
|
-
- Gmail, Outlook, Calendar
|
|
896
|
-
|
|
897
|
-
run through the SAME \`dench tool
|
|
898
|
-
the slug and run it.
|
|
899
|
-
|
|
899
|
+
- Gmail, Outlook, Calendar, LinkedIn (\`source: "unipile"\`) and Slack
|
|
900
|
+
(\`source: "dench"\`), which Dench connects itself. These have no
|
|
901
|
+
connected-account id, but they run through the SAME \`dench tool
|
|
902
|
+
search\` / \`dench tool run\` — find the slug and run it. \`dench tool
|
|
903
|
+
connect slack\` prints the in-house Slack install URL (org admin);
|
|
904
|
+
a human connects the other four from the workspace Marketplace.
|
|
905
|
+
Slack is ALWAYS in-house (never Composio): SLACK_* tools can post
|
|
906
|
+
to / read any channel, with Block Kit blocks and buttons.
|
|
900
907
|
For reading, the CRM is usually better: calendar events sync into
|
|
901
908
|
\`calendar_event\` and recordings into \`meeting\`.
|
|
902
909
|
|
|
903
|
-
Composio does not broker those
|
|
910
|
+
Composio does not broker those five, so they appear ONLY here — a
|
|
904
911
|
missing calendar in this list means it is genuinely not connected.
|
|
905
912
|
|
|
906
913
|
Authenticates with the active \`dench signin\` agent session or
|
|
@@ -1135,15 +1142,18 @@ function backendSnapshot(config: ConfigFile) {
|
|
|
1135
1142
|
};
|
|
1136
1143
|
}
|
|
1137
1144
|
|
|
1145
|
+
function backendSourceHint(source: "flag" | "env" | "config" | "default") {
|
|
1146
|
+
return source === "flag"
|
|
1147
|
+
? "(set via CLI flag for this invocation)"
|
|
1148
|
+
: source === "env"
|
|
1149
|
+
? "(set via DENCH_HOST env var)"
|
|
1150
|
+
: source === "config"
|
|
1151
|
+
? "(set via `dench backend set`)"
|
|
1152
|
+
: "(default — no explicit choice in env or config)";
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1138
1155
|
function formatBackendShow(snapshot: ReturnType<typeof backendSnapshot>) {
|
|
1139
|
-
const sourceHint =
|
|
1140
|
-
snapshot.source === "flag"
|
|
1141
|
-
? "(set via CLI flag for this invocation)"
|
|
1142
|
-
: snapshot.source === "env"
|
|
1143
|
-
? "(set via DENCH_HOST env var)"
|
|
1144
|
-
: snapshot.source === "config"
|
|
1145
|
-
? "(set via `dench backend set`)"
|
|
1146
|
-
: "(default — no explicit choice in env or config)";
|
|
1156
|
+
const sourceHint = backendSourceHint(snapshot.source);
|
|
1147
1157
|
const lines = [
|
|
1148
1158
|
`Active backend: ${snapshot.host} [${snapshot.label}] ${sourceHint}`,
|
|
1149
1159
|
];
|
|
@@ -1462,6 +1472,43 @@ async function selectSession() {
|
|
|
1462
1472
|
}
|
|
1463
1473
|
|
|
1464
1474
|
const [entry] = matches;
|
|
1475
|
+
|
|
1476
|
+
// `dench use` only records WHICH session is selected — it never moves the
|
|
1477
|
+
// backend. If the machine's command target (flags > DENCH_HOST >
|
|
1478
|
+
// `dench backend set` > production default) points at a DIFFERENT host
|
|
1479
|
+
// than the selected session, every later command resolves that target
|
|
1480
|
+
// host, finds no session there, and fails `login_required` — while this
|
|
1481
|
+
// command would have reported success. That silent trap is exactly the
|
|
1482
|
+
// "use dench-com ok, context login_required, sessions all current:false"
|
|
1483
|
+
// incident (defaultBackend was still http://localhost:3000), so refuse
|
|
1484
|
+
// the no-op selection loudly instead.
|
|
1485
|
+
const snapshot = backendSnapshot(config);
|
|
1486
|
+
if (normalizeHost(snapshot.host) !== entry.host) {
|
|
1487
|
+
const alias =
|
|
1488
|
+
backendLabelForHost(entry.host) === "custom"
|
|
1489
|
+
? entry.host
|
|
1490
|
+
: backendLabelForHost(entry.host);
|
|
1491
|
+
throw new CliError(
|
|
1492
|
+
`Session ${entry.key} is saved for ${entry.host}, but dench commands currently target ${snapshot.host} ${backendSourceHint(snapshot.source)}. Selecting it would have no effect: commands on this backend would still fail with login_required.`,
|
|
1493
|
+
{
|
|
1494
|
+
code: "session_host_mismatch",
|
|
1495
|
+
sessionHost: entry.host,
|
|
1496
|
+
targetHost: snapshot.host,
|
|
1497
|
+
targetSource: snapshot.source,
|
|
1498
|
+
nextActions: [
|
|
1499
|
+
`Run dench backend set ${alias} to point commands at ${entry.host}, then re-run dench use ${selector}.`,
|
|
1500
|
+
...(snapshot.source === "env"
|
|
1501
|
+
? ["Or unset DENCH_HOST so the saved backend applies."]
|
|
1502
|
+
: []),
|
|
1503
|
+
...(snapshot.source === "flag"
|
|
1504
|
+
? ["Or drop the --host/--backend/--prod/--staging flag."]
|
|
1505
|
+
: []),
|
|
1506
|
+
`Or sign in on the current backend: dench signin --host ${snapshot.host}.`,
|
|
1507
|
+
],
|
|
1508
|
+
},
|
|
1509
|
+
);
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1465
1512
|
await saveConfig({
|
|
1466
1513
|
...withCurrentSessionSelection(config, {
|
|
1467
1514
|
host: entry.host,
|
|
@@ -2460,10 +2507,42 @@ function resolveApiHost(fallbackHost: string): string {
|
|
|
2460
2507
|
);
|
|
2461
2508
|
}
|
|
2462
2509
|
|
|
2463
|
-
function loginRequiredError(
|
|
2510
|
+
function loginRequiredError(
|
|
2511
|
+
command = "this command",
|
|
2512
|
+
context?: {
|
|
2513
|
+
targetHost: string;
|
|
2514
|
+
targetSource: "flag" | "env" | "config" | "default";
|
|
2515
|
+
otherHostSessions: Array<StoredSessionEntry<StoredSession>>;
|
|
2516
|
+
},
|
|
2517
|
+
) {
|
|
2518
|
+
// Saved sessions for a DIFFERENT host are the most common reason this
|
|
2519
|
+
// error is a lie ("requires dench signin" while three valid sessions sit
|
|
2520
|
+
// in the config): the backend target moved (usually a stale
|
|
2521
|
+
// `dench backend set local` or DENCH_HOST) after the sessions were saved.
|
|
2522
|
+
// Name that directly so agents stop burning turns re-authing.
|
|
2523
|
+
const mismatchActions =
|
|
2524
|
+
context && context.otherHostSessions.length > 0
|
|
2525
|
+
? [
|
|
2526
|
+
`You have ${plural(context.otherHostSessions.length, "saved Dench session")} for ${[
|
|
2527
|
+
...new Set(context.otherHostSessions.map((entry) => entry.host)),
|
|
2528
|
+
].join(
|
|
2529
|
+
", ",
|
|
2530
|
+
)}, but commands are targeting ${context.targetHost} ${backendSourceHint(context.targetSource)}.`,
|
|
2531
|
+
"Run dench backend show to inspect the target, then dench backend set <production|staging|local|url> to match the saved sessions.",
|
|
2532
|
+
]
|
|
2533
|
+
: [];
|
|
2464
2534
|
return new CliError(`${command} requires dench signin`, {
|
|
2465
2535
|
code: "login_required",
|
|
2536
|
+
...(context && context.otherHostSessions.length > 0
|
|
2537
|
+
? {
|
|
2538
|
+
targetHost: context.targetHost,
|
|
2539
|
+
savedSessionHosts: [
|
|
2540
|
+
...new Set(context.otherHostSessions.map((entry) => entry.host)),
|
|
2541
|
+
],
|
|
2542
|
+
}
|
|
2543
|
+
: {}),
|
|
2466
2544
|
nextActions: [
|
|
2545
|
+
...mismatchActions,
|
|
2467
2546
|
"Set DENCH_API_KEY (and CONVEX_URL) in your env if running inside a sandbox or CI.",
|
|
2468
2547
|
'Or run dench signin --kind <kind> --name "AI Agent - Project" to mint an agent session.',
|
|
2469
2548
|
"If already approved, run dench sessions --json, then dench use <session-key-or-workspace-slug>.",
|
|
@@ -2573,7 +2652,14 @@ async function getRuntime() {
|
|
|
2573
2652
|
};
|
|
2574
2653
|
}
|
|
2575
2654
|
|
|
2576
|
-
|
|
2655
|
+
const snapshot = backendSnapshot(config);
|
|
2656
|
+
throw loginRequiredError("this command", {
|
|
2657
|
+
targetHost: snapshot.host,
|
|
2658
|
+
targetSource: snapshot.source,
|
|
2659
|
+
otherHostSessions: listStoredSessionEntries(config).filter(
|
|
2660
|
+
(entry) => entry.host !== normalizeHost(host),
|
|
2661
|
+
),
|
|
2662
|
+
});
|
|
2577
2663
|
}
|
|
2578
2664
|
|
|
2579
2665
|
if (!hasDevEnv()) {
|
|
@@ -4643,14 +4729,33 @@ async function main() {
|
|
|
4643
4729
|
return;
|
|
4644
4730
|
}
|
|
4645
4731
|
const runtime = await getRuntime();
|
|
4646
|
-
|
|
4732
|
+
// `connect slack` never touches the gateway — it prints the in-house
|
|
4733
|
+
// install URL — so don't fail the whole command on gateway auth there.
|
|
4734
|
+
const isSlackConnect =
|
|
4735
|
+
sub === "connect" &&
|
|
4736
|
+
["slack", "slackbot"].includes(
|
|
4737
|
+
(filteredSubArgs[1] ?? "").trim().toLowerCase(),
|
|
4738
|
+
);
|
|
4739
|
+
const gatewayAuth = isSlackConnect
|
|
4740
|
+
? null
|
|
4741
|
+
: await resolveGatewayCommandAuth(runtime, "dench tool");
|
|
4647
4742
|
await runToolCommand({
|
|
4648
4743
|
args: filteredSubArgs,
|
|
4649
4744
|
jsonOutput: json,
|
|
4650
|
-
|
|
4651
|
-
|
|
4652
|
-
|
|
4745
|
+
...(gatewayAuth
|
|
4746
|
+
? {
|
|
4747
|
+
bearerToken: gatewayAuth.bearerToken,
|
|
4748
|
+
gatewayBaseUrl: gatewayAuth.gatewayBaseUrl,
|
|
4749
|
+
gatewayHeaders: gatewayAuth.gatewayHeaders,
|
|
4750
|
+
}
|
|
4751
|
+
: {}),
|
|
4653
4752
|
...unipileQueryAuth(runtime),
|
|
4753
|
+
...(runtime.mode !== "dev"
|
|
4754
|
+
? {
|
|
4755
|
+
appOrigin: runtime.host,
|
|
4756
|
+
workspaceSlug: runtime.organization?.slug,
|
|
4757
|
+
}
|
|
4758
|
+
: {}),
|
|
4654
4759
|
});
|
|
4655
4760
|
return;
|
|
4656
4761
|
}
|
package/email.ts
CHANGED
|
@@ -118,6 +118,134 @@ function requiredFlag(args: string[], name: string): string {
|
|
|
118
118
|
return value;
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
/** Default page cap for internally paginated list commands. */
|
|
122
|
+
export const DEFAULT_MAX_PAGES = 1;
|
|
123
|
+
const HARD_MAX_PAGES = 100;
|
|
124
|
+
|
|
125
|
+
export type PaginatedEnvelope = {
|
|
126
|
+
page: unknown[];
|
|
127
|
+
isDone: boolean;
|
|
128
|
+
continueCursor: string | null;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Drain a Convex `{page, isDone, continueCursor}` pagination envelope for up
|
|
133
|
+
* to `maxPages` pages. Aborts loudly (instead of looping forever) when the
|
|
134
|
+
* server returns the same `continueCursor` twice or omits it while
|
|
135
|
+
* `isDone` is false.
|
|
136
|
+
*/
|
|
137
|
+
export async function collectPaginated(
|
|
138
|
+
fetchPage: (cursor: string | null) => Promise<PaginatedEnvelope>,
|
|
139
|
+
options: { maxPages: number },
|
|
140
|
+
): Promise<{
|
|
141
|
+
rows: unknown[];
|
|
142
|
+
pagesFetched: number;
|
|
143
|
+
isDone: boolean;
|
|
144
|
+
continueCursor: string | null;
|
|
145
|
+
}> {
|
|
146
|
+
const maxPages = Math.max(
|
|
147
|
+
1,
|
|
148
|
+
Math.min(Math.floor(options.maxPages), HARD_MAX_PAGES),
|
|
149
|
+
);
|
|
150
|
+
const seenCursors = new Set<string>();
|
|
151
|
+
const rows: unknown[] = [];
|
|
152
|
+
let cursor: string | null = null;
|
|
153
|
+
let pagesFetched = 0;
|
|
154
|
+
let isDone = false;
|
|
155
|
+
while (pagesFetched < maxPages) {
|
|
156
|
+
const result = await fetchPage(cursor);
|
|
157
|
+
pagesFetched += 1;
|
|
158
|
+
if (Array.isArray(result?.page)) rows.push(...result.page);
|
|
159
|
+
isDone = result?.isDone === true;
|
|
160
|
+
const next =
|
|
161
|
+
typeof result?.continueCursor === "string" && result.continueCursor
|
|
162
|
+
? result.continueCursor
|
|
163
|
+
: null;
|
|
164
|
+
cursor = next;
|
|
165
|
+
if (isDone) break;
|
|
166
|
+
if (!next) {
|
|
167
|
+
throw new EmailCliError(
|
|
168
|
+
"Pagination error: server reported isDone=false but returned no continueCursor. Aborting instead of looping.",
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
if (seenCursors.has(next)) {
|
|
172
|
+
throw new EmailCliError(
|
|
173
|
+
`Pagination aborted: server returned the same continueCursor twice (${next.slice(0, 24)}…). Refusing to loop; re-run with a smaller --limit or report this.`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
seenCursors.add(next);
|
|
177
|
+
}
|
|
178
|
+
return { rows, pagesFetched, isDone, continueCursor: cursor };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function maxPagesFlag(args: string[]): number {
|
|
182
|
+
const raw = getFlag(args, "--max-pages");
|
|
183
|
+
if (raw === undefined) return DEFAULT_MAX_PAGES;
|
|
184
|
+
const parsed = Number(raw);
|
|
185
|
+
if (!Number.isFinite(parsed) || parsed < 1) {
|
|
186
|
+
throw new EmailCliError("--max-pages must be a positive integer");
|
|
187
|
+
}
|
|
188
|
+
return Math.floor(parsed);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
type ResolvedCampaignMatch = {
|
|
192
|
+
campaignId: string;
|
|
193
|
+
name: string;
|
|
194
|
+
status: string;
|
|
195
|
+
recipientCount: number;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
async function resolveCampaignByNameQuery(
|
|
199
|
+
ctx: EmailCliContext,
|
|
200
|
+
name: string,
|
|
201
|
+
): Promise<{ matchType: string; matches: ResolvedCampaignMatch[] }> {
|
|
202
|
+
const payload = (await callQuery(
|
|
203
|
+
ctx,
|
|
204
|
+
"functions/emailCampaigns:resolveCampaignByName",
|
|
205
|
+
{ name },
|
|
206
|
+
)) as JsonRecord;
|
|
207
|
+
return {
|
|
208
|
+
matchType:
|
|
209
|
+
typeof payload.matchType === "string" ? payload.matchType : "none",
|
|
210
|
+
matches: Array.isArray(payload.matches)
|
|
211
|
+
? (payload.matches as ResolvedCampaignMatch[])
|
|
212
|
+
: [],
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function formatCampaignCandidates(matches: ResolvedCampaignMatch[]): string {
|
|
217
|
+
return matches
|
|
218
|
+
.map(
|
|
219
|
+
(match) =>
|
|
220
|
+
` ${match.campaignId} ${JSON.stringify(match.name)} status=${match.status} recipients=${match.recipientCount}`,
|
|
221
|
+
)
|
|
222
|
+
.join("\n");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Resolve the target campaign for a subcommand: `--campaign <id>` wins;
|
|
227
|
+
* otherwise `--name "<campaign name>"` is resolved via
|
|
228
|
+
* resolveCampaignByName and must match exactly one campaign.
|
|
229
|
+
*/
|
|
230
|
+
async function resolveCampaignRef(ctx: EmailCliContext): Promise<string> {
|
|
231
|
+
const explicit = getFlag(ctx.args, "--campaign")?.trim();
|
|
232
|
+
if (explicit) return explicit;
|
|
233
|
+
const name = getFlag(ctx.args, "--name")?.trim();
|
|
234
|
+
if (!name) {
|
|
235
|
+
throw new EmailCliError('Pass --campaign <id> or --name "<campaign name>"');
|
|
236
|
+
}
|
|
237
|
+
const { matches } = await resolveCampaignByNameQuery(ctx, name);
|
|
238
|
+
if (matches.length === 1) return matches[0].campaignId;
|
|
239
|
+
if (matches.length === 0) {
|
|
240
|
+
throw new EmailCliError(
|
|
241
|
+
`No campaign matches name ${JSON.stringify(name)}. List campaigns with \`dench email campaign list\` or resolve with \`dench email campaign resolve --name "${name}"\`.`,
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
throw new EmailCliError(
|
|
245
|
+
`Campaign name ${JSON.stringify(name)} is ambiguous (${matches.length} matches). Pass --campaign <id> instead. Candidates:\n${formatCampaignCandidates(matches)}`,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
121
249
|
async function resolveMailboxAccountIds(
|
|
122
250
|
ctx: EmailCliContext,
|
|
123
251
|
mailbox: string,
|
|
@@ -249,14 +377,29 @@ Usage:
|
|
|
249
377
|
dench email template create --name "Intro" --subject "Hi {{firstName}}" --html-file body.html [--text-file body.txt] [--json]
|
|
250
378
|
dench email template preview --template <id> --data '{"firstName":"Ada"}' [--json]
|
|
251
379
|
dench email campaign create --name "Q2 outreach" (--mailbox you@gmail.com | --identity <identityId|fromEmail>) --template <id> [--json]
|
|
252
|
-
dench email campaign
|
|
253
|
-
dench email campaign
|
|
254
|
-
dench email campaign recipients
|
|
255
|
-
dench email campaign recipients
|
|
256
|
-
dench email campaign
|
|
257
|
-
dench email campaign
|
|
258
|
-
dench email campaign
|
|
259
|
-
dench email campaign
|
|
380
|
+
dench email campaign resolve --name "Q2 outreach" [--json] # find campaign ids by (fuzzy) name; never creates
|
|
381
|
+
dench email campaign list [--status sending] [--limit 25] [--max-pages 1] [--json]
|
|
382
|
+
dench email campaign recipients add (--campaign <id> | --name "<name>") --file recipients.jsonl [--allow-duplicates] [--allow-unlinked] [--json]
|
|
383
|
+
dench email campaign recipients list (--campaign <id> | --name "<name>") [--status queued] [--limit 50] [--max-pages 1] [--summary] [--json]
|
|
384
|
+
dench email campaign recipients add-from-object (--campaign <id> | --name "<name>") --object prospect --email-field "Email" --entries id1,id2 [--allow-duplicates] [--json]
|
|
385
|
+
dench email campaign preview (--campaign <id> | --name "<name>") [--sample 5] [--json]
|
|
386
|
+
dench email campaign submit (--campaign <id> | --name "<name>") [--json]
|
|
387
|
+
dench email campaign status (--campaign <id> | --name "<name>") [--json]
|
|
388
|
+
dench email campaign pause|resume|cancel (--campaign <id> | --name "<name>") [--json]
|
|
389
|
+
|
|
390
|
+
Campaign recipient safety (on by default):
|
|
391
|
+
• recipients add / add-from-object SKIP addresses another workspace campaign or
|
|
392
|
+
sequence already targets (duplicatesElsewhere) or already emailed — including
|
|
393
|
+
campaigns that have COMPLETED (skippedAlreadySent). --allow-duplicates opts out.
|
|
394
|
+
• recipients add rejects rows it cannot link to a CRM person/lead (rejected /
|
|
395
|
+
unlinkedRecipients) — unlinked engagement never syncs to the CRM. Prefer
|
|
396
|
+
recipients add-from-object, or pass --allow-unlinked to add them anyway.
|
|
397
|
+
|
|
398
|
+
Pagination: list commands return a {page, isDone, continueCursor} envelope.
|
|
399
|
+
--limit sets the page size; --max-pages (default 1, this command paginates
|
|
400
|
+
internally up to that many pages) drains further pages and aborts with an
|
|
401
|
+
error if the server ever repeats a continueCursor. When isDone=false, pass
|
|
402
|
+
a larger --max-pages to fetch more.
|
|
260
403
|
|
|
261
404
|
Sequences (automatic multi-step follow-ups):
|
|
262
405
|
dench email sequence create --name "New-customer follow-up" (--mailbox you@gmail.com | --identity <identityId|fromEmail>) [--stop-on-click] [--no-stop-on-reply] [--json]
|
|
@@ -816,20 +959,35 @@ async function runCampaign(ctx: EmailCliContext) {
|
|
|
816
959
|
);
|
|
817
960
|
return;
|
|
818
961
|
}
|
|
962
|
+
if (sub === "resolve") {
|
|
963
|
+
const name = requiredFlag(ctx.args, "--name");
|
|
964
|
+
const payload = await resolveCampaignByNameQuery(ctx, name);
|
|
965
|
+
const text =
|
|
966
|
+
payload.matches.length === 0
|
|
967
|
+
? `No campaign matches ${JSON.stringify(name)}.`
|
|
968
|
+
: `${payload.matches.length} match(es) [${payload.matchType}]:\n${formatCampaignCandidates(payload.matches)}`;
|
|
969
|
+
out(ctx, { ok: true, ...payload }, text);
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
819
972
|
if (sub === "list") {
|
|
820
973
|
const limitRaw = getFlag(ctx.args, "--limit");
|
|
821
974
|
const status = getFlag(ctx.args, "--status");
|
|
822
|
-
const
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
},
|
|
830
|
-
|
|
831
|
-
},
|
|
975
|
+
const maxPages = maxPagesFlag(ctx.args);
|
|
976
|
+
const numItems = limitRaw ? Number(limitRaw) : 25;
|
|
977
|
+
const result = await collectPaginated(
|
|
978
|
+
async (cursor) =>
|
|
979
|
+
(await callQuery(ctx, "functions/emailCampaigns:listCampaigns", {
|
|
980
|
+
paginationOpts: { numItems, cursor },
|
|
981
|
+
status,
|
|
982
|
+
})) as PaginatedEnvelope,
|
|
983
|
+
{ maxPages },
|
|
832
984
|
);
|
|
985
|
+
const payload = {
|
|
986
|
+
page: result.rows,
|
|
987
|
+
isDone: result.isDone,
|
|
988
|
+
continueCursor: result.continueCursor,
|
|
989
|
+
pagesFetched: result.pagesFetched,
|
|
990
|
+
};
|
|
833
991
|
out(ctx, payload, JSON.stringify(payload, null, 2));
|
|
834
992
|
return;
|
|
835
993
|
}
|
|
@@ -838,18 +996,27 @@ async function runCampaign(ctx: EmailCliContext) {
|
|
|
838
996
|
if (nested === "list") {
|
|
839
997
|
const limitRaw = getFlag(ctx.args, "--limit");
|
|
840
998
|
const status = getFlag(ctx.args, "--status");
|
|
841
|
-
const
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
999
|
+
const summary = hasFlag(ctx.args, "--summary");
|
|
1000
|
+
const maxPages = maxPagesFlag(ctx.args);
|
|
1001
|
+
const campaignId = await resolveCampaignRef(ctx);
|
|
1002
|
+
const numItems = limitRaw ? Number(limitRaw) : 50;
|
|
1003
|
+
const result = await collectPaginated(
|
|
1004
|
+
async (cursor) =>
|
|
1005
|
+
(await callQuery(ctx, "functions/emailCampaigns:listRecipients", {
|
|
1006
|
+
campaignId,
|
|
1007
|
+
paginationOpts: { numItems, cursor },
|
|
1008
|
+
status,
|
|
1009
|
+
// --summary skips the per-row CRM person attachment server-side.
|
|
1010
|
+
includePeople: summary ? false : undefined,
|
|
1011
|
+
})) as PaginatedEnvelope,
|
|
1012
|
+
{ maxPages },
|
|
852
1013
|
);
|
|
1014
|
+
const payload = {
|
|
1015
|
+
page: result.rows,
|
|
1016
|
+
isDone: result.isDone,
|
|
1017
|
+
continueCursor: result.continueCursor,
|
|
1018
|
+
pagesFetched: result.pagesFetched,
|
|
1019
|
+
};
|
|
853
1020
|
out(ctx, payload, JSON.stringify(payload, null, 2));
|
|
854
1021
|
return;
|
|
855
1022
|
}
|
|
@@ -858,10 +1025,18 @@ async function runCampaign(ctx: EmailCliContext) {
|
|
|
858
1025
|
ctx,
|
|
859
1026
|
"functions/emailCampaigns:addRecipientsFromCrm",
|
|
860
1027
|
{
|
|
861
|
-
campaignId:
|
|
1028
|
+
campaignId: await resolveCampaignRef(ctx),
|
|
862
1029
|
objectName: requiredFlag(ctx.args, "--object"),
|
|
863
1030
|
emailFieldName: getFlag(ctx.args, "--email-field"),
|
|
864
1031
|
entryIds: parseEntryIds(requiredFlag(ctx.args, "--entries")),
|
|
1032
|
+
// Cross-campaign duplicate + already-sent check is ON by default;
|
|
1033
|
+
// --allow-duplicates opts out.
|
|
1034
|
+
checkDuplicatesAcrossWorkspace: hasFlag(
|
|
1035
|
+
ctx.args,
|
|
1036
|
+
"--allow-duplicates",
|
|
1037
|
+
)
|
|
1038
|
+
? false
|
|
1039
|
+
: undefined,
|
|
865
1040
|
},
|
|
866
1041
|
);
|
|
867
1042
|
out(ctx, payload, JSON.stringify(payload, null, 2));
|
|
@@ -872,19 +1047,58 @@ async function runCampaign(ctx: EmailCliContext) {
|
|
|
872
1047
|
"Usage: dench email campaign recipients add|list|add-from-object",
|
|
873
1048
|
);
|
|
874
1049
|
}
|
|
875
|
-
const campaignId =
|
|
1050
|
+
const campaignId = await resolveCampaignRef(ctx);
|
|
876
1051
|
const file = getFlag(ctx.args, "--file");
|
|
877
1052
|
const rows = file
|
|
878
1053
|
? await readRecipients(file)
|
|
879
1054
|
: ((optionalJsonFlag(ctx.args, "--data") as JsonRecord[] | undefined) ??
|
|
880
1055
|
[]);
|
|
881
1056
|
if (rows.length === 0) throw new EmailCliError("No recipients provided");
|
|
882
|
-
const
|
|
1057
|
+
const allowUnlinked = hasFlag(ctx.args, "--allow-unlinked");
|
|
1058
|
+
const payload = (await callMutation(
|
|
883
1059
|
ctx,
|
|
884
1060
|
"functions/emailCampaigns:addRecipients",
|
|
885
|
-
{
|
|
886
|
-
|
|
887
|
-
|
|
1061
|
+
{
|
|
1062
|
+
campaignId,
|
|
1063
|
+
recipients: toRecipients(rows),
|
|
1064
|
+
// Cross-campaign duplicate + already-sent check is ON by default;
|
|
1065
|
+
// --allow-duplicates opts out.
|
|
1066
|
+
checkDuplicatesAcrossWorkspace: hasFlag(ctx.args, "--allow-duplicates")
|
|
1067
|
+
? false
|
|
1068
|
+
: undefined,
|
|
1069
|
+
allowUnlinked: allowUnlinked ? true : undefined,
|
|
1070
|
+
},
|
|
1071
|
+
)) as JsonRecord;
|
|
1072
|
+
const rejected = Array.isArray(payload.rejected)
|
|
1073
|
+
? (payload.rejected as string[])
|
|
1074
|
+
: [];
|
|
1075
|
+
const shaped: JsonRecord = {
|
|
1076
|
+
...payload,
|
|
1077
|
+
added: payload.added,
|
|
1078
|
+
skippedAlreadySent: payload.alreadySent ?? 0,
|
|
1079
|
+
skippedSuppressed: payload.suppressed ?? 0,
|
|
1080
|
+
skippedInvalid: payload.invalid ?? 0,
|
|
1081
|
+
skippedInCampaign: payload.duplicates ?? 0,
|
|
1082
|
+
skippedInOtherLiveCampaigns: payload.duplicatesElsewhere ?? 0,
|
|
1083
|
+
unlinkedRecipients: rejected.length,
|
|
1084
|
+
};
|
|
1085
|
+
const warnings: string[] = [];
|
|
1086
|
+
if (rejected.length > 0) {
|
|
1087
|
+
warnings.push(
|
|
1088
|
+
`WARNING: unlinkedRecipients: ${rejected.length} — these rows could not be linked to a CRM person/lead and were NOT added; engagement for unlinked recipients will NOT sync to the CRM. Prefer \`recipients add-from-object\` (CRM-linked) or re-run with --allow-unlinked to add them anyway.`,
|
|
1089
|
+
...rejected.slice(0, 20).map((email) => ` rejected: ${email}`),
|
|
1090
|
+
...(rejected.length > 20
|
|
1091
|
+
? [` … and ${rejected.length - 20} more`]
|
|
1092
|
+
: []),
|
|
1093
|
+
);
|
|
1094
|
+
}
|
|
1095
|
+
if ((shaped.skippedAlreadySent as number) > 0) {
|
|
1096
|
+
warnings.push(
|
|
1097
|
+
`Skipped ${shaped.skippedAlreadySent} recipient(s) already emailed by another campaign in this workspace (pass --allow-duplicates to re-send).`,
|
|
1098
|
+
);
|
|
1099
|
+
}
|
|
1100
|
+
if (warnings.length > 0) shaped.warnings = warnings;
|
|
1101
|
+
out(ctx, shaped, [JSON.stringify(shaped, null, 2), ...warnings].join("\n"));
|
|
888
1102
|
return;
|
|
889
1103
|
}
|
|
890
1104
|
if (sub === "preview") {
|
|
@@ -893,7 +1107,7 @@ async function runCampaign(ctx: EmailCliContext) {
|
|
|
893
1107
|
ctx,
|
|
894
1108
|
"functions/emailCampaigns:previewCampaign",
|
|
895
1109
|
{
|
|
896
|
-
campaignId:
|
|
1110
|
+
campaignId: await resolveCampaignRef(ctx),
|
|
897
1111
|
sample: sampleRaw ? Number(sampleRaw) : undefined,
|
|
898
1112
|
},
|
|
899
1113
|
);
|
|
@@ -905,19 +1119,19 @@ async function runCampaign(ctx: EmailCliContext) {
|
|
|
905
1119
|
ctx,
|
|
906
1120
|
"functions/emailCampaigns:submitCampaign",
|
|
907
1121
|
{
|
|
908
|
-
campaignId:
|
|
1122
|
+
campaignId: await resolveCampaignRef(ctx),
|
|
909
1123
|
approvalId: getFlag(ctx.args, "--approval"),
|
|
910
1124
|
},
|
|
911
1125
|
);
|
|
912
1126
|
out(ctx, payload, JSON.stringify(payload, null, 2));
|
|
913
1127
|
return;
|
|
914
1128
|
}
|
|
915
|
-
if (sub === "status") {
|
|
1129
|
+
if (sub === "status" || sub === "get") {
|
|
916
1130
|
const payload = await callQuery(
|
|
917
1131
|
ctx,
|
|
918
1132
|
"functions/emailCampaigns:getCampaign",
|
|
919
1133
|
{
|
|
920
|
-
campaignId:
|
|
1134
|
+
campaignId: await resolveCampaignRef(ctx),
|
|
921
1135
|
},
|
|
922
1136
|
);
|
|
923
1137
|
out(ctx, payload, JSON.stringify(payload, null, 2));
|
|
@@ -931,13 +1145,13 @@ async function runCampaign(ctx: EmailCliContext) {
|
|
|
931
1145
|
? "resumeCampaign"
|
|
932
1146
|
: "cancelCampaign";
|
|
933
1147
|
const payload = await callMutation(ctx, `functions/emailCampaigns:${fn}`, {
|
|
934
|
-
campaignId:
|
|
1148
|
+
campaignId: await resolveCampaignRef(ctx),
|
|
935
1149
|
});
|
|
936
1150
|
out(ctx, payload, JSON.stringify(payload, null, 2));
|
|
937
1151
|
return;
|
|
938
1152
|
}
|
|
939
1153
|
throw new EmailCliError(
|
|
940
|
-
"Usage: dench email campaign create|list|recipients|preview|submit|status|pause|resume|cancel",
|
|
1154
|
+
"Usage: dench email campaign create|resolve|list|recipients|preview|submit|status|pause|resume|cancel",
|
|
941
1155
|
);
|
|
942
1156
|
}
|
|
943
1157
|
|
package/lib/cli-args.ts
CHANGED
|
@@ -89,6 +89,47 @@ export function parseJson(value: string | undefined): unknown {
|
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
export type ParsedLimit = {
|
|
93
|
+
/** What the caller asked for (or the default when --limit was absent). */
|
|
94
|
+
requested: number;
|
|
95
|
+
/** What will actually be sent to the server (requested clamped to cap). */
|
|
96
|
+
effective: number;
|
|
97
|
+
/** True when requested exceeded the cap and was silently reduced. */
|
|
98
|
+
clamped: boolean;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Parse a `--limit` value against a server-side row cap.
|
|
103
|
+
*
|
|
104
|
+
* Servers clamp silently (e.g. `crm query` caps at 1000, `crm search`
|
|
105
|
+
* at 100), which historically let agents believe a `--limit 5000` call
|
|
106
|
+
* returned the full dataset — dedupe/count scripts then operated on a
|
|
107
|
+
* subset and deleted real rows. The caller MUST surface
|
|
108
|
+
* `clamped === true` to the user (stderr warning and/or a
|
|
109
|
+
* `limitClamped` field in JSON output).
|
|
110
|
+
*/
|
|
111
|
+
export function parseLimit(
|
|
112
|
+
raw: string | undefined,
|
|
113
|
+
opts: { defaultValue: number; cap: number; command: string },
|
|
114
|
+
): ParsedLimit {
|
|
115
|
+
if (raw === undefined) {
|
|
116
|
+
const effective = Math.min(opts.defaultValue, opts.cap);
|
|
117
|
+
return { requested: effective, effective, clamped: false };
|
|
118
|
+
}
|
|
119
|
+
const requested = Number.parseInt(raw, 10);
|
|
120
|
+
if (
|
|
121
|
+
!Number.isFinite(requested) ||
|
|
122
|
+
requested < 1 ||
|
|
123
|
+
String(requested) !== raw.trim()
|
|
124
|
+
) {
|
|
125
|
+
throw new CliArgError(
|
|
126
|
+
`--limit must be a positive integer for ${opts.command}, got "${raw}"`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
const effective = Math.min(requested, opts.cap);
|
|
130
|
+
return { requested, effective, clamped: effective < requested };
|
|
131
|
+
}
|
|
132
|
+
|
|
92
133
|
export function assertNoUnknownFlags(
|
|
93
134
|
args: readonly string[],
|
|
94
135
|
command: string,
|
package/lib/command-registry.ts
CHANGED
|
@@ -2549,7 +2549,8 @@ const rawApiOperations = [
|
|
|
2549
2549
|
group: "gateway",
|
|
2550
2550
|
summary:
|
|
2551
2551
|
"List every connected integration — Composio apps plus the Gmail, " +
|
|
2552
|
-
"Outlook, Calendar and
|
|
2552
|
+
"Outlook, Calendar, LinkedIn and Slack connections Dench brokers " +
|
|
2553
|
+
"itself (Slack rows report source: dench).",
|
|
2553
2554
|
cli: "dench integrations",
|
|
2554
2555
|
method: "GET",
|
|
2555
2556
|
path: "/integrations",
|
|
@@ -2659,7 +2660,11 @@ const rawApiOperations = [
|
|
|
2659
2660
|
op({
|
|
2660
2661
|
id: "tool.connect",
|
|
2661
2662
|
group: "gateway",
|
|
2662
|
-
summary:
|
|
2663
|
+
summary:
|
|
2664
|
+
"Create a tool OAuth connection link. Slack is in-house, never " +
|
|
2665
|
+
"Composio: for toolkit slack the CLI prints the first-party install " +
|
|
2666
|
+
"URL (/api/slack/install?slug=<workspace>, org admin) instead of " +
|
|
2667
|
+
"calling this endpoint.",
|
|
2663
2668
|
cli: "dench tool connect",
|
|
2664
2669
|
method: "POST",
|
|
2665
2670
|
path: "/tools/connections",
|
|
@@ -2681,7 +2686,12 @@ const rawApiOperations = [
|
|
|
2681
2686
|
op({
|
|
2682
2687
|
id: "tool.run",
|
|
2683
2688
|
group: "gateway",
|
|
2684
|
-
summary:
|
|
2689
|
+
summary:
|
|
2690
|
+
"Run an external tool action. SLACK_* slugs (SLACK_SEND_MESSAGE " +
|
|
2691
|
+
"with Block Kit blocks/buttons, SLACK_FETCH_HISTORY, " +
|
|
2692
|
+
"SLACK_LIST_CHANNELS, …) run through Dench's in-house Slack app " +
|
|
2693
|
+
"install — any channel in the connected workspace — and are always " +
|
|
2694
|
+
"preferred over Composio Slack.",
|
|
2685
2695
|
cli: "dench tool run",
|
|
2686
2696
|
method: "POST",
|
|
2687
2697
|
path: "/tools/run",
|