@dench.com/cli 2.8.3-staging.117 → 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/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 (Slack, GitHub, Notion, …), each with a
897
+ - Composio apps (GitHub, Notion, HubSpot, …), each with a
894
898
  \`connectedAccountId\` you can pass to \`dench tool run --account\`.
895
- - Gmail, Outlook, Calendar and LinkedIn, which Dench connects itself.
896
- These show \`source: "unipile"\` and no connected-account id, but they
897
- run through the SAME \`dench tool search\` / \`dench tool run\` — find
898
- the slug and run it. Only \`dench tool connect\` is Composio-only; a
899
- human connects these four from the workspace Marketplace.
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 four, so they appear ONLY here — a
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(command = "this command") {
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
- throw loginRequiredError();
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
- const gatewayAuth = await resolveGatewayCommandAuth(runtime, "dench tool");
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
- bearerToken: gatewayAuth.bearerToken,
4651
- gatewayBaseUrl: gatewayAuth.gatewayBaseUrl,
4652
- gatewayHeaders: gatewayAuth.gatewayHeaders,
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 list [--status sending] [--limit 25] [--json]
253
- dench email campaign recipients add --campaign <id> --file recipients.jsonl [--json]
254
- dench email campaign recipients list --campaign <id> [--status queued] [--limit 50] [--json]
255
- dench email campaign recipients add-from-object --campaign <id> --object prospect --email-field "Email" --entries id1,id2 [--json]
256
- dench email campaign preview --campaign <id> [--sample 5] [--json]
257
- dench email campaign submit --campaign <id> [--json]
258
- dench email campaign status --campaign <id> [--json]
259
- dench email campaign pause|resume|cancel --campaign <id> [--json]
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 payload = await callQuery(
823
- ctx,
824
- "functions/emailCampaigns:listCampaigns",
825
- {
826
- paginationOpts: {
827
- numItems: limitRaw ? Number(limitRaw) : 25,
828
- cursor: null,
829
- },
830
- status,
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 payload = await callQuery(
842
- ctx,
843
- "functions/emailCampaigns:listRecipients",
844
- {
845
- campaignId: requiredFlag(ctx.args, "--campaign"),
846
- paginationOpts: {
847
- numItems: limitRaw ? Number(limitRaw) : 50,
848
- cursor: null,
849
- },
850
- status,
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: requiredFlag(ctx.args, "--campaign"),
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 = requiredFlag(ctx.args, "--campaign");
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 payload = await callMutation(
1057
+ const allowUnlinked = hasFlag(ctx.args, "--allow-unlinked");
1058
+ const payload = (await callMutation(
883
1059
  ctx,
884
1060
  "functions/emailCampaigns:addRecipients",
885
- { campaignId, recipients: toRecipients(rows) },
886
- );
887
- out(ctx, payload, JSON.stringify(payload, null, 2));
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: requiredFlag(ctx.args, "--campaign"),
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: requiredFlag(ctx.args, "--campaign"),
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: requiredFlag(ctx.args, "--campaign"),
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: requiredFlag(ctx.args, "--campaign"),
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,
@@ -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 LinkedIn accounts Dench connects itself.",
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: "Create a tool OAuth connection link.",
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: "Run an external tool action.",
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",