@dench.com/cli 2.7.9-staging.86 → 2.7.11-staging.102

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
@@ -818,9 +818,23 @@ function integrationsHelp() {
818
818
  Usage:
819
819
  dench integrations [--json]
820
820
 
821
- Lists all external apps connected to this organization (Slack, Gmail,
822
- GitHub, etc.). Authenticates with the active \`dench signin\` agent
823
- session or DENCH_API_KEY (Bearer), whichever is set. Alias for
821
+ Lists every app connected to this organization, from both brokers:
822
+
823
+ - Composio apps (Slack, GitHub, Notion, …), each with a
824
+ \`connectedAccountId\` you can pass to \`dench tool run --account\`.
825
+ - Gmail, Outlook, Calendar and LinkedIn, which Dench connects itself.
826
+ These show \`source: "unipile"\` and no connected-account id, but they
827
+ run through the SAME \`dench tool search\` / \`dench tool run\` — find
828
+ the slug and run it. Only \`dench tool connect\` is Composio-only; a
829
+ human connects these four from the workspace Marketplace.
830
+ For reading, the CRM is usually better: calendar events sync into
831
+ \`calendar_event\` and recordings into \`meeting\`.
832
+
833
+ Composio does not broker those four, so they appear ONLY here — a
834
+ missing calendar in this list means it is genuinely not connected.
835
+
836
+ Authenticates with the active \`dench signin\` agent session or
837
+ DENCH_API_KEY (Bearer), whichever is set. Same output as
824
838
  \`dench tool status\`.
825
839
  `);
826
840
  }
@@ -2666,6 +2680,27 @@ function encodeDevCrmSessionToken(args: {
2666
2680
  return `dch_dev_${encodeURIComponent(args.workspaceSlug)}:${encodeURIComponent(args.devKey)}`;
2667
2681
  }
2668
2682
 
2683
+ /**
2684
+ * Convex credentials for the Unipile half of a connection listing.
2685
+ *
2686
+ * Gmail, Outlook, Calendar and LinkedIn are Dench's own connections and live in
2687
+ * Convex, so listing them needs a CRM bearer — not the gateway key that carries
2688
+ * the Composio half. Every runtime already holds both; this just picks the
2689
+ * shape `requireCrmReadAccess` accepts.
2690
+ */
2691
+ function unipileQueryAuth(runtime: Runtime): {
2692
+ convex: ConvexHttpClient;
2693
+ sessionToken: string;
2694
+ } {
2695
+ return {
2696
+ convex: runtime.client,
2697
+ sessionToken:
2698
+ runtime.mode === "dev"
2699
+ ? encodeDevCrmSessionToken(runtime.workspaceArgs)
2700
+ : runtime.sessionToken,
2701
+ };
2702
+ }
2703
+
2669
2704
  function asRecord(value: unknown): JsonRecord | undefined {
2670
2705
  if (!value || typeof value !== "object" || Array.isArray(value)) {
2671
2706
  return undefined;
@@ -2759,6 +2794,7 @@ async function connectedAppsContext(runtime: Runtime) {
2759
2794
  bearerToken: gatewayAuth.bearerToken,
2760
2795
  gatewayBaseUrl: gatewayAuth.gatewayBaseUrl,
2761
2796
  gatewayHeaders: gatewayAuth.gatewayHeaders,
2797
+ ...unipileQueryAuth(runtime),
2762
2798
  });
2763
2799
  } catch (error) {
2764
2800
  return {
@@ -2868,26 +2904,42 @@ function formatContext(context: JsonRecord) {
2868
2904
  }
2869
2905
 
2870
2906
  lines.push("", "Connected apps:");
2871
- if (connectedApps?.available === false) {
2907
+ // The two brokers fail independently. Listing whichever half we DID get, and
2908
+ // naming the half we did not, beats collapsing both into "unavailable" —
2909
+ // that is how a connected calendar came to read as absent.
2910
+ if (connections.length === 0) {
2872
2911
  lines.push(
2873
- ` unavailable: ${
2874
- stringField(connectedApps, "error", "reason") ?? "could not check apps"
2875
- }`,
2912
+ connectedApps?.available === false
2913
+ ? ` unavailable: ${
2914
+ stringField(connectedApps, "error", "reason") ??
2915
+ "could not check apps"
2916
+ }`
2917
+ : " none",
2876
2918
  );
2877
- } else if (connections.length === 0) {
2878
- lines.push(" none");
2879
2919
  } else {
2880
2920
  lines.push(
2881
- ...connections
2882
- .slice(0, 8)
2883
- .map(
2884
- (connection) =>
2885
- ` - ${stringField(connection, "toolkit") ?? "unknown"} [${stringField(connection, "status") ?? "unknown"}]${
2886
- stringField(connection, "accountLabel")
2887
- ? ` ${stringField(connection, "accountLabel")}`
2888
- : ""
2889
- }`,
2890
- ),
2921
+ ...connections.slice(0, 8).map((connection) => {
2922
+ const label = stringField(connection, "accountLabel");
2923
+ const via =
2924
+ stringField(connection, "source") === "unipile" ? " via Dench" : "";
2925
+ return ` - ${stringField(connection, "toolkit") ?? "unknown"} [${
2926
+ stringField(connection, "status") ?? "unknown"
2927
+ }]${label ? ` ${label}` : ""}${via}`;
2928
+ }),
2929
+ );
2930
+ if (connectedApps?.available === false) {
2931
+ lines.push(
2932
+ ` (could not check Composio apps: ${
2933
+ stringField(connectedApps, "error", "reason") ?? "unknown error"
2934
+ })`,
2935
+ );
2936
+ }
2937
+ }
2938
+ if (connectedApps?.unipileAvailable === false) {
2939
+ lines.push(
2940
+ ` (could not check Gmail/Outlook/Calendar/LinkedIn: ${
2941
+ stringField(connectedApps, "unipileError") ?? "unknown error"
2942
+ })`,
2891
2943
  );
2892
2944
  }
2893
2945
 
@@ -4337,8 +4389,10 @@ async function main() {
4337
4389
  return;
4338
4390
  }
4339
4391
 
4340
- // `dench integrations` — Composio connected apps (alias for `dench tool
4341
- // status`). Composio tool search/run/connect stay under `dench tool`.
4392
+ // `dench integrations` — every app connected to this workspace, from BOTH
4393
+ // brokers: Composio (via the gateway) and Dench's own Unipile connections
4394
+ // for Gmail, Outlook, Calendar and LinkedIn (via Convex). Composio tool
4395
+ // search/run/connect stay under `dench tool`.
4342
4396
  if (command === "integrations") {
4343
4397
  const runtime = await getRuntime();
4344
4398
  const gatewayAuth = await resolveGatewayCommandAuth(
@@ -4351,6 +4405,7 @@ async function main() {
4351
4405
  bearerToken: gatewayAuth.bearerToken,
4352
4406
  gatewayBaseUrl: gatewayAuth.gatewayBaseUrl,
4353
4407
  gatewayHeaders: gatewayAuth.gatewayHeaders,
4408
+ ...unipileQueryAuth(runtime),
4354
4409
  });
4355
4410
  return;
4356
4411
  }
@@ -4398,6 +4453,7 @@ async function main() {
4398
4453
  bearerToken: gatewayAuth.bearerToken,
4399
4454
  gatewayBaseUrl: gatewayAuth.gatewayBaseUrl,
4400
4455
  gatewayHeaders: gatewayAuth.gatewayHeaders,
4456
+ ...unipileQueryAuth(runtime),
4401
4457
  });
4402
4458
  return;
4403
4459
  }
package/email.ts CHANGED
@@ -230,14 +230,16 @@ export function emailHelp() {
230
230
  console.log(`dench email — outbound email from a connected inbox (or Dench Emailing Service)
231
231
 
232
232
  Usage:
233
- dench email identity verify --from founder@example.com [--json]
234
- dench email identity verify --domain example.com [--from founder@example.com] [--json]
233
+ dench email identity verify --from founder@example.com [--private] [--json]
234
+ dench email identity verify --domain example.com [--from founder@example.com] [--private] [--json]
235
235
  dench email identity list [--domain example.com] [--json]
236
236
  dench email identity dns --identity <identityId|domain|email> [--json]
237
237
  dench email identity status --identity founder@example.com [--json]
238
238
  dench email identity status --identity-id <identityId> [--json]
239
239
  dench email identity disable --identity <identityId|email|domain> [--json]
240
240
  dench email identity restore --identity <identityId|email|domain> [--json]
241
+ dench email identity share --identity <identityId|email> [--json]
242
+ dench email identity restrict --identity <identityId|email> [--json]
241
243
  dench email identity delete --identity <identityId|email|domain> [--json]
242
244
  dench email identity forward --identity <identityId|email> --to inbox@you.com [--json] # forward this sender's sequence replies
243
245
  dench email identity forward --identity <identityId|email> --off [--json] # turn forwarding off
@@ -269,7 +271,17 @@ Sequences (automatic multi-step follow-ups):
269
271
  dench email sequence enroll-from-object --sequence <id> --object prospect --email-field "Email" --entries id1,id2 [--json]
270
272
  dench email sequence enrollments list --sequence <id> [--status active] [--limit 50] [--json]
271
273
  dench email sequence enrollment pause|resume|stop --enrollment <id> [--json]
272
- dench email sequence archive --sequence <id> [--json]`);
274
+ dench email sequence archive --sequence <id> [--json]
275
+
276
+ Email + LinkedIn multichannel sequences:
277
+ dench email multichannel create --name "Founder outreach" [--timezone America/Los_Angeles] [--json]
278
+ dench email multichannel list [--all] [--json]
279
+ dench email multichannel status --sequence <id> [--json]
280
+ dench email multichannel flow --sequence <id> --file steps.json [--json]
281
+ dench email multichannel enroll --sequence <id> [--name Ada] [--email ada@x.com] [--linkedin-url https://linkedin.com/in/ada] [--json]
282
+ dench email multichannel enroll-from-object --sequence <id> --object lead --entries id1,id2 [--email-field Email] [--linkedin-field "POC LinkedIn URL"] [--relation-field Contact] [--reverse] [--allow-duplicates] [--json]
283
+ dench email multichannel launch --sequence <id> [--ack-linkedin-risk] [--json]
284
+ dench email multichannel pause|resume|archive --sequence <id> [--json]`);
273
285
  }
274
286
 
275
287
  export async function runEmailCommand(ctx: EmailCliContext): Promise<void> {
@@ -302,6 +314,10 @@ export async function runEmailCommand(ctx: EmailCliContext): Promise<void> {
302
314
  await runSequence(ctx);
303
315
  return;
304
316
  }
317
+ if (group === "multichannel") {
318
+ await runMultichannel(ctx);
319
+ return;
320
+ }
305
321
  throw new EmailCliError(`Unknown dench email subcommand: ${group}`);
306
322
  }
307
323
 
@@ -514,6 +530,7 @@ async function runIdentity(ctx: EmailCliContext) {
514
530
  fromName: getFlag(ctx.args, "--from-name"),
515
531
  replyToEmail: getFlag(ctx.args, "--reply-to"),
516
532
  configurationSetName,
533
+ ...(hasFlag(ctx.args, "--private") ? { sharing: "restricted" } : {}),
517
534
  },
518
535
  );
519
536
  const verification = (await callAction(
@@ -544,6 +561,7 @@ async function runIdentity(ctx: EmailCliContext) {
544
561
  `${identity.identityId} ${identity.fromEmail} type=${identity.type}` +
545
562
  `${identity.domain ? ` domain=${identity.domain}` : ""}` +
546
563
  ` delivery=${identity.deliveryVerificationStatus} workspace=${identity.denchVerificationStatus}` +
564
+ ` sharing=${identity.sharing ?? "org"}` +
547
565
  `${identity.forwardRepliesTo ? ` forward=${identity.forwardRepliesTo}` : ""}` +
548
566
  `${identity.disabled ? " DISABLED" : ""}`,
549
567
  )
@@ -551,6 +569,23 @@ async function runIdentity(ctx: EmailCliContext) {
551
569
  out(ctx, { ok: true, identities }, text);
552
570
  return;
553
571
  }
572
+ if (sub === "share" || sub === "restrict") {
573
+ const identity = requiredFlag(ctx.args, "--identity");
574
+ const sharing = sub === "restrict" ? "restricted" : "org";
575
+ const payload = await callMutation(
576
+ ctx,
577
+ "functions/emailCampaigns:setIdentitySharing",
578
+ { identity, sharing },
579
+ );
580
+ out(
581
+ ctx,
582
+ payload,
583
+ sharing === "restricted"
584
+ ? `Restricted ${identity} to its owner and workspace admins.`
585
+ : `Shared ${identity} with the workspace.`,
586
+ );
587
+ return;
588
+ }
554
589
  if (sub === "disable" || sub === "restore" || sub === "delete") {
555
590
  const identity = requiredFlag(ctx.args, "--identity");
556
591
  const payload = (await callMutation(
@@ -714,7 +749,7 @@ async function runIdentity(ctx: EmailCliContext) {
714
749
  return;
715
750
  }
716
751
  throw new EmailCliError(
717
- "Usage: dench email identity verify|list|dns|status|disable|restore|delete",
752
+ "Usage: dench email identity verify|list|dns|status|disable|restore|share|restrict|delete",
718
753
  );
719
754
  }
720
755
 
@@ -1127,3 +1162,150 @@ async function runSequence(ctx: EmailCliContext) {
1127
1162
  "Usage: dench email sequence create|update|activate|deactivate|archive|list|status|steps|enroll|enroll-from-object|enrollments|enrollment",
1128
1163
  );
1129
1164
  }
1165
+
1166
+ async function runMultichannel(ctx: EmailCliContext) {
1167
+ const sub = ctx.args.shift();
1168
+ if (sub === "create") {
1169
+ const payload = await callMutation(
1170
+ ctx,
1171
+ "functions/outreachSequences:createSequence",
1172
+ {
1173
+ name: requiredFlag(ctx.args, "--name"),
1174
+ timezone:
1175
+ getFlag(ctx.args, "--timezone") ??
1176
+ Intl.DateTimeFormat().resolvedOptions().timeZone,
1177
+ },
1178
+ );
1179
+ out(ctx, payload, JSON.stringify(payload, null, 2));
1180
+ return;
1181
+ }
1182
+ if (sub === "list") {
1183
+ const payload = await callQuery(
1184
+ ctx,
1185
+ "functions/outreachSequences:listSequences",
1186
+ { includeArchived: hasFlag(ctx.args, "--all") },
1187
+ );
1188
+ out(ctx, payload, JSON.stringify(payload, null, 2));
1189
+ return;
1190
+ }
1191
+ if (sub === "status") {
1192
+ const sequenceId = requiredFlag(ctx.args, "--sequence");
1193
+ const [detail, analytics] = await Promise.all([
1194
+ callQuery(ctx, "functions/outreachSequences:getSequence", { sequenceId }),
1195
+ callQuery(ctx, "functions/outreachSequences:getSequenceAnalytics", {
1196
+ sequenceId,
1197
+ }),
1198
+ ]);
1199
+ const payload = { detail, analytics };
1200
+ out(ctx, payload, JSON.stringify(payload, null, 2));
1201
+ return;
1202
+ }
1203
+ if (sub === "flow") {
1204
+ const raw = await readFile(requiredFlag(ctx.args, "--file"), "utf8");
1205
+ const steps = JSON.parse(raw);
1206
+ if (!Array.isArray(steps)) {
1207
+ throw new EmailCliError("Flow file must contain a JSON array of steps");
1208
+ }
1209
+ const payload = await callMutation(
1210
+ ctx,
1211
+ "functions/outreachSequences:replaceSteps",
1212
+ {
1213
+ sequenceId: requiredFlag(ctx.args, "--sequence"),
1214
+ steps,
1215
+ },
1216
+ );
1217
+ out(ctx, payload, JSON.stringify(payload, null, 2));
1218
+ return;
1219
+ }
1220
+ if (sub === "enroll") {
1221
+ const email = getFlag(ctx.args, "--email");
1222
+ const linkedinProfileUrl = getFlag(ctx.args, "--linkedin-url");
1223
+ if (!email && !linkedinProfileUrl) {
1224
+ throw new EmailCliError("Pass --email and/or --linkedin-url");
1225
+ }
1226
+ const payload = await callMutation(
1227
+ ctx,
1228
+ "functions/outreachSequences:addEnrollment",
1229
+ {
1230
+ sequenceId: requiredFlag(ctx.args, "--sequence"),
1231
+ displayName: getFlag(ctx.args, "--name"),
1232
+ email,
1233
+ linkedinProfileUrl,
1234
+ checkDuplicatesAcrossWorkspace: !hasFlag(
1235
+ ctx.args,
1236
+ "--allow-duplicates",
1237
+ ),
1238
+ },
1239
+ );
1240
+ out(ctx, payload, JSON.stringify(payload, null, 2));
1241
+ return;
1242
+ }
1243
+ if (sub === "enroll-from-object") {
1244
+ const entryIds = parseEntryIds(requiredFlag(ctx.args, "--entries"));
1245
+ if (entryIds.length === 0) {
1246
+ throw new EmailCliError("Pass at least one CRM entry id in --entries");
1247
+ }
1248
+ const payload = await callMutation(
1249
+ ctx,
1250
+ "functions/outreachSequences:enrollFromCrm",
1251
+ {
1252
+ sequenceId: requiredFlag(ctx.args, "--sequence"),
1253
+ objectName: requiredFlag(ctx.args, "--object"),
1254
+ emailFieldName: getFlag(ctx.args, "--email-field"),
1255
+ linkedinUrlFieldName: getFlag(ctx.args, "--linkedin-field"),
1256
+ relationFieldName: getFlag(ctx.args, "--relation-field"),
1257
+ relationDirection: hasFlag(ctx.args, "--reverse")
1258
+ ? "reverse"
1259
+ : undefined,
1260
+ entryIds,
1261
+ checkDuplicatesAcrossWorkspace: !hasFlag(
1262
+ ctx.args,
1263
+ "--allow-duplicates",
1264
+ ),
1265
+ },
1266
+ );
1267
+ out(ctx, payload, JSON.stringify(payload, null, 2));
1268
+ return;
1269
+ }
1270
+ if (sub === "launch") {
1271
+ const sequenceId = requiredFlag(ctx.args, "--sequence");
1272
+ const preview = (await callQuery(
1273
+ ctx,
1274
+ "functions/outreachSequences:previewLaunch",
1275
+ { sequenceId },
1276
+ )) as JsonRecord;
1277
+ if (preview.ready !== true || typeof preview.fingerprint !== "string") {
1278
+ out(ctx, preview, JSON.stringify(preview, null, 2));
1279
+ throw new EmailCliError("Sequence is not ready to launch");
1280
+ }
1281
+ const payload = await callMutation(
1282
+ ctx,
1283
+ "functions/outreachSequences:launch",
1284
+ {
1285
+ sequenceId,
1286
+ fingerprint: preview.fingerprint,
1287
+ linkedinRiskAcknowledged: hasFlag(ctx.args, "--ack-linkedin-risk"),
1288
+ },
1289
+ );
1290
+ out(ctx, payload, JSON.stringify(payload, null, 2));
1291
+ return;
1292
+ }
1293
+ if (sub === "pause" || sub === "resume" || sub === "archive") {
1294
+ const fn =
1295
+ sub === "pause"
1296
+ ? "pauseSequence"
1297
+ : sub === "resume"
1298
+ ? "resumeSequence"
1299
+ : "archiveSequence";
1300
+ const payload = await callMutation(
1301
+ ctx,
1302
+ `functions/outreachSequences:${fn}`,
1303
+ { sequenceId: requiredFlag(ctx.args, "--sequence") },
1304
+ );
1305
+ out(ctx, payload, JSON.stringify(payload, null, 2));
1306
+ return;
1307
+ }
1308
+ throw new EmailCliError(
1309
+ "Usage: dench email multichannel create|list|status|flow|enroll|enroll-from-object|launch|pause|resume|archive",
1310
+ );
1311
+ }
@@ -31,6 +31,7 @@ export type CustomOperation = {
31
31
  | "chatFollow"
32
32
  | "cronDelete"
33
33
  | "cronRunNow"
34
+ | "connectionsList"
34
35
  | "toolDisconnect"
35
36
  | "billingTopup"
36
37
  | "upgrade"
@@ -780,7 +781,7 @@ const rawApiOperations = [
780
781
  id: "crm.sidebar.pins.set",
781
782
  group: "crm",
782
783
  summary:
783
- "Pin (pinned true) or unpin a Library row — or a whole group — on the home-level Pinned shortcut strip (the \"tab on the left\"). Does not move the row inside Library. Workspace-shared; open to any member.",
784
+ 'Pin (pinned true) or unpin a Library row — or a whole group — on the home-level Pinned shortcut strip (the "tab on the left"). Does not move the row inside Library. Workspace-shared; open to any member.',
784
785
  cli: "dench crm sidebar pin",
785
786
  aliases: ["dench crm sidebar unpin"],
786
787
  method: "POST",
@@ -2377,13 +2378,15 @@ const rawApiOperations = [
2377
2378
  op({
2378
2379
  id: "integrations.list",
2379
2380
  group: "gateway",
2380
- summary: "List connected Composio integrations.",
2381
+ summary:
2382
+ "List every connected integration — Composio apps plus the Gmail, " +
2383
+ "Outlook, Calendar and LinkedIn accounts Dench connects itself.",
2381
2384
  cli: "dench integrations",
2382
2385
  method: "GET",
2383
2386
  path: "/integrations",
2384
2387
  requestSchema: noBody,
2385
2388
  responseSchema: anyResponse,
2386
- backend: gateway("GET", "/composio/connections"),
2389
+ backend: custom("connectionsList", "bearer"),
2387
2390
  }),
2388
2391
  op({
2389
2392
  id: "apps.list",
@@ -2482,7 +2485,7 @@ const rawApiOperations = [
2482
2485
  path: "/tools/status",
2483
2486
  requestSchema: noBody,
2484
2487
  responseSchema: anyResponse,
2485
- backend: gateway("GET", "/composio/connections"),
2488
+ backend: custom("connectionsList", "bearer"),
2486
2489
  }),
2487
2490
  op({
2488
2491
  id: "tool.connect",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dench.com/cli",
3
- "version": "2.7.9-staging.86",
3
+ "version": "2.7.11-staging.102",
4
4
  "description": "Dench agent workspace CLI.",
5
5
  "type": "module",
6
6
  "bin": {
package/tools.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `dench tool` and `dench apps` — direct Dench Cloud Gateway access for
2
+ * `dench tool` and `dench integrations` — direct Dench Cloud Gateway access for
3
3
  * Composio's get-apps / get-tools / execute-tool surface.
4
4
  *
5
5
  * Mirrors `denchclaw`'s pattern: this CLI is a thin wrapper over five
@@ -7,10 +7,15 @@
7
7
  * paid-workspace gateway key resolved from the active agent session by
8
8
  * the top-level dispatcher.
9
9
  *
10
+ * The one exception is the connection LISTING, which is not a pure gateway
11
+ * passthrough: Gmail, Outlook, Calendar and LinkedIn are brokered by Dench
12
+ * through Unipile, not Composio, so `status` also reads them from Convex and
13
+ * merges the two into one list. See `fetchUnipileConnections`.
14
+ *
10
15
  * Subcommands:
11
16
  *
12
- * dench apps [--json] — list connected apps
13
- * dench tool status [toolkit?] [--json] — list connections (optionally narrowed)
17
+ * dench integrations [--json] — every connection, both brokers
18
+ * dench tool status [toolkit?] [--json] — same list (optionally narrowed)
14
19
  * dench tool search "<query>" [--toolkit <slug>] [--limit N] [--compact] [--json]
15
20
  * dench tool run <slug> [--args <json>] [--account <id>] [--json]
16
21
  * dench tool connect <toolkit> [--callback-url <url>] [--json] — print OAuth redirect URL
@@ -32,6 +37,8 @@
32
37
  * working.
33
38
  */
34
39
 
40
+ import type { ConvexHttpClient } from "convex/browser";
41
+ import { makeFunctionReference } from "convex/server";
35
42
  import { PRODUCTION_API_URL, PRODUCTION_GATEWAY_URL } from "./host";
36
43
  import {
37
44
  CliArgError,
@@ -51,6 +58,13 @@ export type ToolCliContext = {
51
58
  bearerToken?: string;
52
59
  gatewayBaseUrl?: string;
53
60
  gatewayHeaders?: Record<string, string>;
61
+ /**
62
+ * Convex client + bearer for the Unipile half of the list. Absent on the
63
+ * help path and whenever the caller could not be authenticated, in which
64
+ * case the command still runs and reports Composio only.
65
+ */
66
+ convex?: ConvexHttpClient;
67
+ sessionToken?: string;
54
68
  };
55
69
 
56
70
  type GatewayAuthContext = Pick<
@@ -58,6 +72,8 @@ type GatewayAuthContext = Pick<
58
72
  "bearerToken" | "gatewayBaseUrl" | "gatewayHeaders"
59
73
  >;
60
74
 
75
+ type UnipileAuthContext = Pick<ToolCliContext, "convex" | "sessionToken">;
76
+
61
77
  export async function runToolCommand(ctx: ToolCliContext): Promise<void> {
62
78
  const sub = ctx.args[0];
63
79
  if (!sub || sub === "help" || sub === "--help" || sub === "-h") {
@@ -90,48 +106,147 @@ export async function runToolCommand(ctx: ToolCliContext): Promise<void> {
90
106
  throw new ToolCliError(`Unknown dench tool subcommand: ${sub}`);
91
107
  }
92
108
 
109
+ const listUnipileConnectionsRef = makeFunctionReference<"query">(
110
+ "functions/unipileConnections:listConnections",
111
+ );
112
+ const listUnipileToolsRef = makeFunctionReference<"query">(
113
+ "functions/unipileTools:listTools",
114
+ );
115
+ const getUnipileToolRef = makeFunctionReference<"query">(
116
+ "functions/unipileTools:getTool",
117
+ );
118
+ const runUnipileToolRef = makeFunctionReference<"action">(
119
+ "functions/unipileToolsNode:runTool",
120
+ );
121
+
122
+ /**
123
+ * Gmail, Outlook, Calendar and LinkedIn are brokered by Dench, and Composio
124
+ * refuses to connect them — so its catalogue entries for those toolkits can
125
+ * never execute, and showing them next to the ones that work just teaches an
126
+ * agent to pick a tool that always answers `composio_not_connected`.
127
+ *
128
+ * The set comes from the server with the tools themselves rather than being
129
+ * hardcoded here, so a fifth platform needs no CLI change.
130
+ */
131
+
132
+ /**
133
+ * Composio spells some toolkits without a separator (`googlecalendar`), while
134
+ * our registry uses the hyphenated form. Normalize through this map before the
135
+ * membership test, or the dead Composio calendar entries survive the filter
136
+ * and sit next to the working ones under two names for one product.
137
+ */
138
+ const COMPOSIO_TOOLKIT_ALIASES: Record<string, string> = {
139
+ googlecalendar: "google-calendar",
140
+ outlookcalendar: "outlook-calendar",
141
+ };
142
+
143
+ function withoutShadowedComposioTools(
144
+ items: JsonRecord[],
145
+ ownedToolkits: Set<string>,
146
+ ): JsonRecord[] {
147
+ if (ownedToolkits.size === 0) return items;
148
+ return items.filter((item) => {
149
+ const slug = (
150
+ stringField(item, "toolkit_slug") ??
151
+ stringField(asRecord(item.toolkit), "slug", "name") ??
152
+ ""
153
+ )
154
+ .toLowerCase()
155
+ .replaceAll("_", "-");
156
+ const canonical = COMPOSIO_TOOLKIT_ALIASES[slug] ?? slug;
157
+ return !canonical || !ownedToolkits.has(canonical);
158
+ });
159
+ }
160
+
161
+ /**
162
+ * Gmail, Outlook, Calendar and LinkedIn — the connections Dench brokers itself
163
+ * through Unipile rather than Composio.
164
+ *
165
+ * They come from Convex, not the gateway, so this is a second fetch rather than
166
+ * a richer parse of the first. It NEVER throws: a CLI ships independently of
167
+ * the deployment it talks to (npm publish + a re-baked Daytona snapshot), so a
168
+ * new CLI will meet backends that do not export this query yet. Failing the
169
+ * whole command over that would take `dench integrations` from "missing the
170
+ * calendar" to "broken", which is strictly worse.
171
+ */
172
+ async function fetchUnipileConnections(ctx: UnipileAuthContext): Promise<{
173
+ available: boolean;
174
+ connections: CompactConnection[];
175
+ error?: string;
176
+ }> {
177
+ if (!ctx.convex) {
178
+ return {
179
+ available: false,
180
+ connections: [],
181
+ error: "no Dench session — sign in with `dench signin`",
182
+ };
183
+ }
184
+ try {
185
+ const rows = await ctx.convex.query(listUnipileConnectionsRef, {
186
+ ...(ctx.sessionToken ? { sessionToken: ctx.sessionToken } : {}),
187
+ } as never);
188
+ if (!Array.isArray(rows)) return { available: true, connections: [] };
189
+ return {
190
+ available: true,
191
+ connections: rows.map((row) =>
192
+ compactUnipileConnection(asRecord(row) ?? {}),
193
+ ),
194
+ };
195
+ } catch (error) {
196
+ return { available: false, connections: [], error: errorMessage(error) };
197
+ }
198
+ }
199
+
93
200
  /**
94
201
  * Summarise the user's connected apps for `dench context` and friends.
95
202
  * Speaks straight to the gateway with either DENCH_API_KEY or a gateway
96
- * key resolved from the active agent session.
203
+ * key resolved from the active agent session, and merges in the Unipile
204
+ * connections Composio never reports.
97
205
  * Returns the same legacy shape the previous Convex-routed path did so
98
206
  * existing parsers don't need to change.
99
207
  */
100
208
  export async function fetchConnectedAppsSummary(
101
- ctx: GatewayAuthContext = {},
209
+ ctx: GatewayAuthContext & UnipileAuthContext = {},
102
210
  ): Promise<{
103
211
  available: boolean;
104
212
  count: number;
105
- connections: Array<{
106
- toolkit: string;
107
- status: string;
108
- connectedAccountId: string | null;
109
- accountLabel: string | null;
110
- }>;
213
+ connections: CompactConnection[];
214
+ unipileAvailable: boolean;
215
+ unipileError?: string;
111
216
  error?: string;
112
217
  reason?: string;
113
218
  }> {
219
+ const unipile = await fetchUnipileConnections(ctx);
220
+ const unipileFields = {
221
+ unipileAvailable: unipile.available,
222
+ ...(unipile.error ? { unipileError: unipile.error } : {}),
223
+ };
114
224
  let payload: unknown;
115
225
  try {
116
226
  payload = await callGateway(ctx, "GET", "/v1/composio/connections");
117
227
  } catch (error) {
118
- if (error instanceof MissingApiKeyError) {
119
- return {
120
- available: false,
121
- reason: error.message,
122
- count: 0,
123
- connections: [],
124
- };
125
- }
126
- return {
228
+ // Composio being unreachable must not hide a connected calendar, so the
229
+ // Unipile rows are still reported alongside the failure.
230
+ const failure = {
127
231
  available: false,
128
- error: errorMessage(error),
129
- count: 0,
130
- connections: [],
232
+ count: unipile.connections.length,
233
+ connections: unipile.connections,
234
+ ...unipileFields,
131
235
  };
236
+ return error instanceof MissingApiKeyError
237
+ ? { ...failure, reason: error.message }
238
+ : { ...failure, error: errorMessage(error) };
132
239
  }
133
- const connections = extractConnections(payload).map(compactConnection);
134
- return { available: true, count: connections.length, connections };
240
+ const connections = [
241
+ ...extractConnections(payload).map(compactConnection),
242
+ ...unipile.connections,
243
+ ];
244
+ return {
245
+ available: true,
246
+ count: connections.length,
247
+ connections,
248
+ ...unipileFields,
249
+ };
135
250
  }
136
251
 
137
252
  // ---------------------------------------------------------------------------
@@ -142,11 +257,10 @@ async function runStatusSubcommand(ctx: ToolCliContext): Promise<void> {
142
257
  const filterToolkit = ctx.args[0]?.startsWith("--") ? undefined : ctx.args[0];
143
258
  if (filterToolkit) ctx.args.shift();
144
259
 
145
- const connectionsPayload = await callGateway(
146
- ctx,
147
- "GET",
148
- "/v1/composio/connections",
149
- );
260
+ const [connectionsPayload, unipile] = await Promise.all([
261
+ callGateway(ctx, "GET", "/v1/composio/connections"),
262
+ fetchUnipileConnections(ctx),
263
+ ]);
150
264
  let toolkitsPayload: unknown = null;
151
265
  if (filterToolkit) {
152
266
  const search = encodeURIComponent(filterToolkit);
@@ -157,12 +271,16 @@ async function runStatusSubcommand(ctx: ToolCliContext): Promise<void> {
157
271
  );
158
272
  }
159
273
 
160
- const allConnections = extractConnections(connectionsPayload);
274
+ const allConnections = [
275
+ ...extractConnections(connectionsPayload).map(compactConnection),
276
+ ...unipile.connections,
277
+ ];
278
+ // Filtering after the merge is what makes `dench tool status gmail` answer
279
+ // with the Unipile mailbox instead of nothing.
161
280
  const filteredConnections = filterToolkit
162
- ? allConnections.filter((conn) => {
163
- const slug = stringField(conn, "toolkit_slug", "toolkit") ?? "";
164
- return slug.toLowerCase() === filterToolkit.toLowerCase();
165
- })
281
+ ? allConnections.filter(
282
+ (conn) => conn.toolkit.toLowerCase() === filterToolkit.toLowerCase(),
283
+ )
166
284
  : allConnections;
167
285
 
168
286
  const summary = {
@@ -170,7 +288,9 @@ async function runStatusSubcommand(ctx: ToolCliContext): Promise<void> {
170
288
  available: true,
171
289
  toolkit: filterToolkit ?? null,
172
290
  count: filteredConnections.length,
173
- connections: filteredConnections.map(compactConnection),
291
+ connections: filteredConnections,
292
+ unipileAvailable: unipile.available,
293
+ ...(unipile.error ? { unipileError: unipile.error } : {}),
174
294
  ...(toolkitsPayload
175
295
  ? { toolkit_info: extractToolkits(toolkitsPayload) }
176
296
  : {}),
@@ -210,17 +330,141 @@ async function runSearchSubcommand(ctx: ToolCliContext): Promise<void> {
210
330
  if (toolkit) body.toolkit_slug = toolkit;
211
331
  if (limit) body.limit = limit;
212
332
 
213
- const payload = await callGateway(
214
- ctx,
215
- "POST",
216
- "/v1/composio/tools/search",
217
- body,
333
+ const [payload, unipile] = await Promise.all([
334
+ callGateway(ctx, "POST", "/v1/composio/tools/search", body),
335
+ fetchUnipileTools(ctx, { query, ...(toolkit ? { toolkit } : {}) }),
336
+ ]);
337
+
338
+ const record = asRecord(payload) ?? {};
339
+ const ownedToolkits = new Set(
340
+ unipile.tools
341
+ .map((tool) => stringField(asRecord(tool.toolkit), "slug") ?? "")
342
+ .filter(Boolean),
218
343
  );
344
+ const merged = {
345
+ ...record,
346
+ items: [
347
+ ...withoutShadowedComposioTools(
348
+ extractSearchItems(record),
349
+ ownedToolkits,
350
+ ),
351
+ ...unipile.tools,
352
+ ],
353
+ unipileAvailable: unipile.available,
354
+ ...(unipile.error ? { unipileError: unipile.error } : {}),
355
+ };
356
+
219
357
  if (ctx.jsonOutput) {
220
- console.log(JSON.stringify(payload, null, 2));
358
+ console.log(JSON.stringify(merged, null, 2));
221
359
  return;
222
360
  }
223
- console.log(formatSearchResult(payload, compact));
361
+ console.log(formatSearchResult(merged, compact));
362
+ }
363
+
364
+ /**
365
+ * Whether this slug is ours. A lookup failure answers "not ours", so an old
366
+ * deployment routes everything to Composio exactly as it did before.
367
+ */
368
+ async function resolveUnipileTool(
369
+ ctx: UnipileAuthContext,
370
+ slug: string,
371
+ ): Promise<JsonRecord | null> {
372
+ if (!ctx.convex) return null;
373
+ try {
374
+ return (
375
+ asRecord(await ctx.convex.query(getUnipileToolRef, { slug } as never)) ??
376
+ null
377
+ );
378
+ } catch {
379
+ return null;
380
+ }
381
+ }
382
+
383
+ async function runUnipileTool(
384
+ ctx: ToolCliContext,
385
+ slug: string,
386
+ toolArgs: Record<string, unknown>,
387
+ approvalId: string | undefined,
388
+ ): Promise<void> {
389
+ if (!ctx.convex) throw new MissingApiKeyError();
390
+ const result = await ctx.convex.action(runUnipileToolRef, {
391
+ ...(ctx.sessionToken ? { sessionToken: ctx.sessionToken } : {}),
392
+ slug,
393
+ arguments: toolArgs,
394
+ ...(approvalId ? { approvalId } : {}),
395
+ } as never);
396
+
397
+ const record = asRecord(result) ?? {};
398
+ if (ctx.jsonOutput) {
399
+ console.log(JSON.stringify(record, null, 2));
400
+ } else {
401
+ console.log(formatUnipileToolResult(record));
402
+ }
403
+ // Same convention as the Composio failures below: a refusal the agent is
404
+ // expected to act on exits non-zero without throwing, so the payload above
405
+ // is still the thing it reads.
406
+ if (record.ok === false) process.exitCode = 1;
407
+ }
408
+
409
+ function formatUnipileToolResult(record: JsonRecord): string {
410
+ if (record.ok === false) {
411
+ const lines = [
412
+ `Failed: ${stringField(record, "error") ?? "unknown error"}`,
413
+ ];
414
+ const approvalId = stringField(record, "approvalId");
415
+ if (approvalId) {
416
+ lines.push(
417
+ `Approval required. Once a human says yes:`,
418
+ ` dench approval approve ${approvalId} --evidence "…"`,
419
+ ` dench tool run ${stringField(record, "toolSlug") ?? "<slug>"} --args '…' --approval ${approvalId}`,
420
+ );
421
+ }
422
+ return lines.join("\n");
423
+ }
424
+ if (record.queued === true) {
425
+ const data = asRecord(record.data) ?? {};
426
+ return [
427
+ `Queued: ${stringField(data, "action_id") ?? "unknown"}`,
428
+ "LinkedIn actions are paced to protect the account.",
429
+ ` dench tool run LINKEDIN_GET_ACTION_STATUS --args '{"action_id":"${stringField(data, "action_id") ?? ""}"}'`,
430
+ ].join("\n");
431
+ }
432
+ return JSON.stringify(redactForDisplay(record), null, 2);
433
+ }
434
+
435
+ /**
436
+ * The Unipile half of tool discovery. Never throws, for the same reason the
437
+ * connection listing does not: the CLI ships on its own cadence, so a new CLI
438
+ * will meet deployments that do not export these functions yet. That must cost
439
+ * the Unipile tools, not the whole search.
440
+ */
441
+ async function fetchUnipileTools(
442
+ ctx: UnipileAuthContext,
443
+ options: { query?: string; toolkit?: string } = {},
444
+ ): Promise<{ available: boolean; tools: JsonRecord[]; error?: string }> {
445
+ if (!ctx.convex) {
446
+ return {
447
+ available: false,
448
+ tools: [],
449
+ error: "no Dench session — sign in with `dench signin`",
450
+ };
451
+ }
452
+ try {
453
+ const rows = await ctx.convex.query(listUnipileToolsRef, {
454
+ ...(ctx.sessionToken ? { sessionToken: ctx.sessionToken } : {}),
455
+ ...(options.query ? { query: options.query } : {}),
456
+ ...(options.toolkit ? { toolkit: options.toolkit } : {}),
457
+ } as never);
458
+ if (!Array.isArray(rows)) return { available: true, tools: [] };
459
+ return {
460
+ available: true,
461
+ tools: rows
462
+ .map((row) => asRecord(row))
463
+ .filter((row): row is JsonRecord => row !== undefined),
464
+ };
465
+ } catch (error) {
466
+ return { available: false, tools: [], error: errorMessage(error) };
467
+ }
224
468
  }
225
469
 
226
470
  async function runRunSubcommand(ctx: ToolCliContext): Promise<void> {
@@ -233,6 +477,7 @@ async function runRunSubcommand(ctx: ToolCliContext): Promise<void> {
233
477
  ctx.args.shift();
234
478
  const argsJson = getFlag(ctx.args, "--args");
235
479
  const account = getFlag(ctx.args, "--account");
480
+ const approval = getFlag(ctx.args, "--approval");
236
481
  const parsedArgs = (parseJson(argsJson) ?? {}) as Record<string, unknown>;
237
482
  if (
238
483
  parsedArgs &&
@@ -244,6 +489,16 @@ async function runRunSubcommand(ctx: ToolCliContext): Promise<void> {
244
489
  throw new ToolCliError("--args must be a JSON object");
245
490
  }
246
491
 
492
+ // Ask the server which broker owns this slug rather than keeping a copy of
493
+ // the vocabulary here. Two hand-maintained lists is the drift CLAUDE.md
494
+ // records for gateway usage providers, and here it would mean a real tool
495
+ // routed to Composio, which 404s.
496
+ const unipileTool = await resolveUnipileTool(ctx, toolSlug);
497
+ if (unipileTool) {
498
+ await runUnipileTool(ctx, toolSlug, parsedArgs, approval);
499
+ return;
500
+ }
501
+
247
502
  const body: Record<string, unknown> = {
248
503
  tool_slug: toolSlug,
249
504
  arguments: parsedArgs,
@@ -653,8 +908,30 @@ function extractToolkits(payload: unknown): JsonRecord[] {
653
908
  );
654
909
  }
655
910
 
656
- function compactConnection(connection: JsonRecord) {
911
+ /**
912
+ * The merged row shape. `source` is the discriminator: `composio` rows carry a
913
+ * routable `connectedAccountId` (usable as `dench tool run --account`), while
914
+ * `unipile` rows never do — their account id is an internal credential.
915
+ */
916
+ export type CompactConnection = {
917
+ source: "composio" | "unipile";
918
+ toolkit: string;
919
+ status: string;
920
+ connectedAccountId: string | null;
921
+ accountLabel: string | null;
922
+ /** Unipile only: whether the connection belongs to this agent's member. */
923
+ isOwner?: boolean;
924
+ ownerName?: string;
925
+ /**
926
+ * Unipile only. A teammate's `private` connection is never listed at all, so
927
+ * `private` here always means the caller's own.
928
+ */
929
+ visibility?: "private" | "shared";
930
+ };
931
+
932
+ function compactConnection(connection: JsonRecord): CompactConnection {
657
933
  return {
934
+ source: "composio",
658
935
  toolkit:
659
936
  stringField(connection, "toolkit_slug", "toolkit") ??
660
937
  stringField(asRecord(connection.toolkit), "slug", "name") ??
@@ -672,10 +949,47 @@ function compactConnection(connection: JsonRecord) {
672
949
  };
673
950
  }
674
951
 
952
+ function compactUnipileConnection(connection: JsonRecord): CompactConnection {
953
+ return {
954
+ source: "unipile",
955
+ toolkit: stringField(connection, "toolkit") ?? "unknown",
956
+ status: stringField(connection, "status") ?? "unknown",
957
+ connectedAccountId: null,
958
+ accountLabel: stringField(connection, "accountLabel") ?? null,
959
+ isOwner: connection.isOwner === true,
960
+ ownerName: stringField(connection, "ownerName") ?? undefined,
961
+ visibility: connection.visibility === "private" ? "private" : "shared",
962
+ };
963
+ }
964
+
965
+ function formatConnectionLine(connection: CompactConnection): string {
966
+ const label = connection.accountLabel ? ` (${connection.accountLabel})` : "";
967
+ const id = connection.connectedAccountId
968
+ ? ` id=${connection.connectedAccountId}`
969
+ : "";
970
+ // "via Dench" both names the broker and explains why the row has no
971
+ // connected-account id to pass to `dench tool run`.
972
+ const via = connection.source === "unipile" ? " via Dench" : "";
973
+ // Whose mailbox/LinkedIn it is only matters when it is not this agent's —
974
+ // `dench email --mailbox` picks between them.
975
+ const owner =
976
+ connection.source === "unipile" &&
977
+ connection.isOwner === false &&
978
+ connection.ownerName
979
+ ? ` · ${connection.ownerName}`
980
+ : "";
981
+ // Only ever the caller's own — a teammate's private connection is filtered
982
+ // out server-side rather than shown as unusable.
983
+ const privacy = connection.visibility === "private" ? " · private" : "";
984
+ return ` - ${connection.toolkit} [${connection.status}]${label}${id}${via}${owner}${privacy}`;
985
+ }
986
+
675
987
  function formatStatusSummary(summary: {
676
988
  toolkit: string | null;
677
989
  count: number;
678
- connections: ReturnType<typeof compactConnection>[];
990
+ connections: CompactConnection[];
991
+ unipileAvailable?: boolean;
992
+ unipileError?: string;
679
993
  }): string {
680
994
  const lines: string[] = [];
681
995
  if (summary.toolkit) {
@@ -686,18 +1000,16 @@ function formatStatusSummary(summary: {
686
1000
  if (summary.connections.length === 0) {
687
1001
  lines.push(" none");
688
1002
  } else {
689
- for (const connection of summary.connections.slice(0, 24)) {
690
- const label = connection.accountLabel
691
- ? ` (${connection.accountLabel})`
692
- : "";
693
- lines.push(
694
- ` - ${connection.toolkit} [${connection.status}]${label}${
695
- connection.connectedAccountId
696
- ? ` id=${connection.connectedAccountId}`
697
- : ""
698
- }`,
699
- );
700
- }
1003
+ lines.push(...summary.connections.slice(0, 24).map(formatConnectionLine));
1004
+ }
1005
+ // Say so rather than silently listing Composio only — otherwise a connected
1006
+ // calendar reads as absent, which is the failure this merge exists to fix.
1007
+ if (summary.unipileAvailable === false) {
1008
+ lines.push(
1009
+ ` (could not check Gmail/Outlook/Calendar/LinkedIn: ${
1010
+ summary.unipileError ?? "unknown error"
1011
+ })`,
1012
+ );
701
1013
  }
702
1014
  return lines.join("\n");
703
1015
  }
@@ -849,16 +1161,27 @@ function toolHelp() {
849
1161
  console.log(`Dench tool commands
850
1162
 
851
1163
  Usage:
852
- dench apps [--json]
1164
+ dench integrations [--json]
853
1165
  dench tool status [toolkit] [--json]
854
1166
  dench tool connect <toolkit> [--callback-url <url>] [--json]
855
1167
  dench tool search "create github issue" [--toolkit github] [--limit 20] [--compact] [--json]
856
- dench tool run <composio_tool_slug> --args '{"key":"value"}' [--account <connectedAccountId>] [--json]
1168
+ dench tool run <tool_slug> --args '{"key":"value"}' [--account <connectedAccountId>] [--approval <approvalId>] [--json]
857
1169
  dench tool disconnect <connectionId> [--json]
858
1170
 
859
1171
  Notes:
860
1172
  All commands authenticate with DENCH_API_KEY or the active \`dench signin\`
861
1173
  agent session. Agent sessions require a paid workspace.
1174
+ Results carry a "source". "composio" rows are brokered by Composio; "unipile"
1175
+ rows (gmail, outlook, calendar, linkedin) are connected by Dench itself. Both
1176
+ run through \`dench tool run\` with the same slug + --args contract; only
1177
+ \`dench tool connect\` is Composio-only — the human connects the other four
1178
+ from the workspace Marketplace.
1179
+ A write tool returns approval_required with an approvalId. Once a human
1180
+ approves it, re-run with --approval <approvalId>; an approval is bound to that
1181
+ exact tool and argument set.
1182
+ LinkedIn tools are QUEUED, not run inline, so account-safety pacing (daily
1183
+ caps, working hours, spacing) applies. They return an action_id — poll it with
1184
+ LINKEDIN_GET_ACTION_STATUS.
862
1185
  dench tool connect prints the OAuth redirect URL for the human to open.
863
1186
  dench tool run output is redacted for display; pass --json for raw output.
864
1187
  Override the gateway base with DENCH_GATEWAY_URL or GATEWAY_URL.