@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 +77 -21
- package/email.ts +186 -4
- package/lib/command-registry.ts +7 -4
- package/package.json +1 -1
- package/tools.ts +382 -59
package/dench.ts
CHANGED
|
@@ -818,9 +818,23 @@ function integrationsHelp() {
|
|
|
818
818
|
Usage:
|
|
819
819
|
dench integrations [--json]
|
|
820
820
|
|
|
821
|
-
Lists
|
|
822
|
-
|
|
823
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2874
|
-
|
|
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
|
-
|
|
2883
|
-
|
|
2884
|
-
(connection)
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
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` —
|
|
4341
|
-
//
|
|
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
|
+
}
|
package/lib/command-registry.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
2488
|
+
backend: custom("connectionsList", "bearer"),
|
|
2486
2489
|
}),
|
|
2487
2490
|
op({
|
|
2488
2491
|
id: "tool.connect",
|
package/package.json
CHANGED
package/tools.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `dench tool` and `dench
|
|
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
|
|
13
|
-
* dench tool status [toolkit?] [--json] — list
|
|
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:
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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 =
|
|
134
|
-
|
|
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
|
|
146
|
-
ctx,
|
|
147
|
-
|
|
148
|
-
|
|
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 =
|
|
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(
|
|
163
|
-
|
|
164
|
-
|
|
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
|
|
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
|
|
214
|
-
ctx,
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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(
|
|
358
|
+
console.log(JSON.stringify(merged, null, 2));
|
|
221
359
|
return;
|
|
222
360
|
}
|
|
223
|
-
console.log(formatSearchResult(
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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
|
|
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 <
|
|
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.
|