@dench.com/cli 2.7.8 → 2.7.10-staging.100
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 +103 -21
- package/email.ts +186 -4
- package/lib/api-schemas.ts +23 -0
- package/lib/command-registry.ts +139 -4
- package/package.json +2 -1
- package/tools.ts +382 -59
- package/web.ts +356 -0
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.
|