@dench.com/cli 0.3.8 → 0.4.1

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.
Files changed (5) hide show
  1. package/chat.ts +215 -8
  2. package/cron.ts +8 -4
  3. package/dench.ts +166 -503
  4. package/package.json +2 -1
  5. package/tools.ts +808 -0
package/tools.ts ADDED
@@ -0,0 +1,808 @@
1
+ /**
2
+ * `dench tool` and `dench apps` — direct Dench Cloud Gateway access for
3
+ * Composio's get-apps / get-tools / execute-tool surface.
4
+ *
5
+ * Mirrors `denchclaw`'s pattern: this CLI is a thin wrapper over five
6
+ * gateway endpoints, authenticated with the unified `DENCH_API_KEY`
7
+ * baked into every Dench sandbox at create time. There is no Convex
8
+ * session involved; that means these subcommands work in any
9
+ * environment that has the API key (sandboxes, CI, local dev with the
10
+ * key exported manually). The previous implementation forced
11
+ * `requireSessionRuntime` and routed through a Convex action, which
12
+ * broke `apiKey` runtime mode entirely.
13
+ *
14
+ * Subcommands:
15
+ *
16
+ * dench apps [--json] — list connected apps
17
+ * dench tool status [toolkit?] [--json] — list connections (optionally narrowed)
18
+ * dench tool search "<query>" [--toolkit <slug>] [--limit N] [--json]
19
+ * dench tool run <slug> [--args <json>] [--account <id>] [--json]
20
+ * dench tool connect <toolkit> [--callback-url <url>] [--json] — print OAuth redirect URL
21
+ * dench tool disconnect <connectionId> [--json]
22
+ *
23
+ * Endpoints used (all `Authorization: Bearer DENCH_API_KEY`):
24
+ *
25
+ * GET /v1/composio/connections
26
+ * GET /v1/composio/toolkits?search=<slug>
27
+ * POST /v1/composio/tools/search { query, toolkit_slug?, limit? }
28
+ * POST /v1/composio/tools/execute { tool_slug, arguments, connected_account_id? }
29
+ * POST /v1/composio/connect { toolkit, callback_url }
30
+ * DELETE /v1/composio/connections/<id>
31
+ *
32
+ * Output parity: this module preserves the JSON shape and human
33
+ * formatting that the previous Convex-routed implementation produced
34
+ * (compact ranked search, "Connect <toolkit> here: <url>", redacted
35
+ * run output) so any agent that already parses `dench tool ...` keeps
36
+ * working.
37
+ */
38
+
39
+ import {
40
+ CliArgError,
41
+ getFlag,
42
+ hasFlag,
43
+ parseJson,
44
+ shift as shiftRaw,
45
+ } from "./lib/cli-args";
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Public dispatcher
49
+ // ---------------------------------------------------------------------------
50
+
51
+ export type ToolCliContext = {
52
+ args: string[];
53
+ jsonOutput: boolean;
54
+ };
55
+
56
+ export async function runToolCommand(ctx: ToolCliContext): Promise<void> {
57
+ const sub = ctx.args[0];
58
+ if (!sub || sub === "help" || sub === "--help" || sub === "-h") {
59
+ toolHelp();
60
+ return;
61
+ }
62
+ ctx.args.shift();
63
+
64
+ if (sub === "status") {
65
+ await runStatusSubcommand(ctx);
66
+ return;
67
+ }
68
+ if (sub === "search") {
69
+ await runSearchSubcommand(ctx);
70
+ return;
71
+ }
72
+ if (sub === "run") {
73
+ await runRunSubcommand(ctx);
74
+ return;
75
+ }
76
+ if (sub === "connect") {
77
+ await runConnectSubcommand(ctx);
78
+ return;
79
+ }
80
+ if (sub === "disconnect") {
81
+ await runDisconnectSubcommand(ctx);
82
+ return;
83
+ }
84
+
85
+ throw new ToolCliError(`Unknown dench tool subcommand: ${sub}`);
86
+ }
87
+
88
+ /**
89
+ * Summarise the user's connected apps for `dench context` and friends.
90
+ * Bypasses any Convex session and speaks straight to the gateway.
91
+ * Returns the same legacy shape the previous Convex-routed path did so
92
+ * existing parsers don't need to change.
93
+ */
94
+ export async function fetchConnectedAppsSummary(): Promise<{
95
+ available: boolean;
96
+ count: number;
97
+ connections: Array<{
98
+ toolkit: string;
99
+ status: string;
100
+ connectedAccountId: string | null;
101
+ accountLabel: string | null;
102
+ }>;
103
+ error?: string;
104
+ reason?: string;
105
+ }> {
106
+ let payload: unknown;
107
+ try {
108
+ payload = await callGateway("GET", "/v1/composio/connections");
109
+ } catch (error) {
110
+ if (error instanceof MissingApiKeyError) {
111
+ return {
112
+ available: false,
113
+ reason: error.message,
114
+ count: 0,
115
+ connections: [],
116
+ };
117
+ }
118
+ return {
119
+ available: false,
120
+ error: errorMessage(error),
121
+ count: 0,
122
+ connections: [],
123
+ };
124
+ }
125
+ const connections = extractConnections(payload).map(compactConnection);
126
+ return { available: true, count: connections.length, connections };
127
+ }
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // Subcommands
131
+ // ---------------------------------------------------------------------------
132
+
133
+ async function runStatusSubcommand(ctx: ToolCliContext): Promise<void> {
134
+ const filterToolkit = ctx.args[0]?.startsWith("--") ? undefined : ctx.args[0];
135
+ if (filterToolkit) ctx.args.shift();
136
+
137
+ const connectionsPayload = await callGateway(
138
+ "GET",
139
+ "/v1/composio/connections",
140
+ );
141
+ let toolkitsPayload: unknown = null;
142
+ if (filterToolkit) {
143
+ const search = encodeURIComponent(filterToolkit);
144
+ toolkitsPayload = await callGateway(
145
+ "GET",
146
+ `/v1/composio/toolkits?search=${search}`,
147
+ );
148
+ }
149
+
150
+ const allConnections = extractConnections(connectionsPayload);
151
+ const filteredConnections = filterToolkit
152
+ ? allConnections.filter((conn) => {
153
+ const slug = stringField(conn, "toolkit_slug", "toolkit") ?? "";
154
+ return slug.toLowerCase() === filterToolkit.toLowerCase();
155
+ })
156
+ : allConnections;
157
+
158
+ const summary = {
159
+ ok: true,
160
+ available: true,
161
+ toolkit: filterToolkit ?? null,
162
+ count: filteredConnections.length,
163
+ connections: filteredConnections.map(compactConnection),
164
+ ...(toolkitsPayload
165
+ ? { toolkit_info: extractToolkits(toolkitsPayload) }
166
+ : {}),
167
+ };
168
+
169
+ if (ctx.jsonOutput) {
170
+ console.log(JSON.stringify(summary, null, 2));
171
+ return;
172
+ }
173
+ console.log(formatStatusSummary(summary));
174
+ }
175
+
176
+ async function runSearchSubcommand(ctx: ToolCliContext): Promise<void> {
177
+ const toolkit = getFlag(ctx.args, "--toolkit");
178
+ const limitRaw = getFlag(ctx.args, "--limit");
179
+ const limit = limitRaw ? parsePositiveInt("--limit", limitRaw) : undefined;
180
+ const queryParts = ctx.args.slice();
181
+ ctx.args.length = 0;
182
+ const query = queryParts.join(" ").trim();
183
+ if (!query) {
184
+ throw new ToolCliError(
185
+ 'Usage: dench tool search "<query>" [--toolkit <slug>] [--limit N] [--json]',
186
+ );
187
+ }
188
+
189
+ const body: Record<string, unknown> = { query };
190
+ if (toolkit) body.toolkit_slug = toolkit;
191
+ if (limit) body.limit = limit;
192
+
193
+ const payload = await callGateway("POST", "/v1/composio/tools/search", body);
194
+ if (ctx.jsonOutput) {
195
+ console.log(JSON.stringify(payload, null, 2));
196
+ return;
197
+ }
198
+ console.log(formatSearchResult(payload));
199
+ }
200
+
201
+ async function runRunSubcommand(ctx: ToolCliContext): Promise<void> {
202
+ const toolSlug = ctx.args[0];
203
+ if (!toolSlug || toolSlug.startsWith("--")) {
204
+ throw new ToolCliError(
205
+ 'Usage: dench tool run <composio_tool_slug> [--args \'{"key":"value"}\'] [--account <connectedAccountId>] [--json]',
206
+ );
207
+ }
208
+ ctx.args.shift();
209
+ const argsJson = getFlag(ctx.args, "--args");
210
+ const account = getFlag(ctx.args, "--account");
211
+ const parsedArgs = (parseJson(argsJson) ?? {}) as Record<string, unknown>;
212
+ if (
213
+ parsedArgs &&
214
+ typeof parsedArgs === "object" &&
215
+ !Array.isArray(parsedArgs)
216
+ ) {
217
+ // ok
218
+ } else {
219
+ throw new ToolCliError("--args must be a JSON object");
220
+ }
221
+
222
+ const body: Record<string, unknown> = {
223
+ tool_slug: toolSlug,
224
+ arguments: parsedArgs,
225
+ };
226
+ if (account) body.connected_account_id = account;
227
+
228
+ let payload: unknown;
229
+ try {
230
+ payload = await callGateway("POST", "/v1/composio/tools/execute", body);
231
+ } catch (error) {
232
+ if (error instanceof GatewayHttpError && isLikelyNoConnection(error)) {
233
+ const noConnPayload = noConnectionPayload(toolSlug, error);
234
+ if (ctx.jsonOutput) {
235
+ console.log(JSON.stringify(noConnPayload, null, 2));
236
+ } else {
237
+ console.log(formatNoConnection(noConnPayload));
238
+ }
239
+ process.exitCode = 1;
240
+ return;
241
+ }
242
+ throw error;
243
+ }
244
+
245
+ if (ctx.jsonOutput) {
246
+ console.log(JSON.stringify(payload, null, 2));
247
+ return;
248
+ }
249
+ console.log(redactForDisplay(payload));
250
+ }
251
+
252
+ async function runConnectSubcommand(ctx: ToolCliContext): Promise<void> {
253
+ const toolkit = ctx.args[0];
254
+ if (!toolkit || toolkit.startsWith("--")) {
255
+ throw new ToolCliError(
256
+ "Usage: dench tool connect <toolkit> [--callback-url <url>] [--json]",
257
+ );
258
+ }
259
+ ctx.args.shift();
260
+ const callbackUrl =
261
+ getFlag(ctx.args, "--callback-url") ??
262
+ process.env.DENCH_OAUTH_CALLBACK_URL?.trim() ??
263
+ `${resolveAppPublicOrigin()}/api/composio/callback`;
264
+
265
+ const payload = await callGateway("POST", "/v1/composio/connect", {
266
+ toolkit,
267
+ callback_url: callbackUrl,
268
+ });
269
+
270
+ const record = asRecord(payload);
271
+ const redirectUrl =
272
+ stringField(record, "redirect_url", "redirectUrl", "url") ??
273
+ stringField(asRecord(record?.data), "redirect_url", "redirectUrl", "url");
274
+ const summary = {
275
+ ok: true,
276
+ toolkit,
277
+ callbackUrl,
278
+ redirectUrl: redirectUrl ?? null,
279
+ connectionId:
280
+ stringField(record, "connection_id", "connectionId") ??
281
+ stringField(asRecord(record?.data), "connection_id", "connectionId") ??
282
+ null,
283
+ ...(redirectUrl
284
+ ? { message: `Connect ${toolkit} here: ${redirectUrl}` }
285
+ : {}),
286
+ raw: payload,
287
+ };
288
+
289
+ if (ctx.jsonOutput) {
290
+ console.log(JSON.stringify(summary, null, 2));
291
+ return;
292
+ }
293
+ if (redirectUrl) {
294
+ console.log(`Connect ${toolkit} here: ${redirectUrl}`);
295
+ return;
296
+ }
297
+ console.log(JSON.stringify(payload, null, 2));
298
+ }
299
+
300
+ async function runDisconnectSubcommand(ctx: ToolCliContext): Promise<void> {
301
+ const connectionId = ctx.args[0];
302
+ if (!connectionId || connectionId.startsWith("--")) {
303
+ throw new ToolCliError(
304
+ "Usage: dench tool disconnect <connectionId> [--json]",
305
+ );
306
+ }
307
+ ctx.args.shift();
308
+
309
+ const path = `/v1/composio/connections/${encodeURIComponent(connectionId)}`;
310
+ let payload: unknown = { ok: true, deleted: true };
311
+ try {
312
+ payload = await callGateway("DELETE", path);
313
+ } catch (error) {
314
+ // Composio returns 404 for connections that are already gone — surface
315
+ // that as a soft success so callers can keep their local state in sync.
316
+ if (error instanceof GatewayHttpError && error.status === 404) {
317
+ payload = { ok: true, deleted: true, alreadyGone: true };
318
+ } else {
319
+ throw error;
320
+ }
321
+ }
322
+
323
+ if (ctx.jsonOutput) {
324
+ console.log(JSON.stringify(payload, null, 2));
325
+ return;
326
+ }
327
+ const record = asRecord(payload);
328
+ if (record?.alreadyGone) {
329
+ console.log(`Connection ${connectionId} was already disconnected.`);
330
+ return;
331
+ }
332
+ console.log(`Disconnected ${connectionId}.`);
333
+ }
334
+
335
+ // ---------------------------------------------------------------------------
336
+ // HTTP plumbing
337
+ // ---------------------------------------------------------------------------
338
+
339
+ class ToolCliError extends Error {
340
+ constructor(message: string) {
341
+ super(message);
342
+ this.name = "ToolCliError";
343
+ }
344
+ }
345
+
346
+ class MissingApiKeyError extends ToolCliError {
347
+ constructor() {
348
+ super(
349
+ "DENCH_API_KEY is not set. Inside a Dench sandbox it is baked into " +
350
+ "the env automatically; outside, export it manually or run " +
351
+ "`dench login` and copy the api-key from the output.",
352
+ );
353
+ this.name = "MissingApiKeyError";
354
+ }
355
+ }
356
+
357
+ class GatewayHttpError extends ToolCliError {
358
+ status: number;
359
+ body: string;
360
+ path: string;
361
+
362
+ constructor(args: { status: number; body: string; path: string }) {
363
+ const truncated =
364
+ args.body.length > 500 ? `${args.body.slice(0, 500)}…` : args.body;
365
+ super(
366
+ `Gateway ${args.path} returned ${args.status}: ${truncated || "(empty)"}`,
367
+ );
368
+ this.name = "GatewayHttpError";
369
+ this.status = args.status;
370
+ this.body = args.body;
371
+ this.path = args.path;
372
+ }
373
+ }
374
+
375
+ function resolveGatewayBaseUrl(): string {
376
+ const explicit =
377
+ process.env.DENCH_GATEWAY_URL?.trim() || process.env.GATEWAY_URL?.trim();
378
+ if (explicit) return explicit.replace(/\/+$/, "");
379
+ const host = process.env.DENCH_HOST?.trim();
380
+ if (host && /localhost|127\.0\.0\.1/.test(host)) {
381
+ return "http://localhost:8787";
382
+ }
383
+ return "https://gateway.merseoriginals.com";
384
+ }
385
+
386
+ /**
387
+ * Best-effort guess at the Next.js host that should receive the
388
+ * Composio OAuth callback when an agent runs `dench tool connect`
389
+ * outside the browser. Sandboxes set `DENCH_API_URL` /
390
+ * `DENCH_INTERNAL_BASE` / `DENCH_HOST` already; pick whichever one is
391
+ * available so the redirect lands on the right deployment. Falls back
392
+ * to production.
393
+ */
394
+ function resolveAppPublicOrigin(): string {
395
+ const candidates = [
396
+ process.env.DENCH_API_URL,
397
+ process.env.DENCH_INTERNAL_BASE,
398
+ process.env.DENCH_HOST,
399
+ process.env.NEXT_PUBLIC_APP_URL,
400
+ process.env.NEXT_PUBLIC_SITE_URL,
401
+ ];
402
+ for (const candidate of candidates) {
403
+ const trimmed = candidate?.trim();
404
+ if (trimmed) return trimmed.replace(/\/+$/, "");
405
+ }
406
+ return "https://dench.com";
407
+ }
408
+
409
+ function requireApiKey(): string {
410
+ const apiKey = process.env.DENCH_API_KEY?.trim();
411
+ if (!apiKey) throw new MissingApiKeyError();
412
+ return apiKey;
413
+ }
414
+
415
+ async function callGateway(
416
+ method: "GET" | "POST" | "DELETE",
417
+ path: string,
418
+ body?: Record<string, unknown>,
419
+ ): Promise<unknown> {
420
+ const apiKey = requireApiKey();
421
+ const baseUrl = resolveGatewayBaseUrl();
422
+ const init: RequestInit = {
423
+ method,
424
+ headers: {
425
+ "content-type": "application/json",
426
+ authorization: `Bearer ${apiKey}`,
427
+ },
428
+ };
429
+ if (body !== undefined) init.body = JSON.stringify(body);
430
+ const response = await fetch(`${baseUrl}${path}`, init);
431
+ if (!response.ok) {
432
+ const detail = await response.text().catch(() => "");
433
+ throw new GatewayHttpError({ status: response.status, body: detail, path });
434
+ }
435
+ if (response.status === 204) return null;
436
+ const contentType = response.headers.get("content-type") ?? "";
437
+ if (!contentType.includes("application/json")) {
438
+ return response.text();
439
+ }
440
+ return response.json();
441
+ }
442
+
443
+ // ---------------------------------------------------------------------------
444
+ // Result extraction & formatting
445
+ // ---------------------------------------------------------------------------
446
+
447
+ const NO_CONNECTION_HINTS = [
448
+ "composio_account_selection_required",
449
+ "no active connection",
450
+ "no connection found",
451
+ "connected_account_id is required",
452
+ "is not active or does not exist",
453
+ "account is not active",
454
+ "account does not exist",
455
+ "connection has been disabled",
456
+ "connection is disabled",
457
+ ];
458
+
459
+ function isLikelyNoConnection(error: GatewayHttpError): boolean {
460
+ if (![400, 404, 422].includes(error.status)) return false;
461
+ const lower = error.body.toLowerCase();
462
+ return NO_CONNECTION_HINTS.some((hint) => lower.includes(hint));
463
+ }
464
+
465
+ function noConnectionPayload(toolSlug: string, error: GatewayHttpError) {
466
+ const toolkit = toolkitFromSlug(toolSlug);
467
+ return {
468
+ ok: false,
469
+ no_connection: true,
470
+ tool_slug: toolSlug,
471
+ toolkit,
472
+ error: error.message,
473
+ next_actions: toolkit
474
+ ? [
475
+ `Run \`dench tool connect ${toolkit}\` to start the OAuth flow, then re-run this command.`,
476
+ ]
477
+ : [
478
+ "Run `dench tool connect <toolkit>` to start the OAuth flow, then re-run this command.",
479
+ ],
480
+ };
481
+ }
482
+
483
+ function formatNoConnection(payload: ReturnType<typeof noConnectionPayload>) {
484
+ const lines = [
485
+ "No active connection for this tool.",
486
+ `Tool: ${payload.tool_slug}`,
487
+ payload.toolkit ? `Toolkit: ${payload.toolkit}` : null,
488
+ "",
489
+ "Next actions:",
490
+ ...payload.next_actions.map((line) => ` - ${line}`),
491
+ ];
492
+ return lines.filter((line) => line !== null).join("\n");
493
+ }
494
+
495
+ function toolkitFromSlug(toolSlug: string): string | undefined {
496
+ const head = toolSlug.split("_")[0];
497
+ return head ? head.toLowerCase() : undefined;
498
+ }
499
+
500
+ function extractConnections(payload: unknown): JsonRecord[] {
501
+ const root = asRecord(payload);
502
+ const candidates = [
503
+ asRecordArray(root?.connections),
504
+ asRecordArray(root?.items),
505
+ asRecordArray(asRecord(root?.data)?.items),
506
+ asRecordArray(asRecord(root?.data)?.connections),
507
+ asRecordArray(root?.data),
508
+ ];
509
+ for (const candidate of candidates) {
510
+ if (candidate.length > 0) return candidate;
511
+ }
512
+ if (Array.isArray(payload))
513
+ return payload.filter((entry) => asRecord(entry)) as JsonRecord[];
514
+ return [];
515
+ }
516
+
517
+ function extractToolkits(payload: unknown): JsonRecord[] {
518
+ const root = asRecord(payload);
519
+ return (
520
+ asRecordArray(root?.items) ||
521
+ asRecordArray(root?.toolkits) ||
522
+ asRecordArray(root?.data) ||
523
+ []
524
+ );
525
+ }
526
+
527
+ function compactConnection(connection: JsonRecord) {
528
+ return {
529
+ toolkit:
530
+ stringField(connection, "toolkit_slug", "toolkit") ??
531
+ stringField(asRecord(connection.toolkit), "slug", "name") ??
532
+ "unknown",
533
+ status: stringField(connection, "status", "connection_status") ?? "unknown",
534
+ connectedAccountId:
535
+ stringField(
536
+ connection,
537
+ "connected_account_id",
538
+ "connectedAccountId",
539
+ "id",
540
+ ) ?? null,
541
+ accountLabel:
542
+ stringField(connection, "account_label", "accountLabel", "label") ?? null,
543
+ };
544
+ }
545
+
546
+ function formatStatusSummary(summary: {
547
+ toolkit: string | null;
548
+ count: number;
549
+ connections: ReturnType<typeof compactConnection>[];
550
+ }): string {
551
+ const lines: string[] = [];
552
+ if (summary.toolkit) {
553
+ lines.push(`Connections for ${summary.toolkit}:`);
554
+ } else {
555
+ lines.push("Connected apps:");
556
+ }
557
+ if (summary.connections.length === 0) {
558
+ lines.push(" none");
559
+ } else {
560
+ for (const connection of summary.connections.slice(0, 24)) {
561
+ const label = connection.accountLabel
562
+ ? ` (${connection.accountLabel})`
563
+ : "";
564
+ lines.push(
565
+ ` - ${connection.toolkit} [${connection.status}]${label}${
566
+ connection.connectedAccountId
567
+ ? ` id=${connection.connectedAccountId}`
568
+ : ""
569
+ }`,
570
+ );
571
+ }
572
+ }
573
+ return lines.join("\n");
574
+ }
575
+
576
+ const READ_ONLY_TOOL_ACTIONS = new Set([
577
+ "FETCH",
578
+ "GET",
579
+ "LIST",
580
+ "SEARCH",
581
+ "READ",
582
+ "FIND",
583
+ ]);
584
+
585
+ function toolApprovalHint(toolSlug: string | undefined): string {
586
+ if (!toolSlug) return "approval unknown";
587
+ const tokens = toolSlug
588
+ .toUpperCase()
589
+ .split(/[^A-Z0-9]+/)
590
+ .filter(Boolean);
591
+ const readOnly = tokens.find((token) => READ_ONLY_TOOL_ACTIONS.has(token));
592
+ return readOnly ? `read-only (${readOnly})` : "approval likely";
593
+ }
594
+
595
+ function formatSearchResult(searchResult: unknown): string {
596
+ const root = asRecord(searchResult);
597
+ const data = asRecord(root?.data) ?? root;
598
+ const items = extractSearchItems(data);
599
+ const connectedSet = connectedToolkitSet(data);
600
+
601
+ if (items.length === 0) {
602
+ return [
603
+ "No matching tools found.",
604
+ "",
605
+ "`dench tool search` is a KEYWORD search over Composio's tool",
606
+ "names and descriptions, not a semantic / natural-language search.",
607
+ "Retry with one or two concrete tokens that likely appear in the",
608
+ 'tool slug or its docs ("fetch emails", "send message", "create',
609
+ 'issue", "list channels"). Drop filter words like "today" /',
610
+ '"unread" — those go in the EXECUTED tool\'s --args, not in the',
611
+ "catalog query. Pass --toolkit <slug> to narrow further.",
612
+ "",
613
+ "Use --json if you need the raw provider response.",
614
+ ].join("\n");
615
+ }
616
+
617
+ const lines = [
618
+ `Top tools${stringField(data, "query") ? ` for "${stringField(data, "query")}"` : ""}:`,
619
+ ];
620
+ for (const [index, tool] of items.entries()) {
621
+ const slug = stringField(tool, "slug", "tool_slug") ?? "unknown_tool";
622
+ const name = stringField(tool, "display_name", "name") ?? slug;
623
+ const toolkit =
624
+ stringField(tool, "toolkit_slug") ??
625
+ stringField(asRecord(tool.toolkit), "slug", "name") ??
626
+ "unknown";
627
+ const isConnected =
628
+ booleanField(asRecord(tool.connection_status), "is_connected") ??
629
+ connectedSet.has(toolkit.toLowerCase());
630
+ const description = compactLine(tool.description) ?? null;
631
+ lines.push(
632
+ `${index + 1}. ${slug} - ${name}`,
633
+ ` toolkit: ${toolkit} | ${toolApprovalHint(slug)} | ${
634
+ isConnected ? "connected" : "not connected"
635
+ }`,
636
+ );
637
+ if (description) lines.push(` ${description}`);
638
+ }
639
+ lines.push(
640
+ "",
641
+ "Use dench tool run <slug> --args '{...}' when ready.",
642
+ "Use --json only when you need full schemas or raw arguments.",
643
+ );
644
+ return lines.join("\n");
645
+ }
646
+
647
+ function extractSearchItems(data: JsonRecord | undefined): JsonRecord[] {
648
+ const direct = asRecordArray(data?.items);
649
+ if (direct.length > 0) return direct;
650
+ const schemas = asRecord(data?.tool_schemas);
651
+ if (!schemas) return [];
652
+ return Object.entries(schemas).flatMap<JsonRecord>(([slug, value]) => {
653
+ const tool = asRecord(value);
654
+ return tool
655
+ ? [{ ...tool, tool_slug: stringField(tool, "tool_slug") ?? slug }]
656
+ : [];
657
+ });
658
+ }
659
+
660
+ function connectedToolkitSet(data: JsonRecord | undefined): Set<string> {
661
+ const set = new Set<string>();
662
+ for (const value of asRecordArray(data?.toolkit_connection_statuses)) {
663
+ if (booleanField(value, "has_active_connection")) {
664
+ const slug = stringField(value, "toolkit");
665
+ if (slug) set.add(slug.toLowerCase());
666
+ }
667
+ }
668
+ if (Array.isArray(data?.connected_toolkits)) {
669
+ for (const value of data?.connected_toolkits ?? []) {
670
+ if (typeof value === "string") set.add(value.toLowerCase());
671
+ }
672
+ }
673
+ return set;
674
+ }
675
+
676
+ // ---------------------------------------------------------------------------
677
+ // Redaction (display-only)
678
+ // ---------------------------------------------------------------------------
679
+
680
+ const SENSITIVE_KEY_RE =
681
+ /(?:otp|oneTime|verification|security|passcode|password|secret|api[_-]?key|access[_-]?token|refresh[_-]?token|authorization)/i;
682
+
683
+ function redactForDisplay(value: unknown, key?: string): unknown {
684
+ if (typeof value === "string") {
685
+ return key && SENSITIVE_KEY_RE.test(key)
686
+ ? "[redacted]"
687
+ : value
688
+ .replace(
689
+ /\b((?:one[-\s]?time|verification|security|login|sign[-\s]?in|auth(?:entication)?|2fa|mfa|otp|passcode|code)[^.\n\r]{0,80}?)(\d[\d\s-]{2,10}\d)\b/gi,
690
+ "$1[redacted-code]",
691
+ )
692
+ .replace(
693
+ /\b((?:api[_\s-]?key|secret|token|password)[^.\n\r]{0,60}?)([A-Za-z0-9_=-]{12,})\b/gi,
694
+ "$1[redacted-secret]",
695
+ );
696
+ }
697
+ if (typeof value === "number" && key && SENSITIVE_KEY_RE.test(key)) {
698
+ return "[redacted]";
699
+ }
700
+ if (Array.isArray(value)) {
701
+ return value.map((item) => redactForDisplay(item));
702
+ }
703
+ const record = asRecord(value);
704
+ if (!record) return value;
705
+ return Object.fromEntries(
706
+ Object.entries(record).map(([entryKey, entryValue]) => [
707
+ entryKey,
708
+ redactForDisplay(entryValue, entryKey),
709
+ ]),
710
+ );
711
+ }
712
+
713
+ // ---------------------------------------------------------------------------
714
+ // Help text
715
+ // ---------------------------------------------------------------------------
716
+
717
+ function toolHelp() {
718
+ console.log(`Dench tool commands
719
+
720
+ Usage:
721
+ dench apps [--json]
722
+ dench tool status [toolkit] [--json]
723
+ dench tool connect <toolkit> [--callback-url <url>] [--json]
724
+ dench tool search "create github issue" [--toolkit github] [--limit 20] [--json]
725
+ dench tool run <composio_tool_slug> --args '{"key":"value"}' [--account <connectedAccountId>] [--json]
726
+ dench tool disconnect <connectionId> [--json]
727
+
728
+ Notes:
729
+ All commands authenticate with DENCH_API_KEY (Bearer) directly to the
730
+ Dench Cloud Gateway. No \`dench login\` agent session required.
731
+ dench tool connect prints the OAuth redirect URL for the human to open.
732
+ dench tool run output is redacted for display; pass --json for raw output.
733
+ Override the gateway base with DENCH_GATEWAY_URL or GATEWAY_URL.
734
+ `);
735
+ }
736
+
737
+ // ---------------------------------------------------------------------------
738
+ // Tiny utility helpers (local copies — keeps this file self-contained)
739
+ // ---------------------------------------------------------------------------
740
+
741
+ type JsonRecord = Record<string, unknown>;
742
+
743
+ function asRecord(value: unknown): JsonRecord | undefined {
744
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
745
+ return undefined;
746
+ }
747
+ return value as JsonRecord;
748
+ }
749
+
750
+ function asRecordArray(value: unknown): JsonRecord[] {
751
+ return Array.isArray(value)
752
+ ? value.flatMap<JsonRecord>((item) => asRecord(item) ?? [])
753
+ : [];
754
+ }
755
+
756
+ function stringField(
757
+ record: JsonRecord | undefined,
758
+ ...names: string[]
759
+ ): string | undefined {
760
+ if (!record) return undefined;
761
+ for (const name of names) {
762
+ const value = record[name];
763
+ if (typeof value === "string" && value.trim()) return value.trim();
764
+ }
765
+ return undefined;
766
+ }
767
+
768
+ function booleanField(
769
+ record: JsonRecord | undefined,
770
+ ...names: string[]
771
+ ): boolean | undefined {
772
+ if (!record) return undefined;
773
+ for (const name of names) {
774
+ const value = record[name];
775
+ if (typeof value === "boolean") return value;
776
+ }
777
+ return undefined;
778
+ }
779
+
780
+ function compactLine(value: unknown, maxLength = 140): string | undefined {
781
+ if (typeof value !== "string") return undefined;
782
+ const text = value.replace(/\s+/g, " ").trim();
783
+ if (!text) return undefined;
784
+ return text.length > maxLength ? `${text.slice(0, maxLength - 1)}...` : text;
785
+ }
786
+
787
+ function errorMessage(error: unknown): string {
788
+ return error instanceof Error ? error.message : String(error);
789
+ }
790
+
791
+ function parsePositiveInt(name: string, raw: string): number {
792
+ const parsed = Number(raw);
793
+ if (!Number.isFinite(parsed) || parsed <= 0) {
794
+ throw new ToolCliError(`Invalid ${name} value: ${raw}`);
795
+ }
796
+ return Math.floor(parsed);
797
+ }
798
+
799
+ // Re-export shared helpers used implicitly above so eslint doesn't flag
800
+ // them as unused when callers tree-shake.
801
+ export { ToolCliError, GatewayHttpError, MissingApiKeyError };
802
+ // Kept reachable so a future caller can build their own dispatcher.
803
+ export { resolveGatewayBaseUrl, requireApiKey, callGateway };
804
+ // shiftRaw / hasFlag from cli-args are not used in this module yet but
805
+ // keep the import group consistent with cli/search.ts conventions.
806
+ void shiftRaw;
807
+ void hasFlag;
808
+ void CliArgError;