/** * `dench tool` and `dench integrations` — direct Dench Cloud Gateway access for * Composio's get-apps / get-tools / execute-tool surface. * * Mirrors `denchclaw`'s pattern: this CLI is a thin wrapper over five * gateway endpoints, authenticated with either `DENCH_API_KEY` or a * paid-workspace gateway key resolved from the active agent session by * the top-level dispatcher. * * Dench-brokered connections are not pure gateway passthroughs: * - the connection LISTING: Gmail, Outlook, Calendar and LinkedIn (Unipile) * and Slack (Dench's in-house Slack app) are brokered by Dench, not * Composio, so `status` also reads them from Convex and merges the two * into one list. See `fetchUnipileConnections`. * - `connect slack`: prints the in-house Slack install URL * (`/api/slack/install?slug=…`) instead of a Composio OAuth redirect — * the in-house connection always takes preference over Composio Slack. * - Gmail, Outlook, Calendar and LinkedIn connect through the same Unipile * hosted-auth actions as the web app, authenticated by the Dench session. * * Subcommands: * * dench integrations [--json] — every connection, both brokers * dench tool status [toolkit?] [--json] — same list (optionally narrowed) * dench tool search "" [--toolkit ] [--limit N] [--compact] [--json] * dench tool run [--args ] [--account ] [--json] * dench tool connect [--callback-url ] [--json] — print OAuth redirect URL * dench tool disconnect [--json] * * Endpoints used (all `Authorization: Bearer `): * * GET /v1/composio/connections * GET /v1/composio/toolkits?search= * POST /v1/composio/tools/search { query, toolkit_slug?, limit? } * POST /v1/composio/tools/execute { tool_slug, arguments, connected_account_id? } * POST /v1/composio/connect { toolkit, callback_url } * DELETE /v1/composio/connections/ * * Output parity: this module preserves the JSON shape and human * formatting that the previous Convex-routed implementation produced * (compact ranked search, "Connect here: ", redacted * run output) so any agent that already parses `dench tool ...` keeps * working. */ import type { ConvexHttpClient } from "convex/browser"; import { makeFunctionReference } from "convex/server"; import { PRODUCTION_API_URL, PRODUCTION_GATEWAY_URL } from "./host"; import { CliArgError, getFlag, hasFlag, parseJson, shift as shiftRaw, } from "./lib/cli-args"; import { type ConnectionSource, connectionSource, } from "./lib/connection-source"; import { normalizeGatewayBaseUrl, stripTrailingSlashes, } from "./lib/gateway-url"; import { isNativeToolRecord, nativeToolkit, nativeToolkitForTool, } from "./lib/native-providers"; import { normalizeUnipileToolkitSlug, unipileConnectKindFromToolkitSlug, } from "./lib/unipile-toolkits"; export { type ConnectionSource, connectionSource }; // --------------------------------------------------------------------------- // Public dispatcher // --------------------------------------------------------------------------- export type ToolCliContext = { args: string[]; jsonOutput: boolean; bearerToken?: string; gatewayBaseUrl?: string; gatewayHeaders?: Record; gatewayAuthError?: string; /** Resolve the gateway only when a third-party operation actually needs it. */ resolveGatewayAuth?: () => Promise; /** * Convex client + bearer for the Unipile half of the list. Absent on the * help path and whenever the caller could not be authenticated, in which * case the command still runs and reports Composio only. */ convex?: ConvexHttpClient; sessionToken?: string; /** * The session's app host + workspace slug, when the dispatcher knows them. * Used to build first-party links (the in-house Slack install URL) against * the RIGHT tier — a staging session must not print a production URL. */ appOrigin?: string; workspaceSlug?: string; }; type GatewayAuthContext = Pick< ToolCliContext, | "bearerToken" | "gatewayBaseUrl" | "gatewayHeaders" | "gatewayAuthError" | "resolveGatewayAuth" >; type UnipileAuthContext = Pick; export async function runToolCommand(ctx: ToolCliContext): Promise { const sub = ctx.args[0]; if (!sub || sub === "help" || sub === "--help" || sub === "-h") { toolHelp(); return; } ctx.args.shift(); if (sub === "status") { await runStatusSubcommand(ctx); return; } if (sub === "search") { await runSearchSubcommand(ctx); return; } if (sub === "run") { await runRunSubcommand(ctx); return; } if (sub === "connect") { await runConnectSubcommand(ctx); return; } if (sub === "disconnect") { await runDisconnectSubcommand(ctx); return; } throw new ToolCliError(`Unknown dench tool subcommand: ${sub}`); } const listUnipileConnectionsRef = makeFunctionReference<"query">( "functions/unipileConnections:listConnections", ); const listUnipileToolsRef = makeFunctionReference<"query">( "functions/unipileTools:listTools", ); const getUnipileToolRef = makeFunctionReference<"query">( "functions/unipileTools:getTool", ); const runUnipileToolRef = makeFunctionReference<"action">( "functions/unipileToolsNode:runTool", ); const connectMailboxRef = makeFunctionReference<"action">( "functions/emailMailboxUnipileNode:createHostedAuthLink", ); const connectLinkedinRef = makeFunctionReference<"action">( "functions/linkedinUnipileNode:createHostedAuthLink", ); const connectCalendarRef = makeFunctionReference<"action">( "functions/calendarUnipileNode:createCalendarHostedAuthLink", ); /** Native ownership survives empty searches, disconnections and registry failures. */ function withoutShadowedComposioTools(items: JsonRecord[]): JsonRecord[] { return items.filter((item) => !isNativeToolRecord(item)); } /** * Gmail, Outlook, Calendar and LinkedIn — the connections Dench brokers itself * through Unipile rather than Composio. * * They come from Convex, not the gateway, so this is a second fetch rather than * a richer parse of the first. It NEVER throws: a CLI ships independently of * the deployment it talks to (npm publish + a re-baked Daytona snapshot), so a * new CLI will meet backends that do not export this query yet. Failing the * whole command over that would take `dench integrations` from "missing the * calendar" to "broken", which is strictly worse. */ export async function fetchUnipileConnections( ctx: UnipileAuthContext, ): Promise<{ available: boolean; connections: CompactConnection[]; error?: string; }> { if (!ctx.convex) { return { available: false, connections: [], error: "no Dench session — sign in with `dench signin`", }; } try { const rows = await ctx.convex.query(listUnipileConnectionsRef, { ...(ctx.sessionToken ? { sessionToken: ctx.sessionToken } : {}), } as never); if (!Array.isArray(rows)) return { available: true, connections: [] }; return { available: true, connections: rows.map((row) => compactUnipileConnection(asRecord(row) ?? {}), ), }; } catch (error) { return { available: false, connections: [], error: errorMessage(error) }; } } /** * Summarise the user's connected apps for `dench context` and friends. * Speaks straight to the gateway with either DENCH_API_KEY or a gateway * key resolved from the active agent session, and merges in the Unipile * connections Composio never reports. * Returns the same legacy shape the previous Convex-routed path did so * existing parsers don't need to change. */ export async function fetchConnectedAppsSummary( ctx: GatewayAuthContext & UnipileAuthContext = {}, ): Promise<{ available: boolean; count: number; connections: CompactConnection[]; nativeAvailable: boolean; nativeError?: string; error?: string; reason?: string; }> { const unipile = await fetchUnipileConnections(ctx); const unipileFields = { nativeAvailable: unipile.available, ...(unipile.error ? { nativeError: unipile.error } : {}), }; if (ctx.gatewayAuthError) { return { available: false, count: unipile.connections.length, connections: unipile.connections, ...unipileFields, reason: ctx.gatewayAuthError, }; } let payload: unknown; try { payload = await callGateway(ctx, "GET", "/v1/composio/connections"); } catch (error) { // Composio being unreachable must not hide a connected calendar, so the // Unipile rows are still reported alongside the failure. const failure = { available: false, count: unipile.connections.length, connections: unipile.connections, ...unipileFields, }; return ctx.gatewayAuthError ? { ...failure, reason: ctx.gatewayAuthError } : error instanceof MissingApiKeyError ? { ...failure, reason: error.message } : { ...failure, error: errorMessage(error) }; } const connections = [ ...extractConnections(payload) .map(compactConnection) .filter((connection) => !nativeToolkit(connection.toolkit)), ...unipile.connections, ]; return { available: true, count: connections.length, connections, ...unipileFields, }; } // --------------------------------------------------------------------------- // Subcommands // --------------------------------------------------------------------------- async function runStatusSubcommand(ctx: ToolCliContext): Promise { const requestedToolkit = ctx.args[0]?.startsWith("--") ? undefined : ctx.args[0]; if (requestedToolkit) ctx.args.shift(); const unipileToolkit = requestedToolkit ? nativeToolkit(requestedToolkit) : null; const filterToolkit = unipileToolkit ?? requestedToolkit; let connectionsSummary: Awaited>; if (unipileToolkit) { const unipile = await fetchUnipileConnections(ctx); connectionsSummary = { available: unipile.available, count: unipile.connections.length, connections: unipile.connections, nativeAvailable: unipile.available, ...(unipile.error ? { nativeError: unipile.error } : {}), }; } else { connectionsSummary = await fetchConnectedAppsSummary(ctx); } let toolkitsPayload: unknown = null; if (filterToolkit && !unipileToolkit && connectionsSummary.available) { const search = encodeURIComponent(filterToolkit); toolkitsPayload = await callGateway( ctx, "GET", `/v1/composio/toolkits?search=${search}`, ); } const allConnections = connectionsSummary.connections; // Filtering after the merge is what makes `dench tool status gmail` answer // with the Unipile mailbox instead of nothing. const filteredConnections = filterToolkit ? allConnections.filter( (conn) => conn.toolkit.toLowerCase() === filterToolkit.toLowerCase() || (unipileToolkit === "calendar" && ["google-calendar", "outlook-calendar"].includes(conn.toolkit)), ) : allConnections; const summary = { ok: unipileToolkit ? connectionsSummary.nativeAvailable : true, available: connectionsSummary.available, toolkit: filterToolkit ?? null, count: filteredConnections.length, connections: filteredConnections, nativeAvailable: connectionsSummary.nativeAvailable, ...(connectionsSummary.nativeError ? { nativeError: connectionsSummary.nativeError } : {}), ...(connectionsSummary.error ? { error: connectionsSummary.error } : {}), ...(connectionsSummary.reason ? { reason: connectionsSummary.reason } : {}), ...(toolkitsPayload ? { toolkit_info: extractToolkits(toolkitsPayload) } : {}), }; if (ctx.jsonOutput) { console.log(JSON.stringify(summary, null, 2)); return; } console.log(formatStatusSummary(summary)); } async function runSearchSubcommand(ctx: ToolCliContext): Promise { const requestedToolkit = getFlag(ctx.args, "--toolkit"); const ownedToolkit = requestedToolkit ? nativeToolkit(requestedToolkit) : null; const toolkit = ownedToolkit ?? requestedToolkit; const limitRaw = getFlag(ctx.args, "--limit"); const limit = limitRaw ? parsePositiveInt("--limit", limitRaw) : undefined; const compact = hasFlag(ctx.args, "--compact"); const queryParts = ctx.args.slice(); ctx.args.length = 0; // Everything left is the query, so an unconsumed flag would be searched for // as a word and quietly match nothing. Refuse it instead: a typo that reads // as "no results" is indistinguishable from an empty catalog. const unknown = queryParts.find((part) => part.startsWith("--")); if (unknown) { throw new ToolCliError( `Unknown flag for dench tool search: ${unknown}. Supported: --toolkit , --limit N, --compact, --json.`, ); } const query = queryParts.join(" ").trim(); if (!query) { throw new ToolCliError( 'Usage: dench tool search "" [--toolkit ] [--limit N] [--compact] [--json]', ); } const body: Record = { query }; if (toolkit) body.toolkit_slug = toolkit; if (limit) body.limit = limit; const [gateway, unipile] = await Promise.all([ ownedToolkit ? Promise.resolve({ payload: {} as unknown, error: undefined as string | undefined, }) : callGateway(ctx, "POST", "/v1/composio/tools/search", body) .then((payload) => ({ payload, error: undefined as string | undefined, })) .catch((error) => ({ payload: {} as unknown, error: errorMessage(error), })), fetchUnipileTools(ctx, { query, ...(toolkit ? { toolkit } : {}) }), ]); const record = asRecord(gateway.payload) ?? {}; const merged = { query, items: [ ...unipile.tools, ...withoutShadowedComposioTools( extractSearchItems(asRecord(record.data) ?? record), ), ].slice(0, limit ?? 100), // Do not retain unfiltered nested tool_schemas/data from the gateway. connected_toolkits: [ ...[...connectedToolkitSet(asRecord(record.data) ?? record)].filter( (slug) => !nativeToolkit(slug), ), ], nativeAvailable: unipile.available, ...(unipile.error ? { nativeError: unipile.error } : {}), ...(gateway.error ? { gatewayError: gateway.error } : {}), ...(ownedToolkit ? { toolkit: ownedToolkit } : {}), }; if (ctx.jsonOutput) { console.log(JSON.stringify(merged, null, 2)); return; } console.log(formatSearchResult(merged, compact)); } /** * Resolve a native action without interpreting a broken registry as another provider. */ async function resolveUnipileTool( ctx: UnipileAuthContext, slug: string, ): Promise { if (!ctx.convex) { if (nativeToolkitForTool(slug)) throw new ToolCliError( "native_unavailable: Sign in to Dench to use this integration.", ); return null; } try { return ( asRecord(await ctx.convex.query(getUnipileToolRef, { slug } as never)) ?? null ); } catch (error) { if (nativeToolkitForTool(slug)) throw new ToolCliError(`native_unavailable: ${errorMessage(error)}`); return null; } } async function runUnipileTool( ctx: ToolCliContext, slug: string, toolArgs: Record, approvalId: string | undefined, ): Promise { if (!ctx.convex) throw new MissingApiKeyError(); const result = await ctx.convex.action(runUnipileToolRef, { ...(ctx.sessionToken ? { sessionToken: ctx.sessionToken } : {}), slug, arguments: toolArgs, ...(approvalId ? { approvalId } : {}), } as never); const record = asRecord(result) ?? {}; if (ctx.jsonOutput) { console.log(JSON.stringify(record, null, 2)); } else { console.log(formatUnipileToolResult(record)); } // Same convention as the Composio failures below: a refusal the agent is // expected to act on exits non-zero without throwing, so the payload above // is still the thing it reads. if (record.ok === false) process.exitCode = 1; } function formatUnipileToolResult(record: JsonRecord): string { if (record.ok === false) { const lines = [ `Failed: ${stringField(record, "error") ?? "unknown error"}`, ]; const approvalId = stringField(record, "approvalId"); if (approvalId) { lines.push( `Approval required. Once a human says yes:`, ` dench approval approve ${approvalId} --evidence "…"`, ` dench tool run ${stringField(record, "toolSlug") ?? ""} --args '…' --approval ${approvalId}`, ); } return lines.join("\n"); } if (record.queued === true) { const data = asRecord(record.data) ?? {}; return [ `Queued: ${stringField(data, "action_id") ?? "unknown"}`, "LinkedIn actions are paced to protect the account.", ` dench tool run LINKEDIN_GET_ACTION_STATUS --args '{"action_id":"${stringField(data, "action_id") ?? ""}"}'`, ].join("\n"); } return JSON.stringify(redactForDisplay(record), null, 2); } /** * The Unipile half of tool discovery. Never throws, for the same reason the * connection listing does not: the CLI ships on its own cadence, so a new CLI * will meet deployments that do not export these functions yet. That must cost * the Unipile tools, not the whole search. */ async function fetchUnipileTools( ctx: UnipileAuthContext, options: { query?: string; toolkit?: string } = {}, ): Promise<{ available: boolean; tools: JsonRecord[]; error?: string }> { if (!ctx.convex) { return { available: false, tools: [], error: "no Dench session — sign in with `dench signin`", }; } try { const rows = await ctx.convex.query(listUnipileToolsRef, { ...(ctx.sessionToken ? { sessionToken: ctx.sessionToken } : {}), ...(options.query ? { query: options.query } : {}), ...(options.toolkit ? { toolkit: options.toolkit } : {}), } as never); if (!Array.isArray(rows)) return { available: true, tools: [] }; return { available: true, tools: rows .map((row) => asRecord(row)) .filter((row): row is JsonRecord => row !== undefined) .map((row) => typeof row.source === "string" ? { ...row, source: connectionSource(row.source, "dench"), connection_status: { is_connected: true }, } : row, ), }; } catch (error) { return { available: false, tools: [], error: errorMessage(error) }; } } async function runRunSubcommand(ctx: ToolCliContext): Promise { const toolSlug = ctx.args[0]; if (!toolSlug || toolSlug.startsWith("--")) { throw new ToolCliError( 'Usage: dench tool run [--args \'{"key":"value"}\'] [--account ] [--json]', ); } ctx.args.shift(); const argsJson = getFlag(ctx.args, "--args"); const account = getFlag(ctx.args, "--account"); const approval = getFlag(ctx.args, "--approval"); const parsedArgs = (parseJson(argsJson) ?? {}) as Record; if ( parsedArgs && typeof parsedArgs === "object" && !Array.isArray(parsedArgs) ) { // ok } else { throw new ToolCliError("--args must be a JSON object"); } // Ask the server which broker owns this slug rather than keeping a copy of // the vocabulary here. Two hand-maintained lists is the drift CLAUDE.md // records for gateway usage providers, and here it would mean a real tool // routed to Composio, which 404s. const unipileTool = await resolveUnipileTool(ctx, toolSlug); if (unipileTool) { if (account) throw new ToolCliError( "--account is for third-party connections. Pass the native mailbox/account field in --args.", ); await runUnipileTool(ctx, toolSlug, parsedArgs, approval); return; } if (nativeToolkitForTool(toolSlug)) { throw new ToolCliError( `unsupported_native_capability: ${toolSlug} is not supported by Dench's native integration. For email drafts use dench email draft create; do not reconnect or try another provider.`, ); } const body: Record = { tool_slug: toolSlug, arguments: parsedArgs, }; if (account) body.connected_account_id = account; let payload: unknown; try { payload = await callGateway( ctx, "POST", "/v1/composio/tools/execute", body, ); } catch (error) { if (!(error instanceof GatewayHttpError)) throw error; // Ambiguity is the opposite of a missing connection, and it has to be // checked first: the gateway reports it with a 400 whose body also trips // the no-connection hints, which would send the caller off to add a third // account when the fix is to name one of the two they have. if (isAccountSelectionRequired(error)) { const selection = accountSelectionPayload(toolSlug, error); console.log( ctx.jsonOutput ? JSON.stringify(selection, null, 2) : formatAccountSelection(selection), ); process.exitCode = 1; return; } if (isLikelyNoConnection(error)) { const noConnPayload = noConnectionPayload(toolSlug, error); if (ctx.jsonOutput) { console.log(JSON.stringify(noConnPayload, null, 2)); } else { console.log(formatNoConnection(noConnPayload)); } process.exitCode = 1; return; } throw error; } if (ctx.jsonOutput) { console.log(JSON.stringify(payload, null, 2)); return; } console.log(redactForDisplay(payload)); } const getAgentBillingStatusRef = makeFunctionReference<"query">( "functions/agentWorkspace:getAgentBillingStatus", ); /** * The active workspace's slug, for building the in-house Slack install URL. * Best-effort — a failure yields null and the caller prints a placeholder the * human can fill in, rather than the whole command failing. */ async function resolveWorkspaceSlug( ctx: UnipileAuthContext, ): Promise { if (!ctx.convex) return null; try { const result = await ctx.convex.query(getAgentBillingStatusRef, { ...(ctx.sessionToken ? { sessionToken: ctx.sessionToken } : {}), } as never); return stringField(asRecord(asRecord(result)?.workspace), "slug") ?? null; } catch { return null; } } /** * Slack connects through Dench's OWN Slack app — the in-house install that * powers both the messaging channel (DM / @mention the bot) and the agent * SLACK_* tools. It is never connected through Composio, so `dench tool * connect slack` prints the first-party OAuth install URL instead of asking * the gateway for a Composio redirect. Admin-only: the URL enforces org-admin * membership itself. */ async function runSlackConnectSubcommand( ctx: ToolCliContext, toolkit: string, ): Promise { const slug = ctx.workspaceSlug?.trim() || (await resolveWorkspaceSlug(ctx)); const origin = stripTrailingSlashes( ctx.appOrigin?.trim() || resolveAppPublicOrigin(), ); const redirectUrl = `${origin}/api/slack/install?slug=${ slug ? encodeURIComponent(slug) : "" }`; const message = `Connect Slack here (org admin only): ${redirectUrl}`; const summary = { ok: true, toolkit, inHouse: true, redirectUrl, message, note: "Slack is Dench's in-house integration. One install powers " + "both the Slack messaging channel and the agent SLACK_* tools " + "(SLACK_SEND_MESSAGE, SLACK_FETCH_HISTORY, …).", }; if (ctx.jsonOutput) { console.log(JSON.stringify(summary, null, 2)); return; } console.log(message); console.log( "Slack is Dench's in-house integration. Once an admin installs it,", ); console.log( "run tools with `dench tool run SLACK_SEND_MESSAGE --args '{…}'` — see `dench tool search --toolkit slack`.", ); } async function runConnectSubcommand(ctx: ToolCliContext): Promise { const toolkit = ctx.args[0]; if (!toolkit || toolkit.startsWith("--")) { throw new ToolCliError( "Usage: dench tool connect [--callback-url ] [--json]", ); } ctx.args.shift(); const normalizedToolkit = toolkit.trim().toLowerCase(); // Hosted chat owns the human connection UI. Do not mint OAuth credentials // in a model command or journal them in a tool result. External CLI clients // retain their ordinary link-based flow. if (process.env.DENCH_CHAT_ARTIFACTS === "1") { console.log( JSON.stringify({ ok: true, kind: "connect", toolkit: nativeToolkit(toolkit) ?? normalizedToolkit, action: "connect", }), ); return; } if (normalizedToolkit === "slack" || normalizedToolkit === "slackbot") { await runSlackConnectSubcommand(ctx, normalizedToolkit); return; } const unipile = unipileConnectKindFromToolkitSlug(toolkit); if (unipile) { if (!ctx.convex || !ctx.sessionToken) { throw new ToolCliError( "Sign in with `dench signin` or use a member-owned Dench API key to connect this integration.", ); } if (ctx.args.includes("--callback-url")) { throw new ToolCliError( "Dench-connected accounts use Dench's managed callback. --callback-url is only supported for connected apps.", ); } const result = asRecord( await ctx.convex.action( unipile.kind === "mailbox" ? connectMailboxRef : unipile.kind === "linkedin" ? connectLinkedinRef : connectCalendarRef, { sessionToken: ctx.sessionToken, ...(unipile.kind === "mailbox" ? { provider: unipile.provider } : {}), }, ), ); const redirectUrl = stringField(result, "link"); if (!redirectUrl) { throw new ToolCliError("Dench did not return a connection link."); } const message = `Connect ${toolkit} here: ${redirectUrl}`; if (ctx.jsonOutput) { console.log( JSON.stringify( { ok: true, source: "dench", toolkit: normalizeUnipileToolkitSlug(toolkit), redirectUrl, expiresAt: result?.expiresAt, connectionId: null, message, }, null, 2, ), ); } else { console.log(message); } return; } const callbackUrl = getFlag(ctx.args, "--callback-url") ?? process.env.DENCH_OAUTH_CALLBACK_URL?.trim() ?? `${resolveAppPublicOrigin()}/api/composio/callback`; const payload = await callGateway(ctx, "POST", "/v1/composio/connect", { toolkit, callback_url: callbackUrl, }); const record = asRecord(payload); const redirectUrl = stringField(record, "redirect_url", "redirectUrl", "url") ?? stringField(asRecord(record?.data), "redirect_url", "redirectUrl", "url"); const summary = { ok: true, toolkit, callbackUrl, redirectUrl: redirectUrl ?? null, connectionId: stringField(record, "connection_id", "connectionId") ?? stringField(asRecord(record?.data), "connection_id", "connectionId") ?? null, ...(redirectUrl ? { message: `Connect ${toolkit} here: ${redirectUrl}` } : {}), raw: payload, }; if (ctx.jsonOutput) { console.log(JSON.stringify(summary, null, 2)); return; } if (redirectUrl) { console.log(`Connect ${toolkit} here: ${redirectUrl}`); return; } console.log(JSON.stringify(payload, null, 2)); } async function runDisconnectSubcommand(ctx: ToolCliContext): Promise { const connectionId = ctx.args[0]; if (!connectionId || connectionId.startsWith("--")) { throw new ToolCliError( "Usage: dench tool disconnect [--json]", ); } ctx.args.shift(); const path = `/v1/composio/connections/${encodeURIComponent(connectionId)}`; let payload: unknown = { ok: true, deleted: true }; try { payload = await callGateway(ctx, "DELETE", path); } catch (error) { // Composio returns 404 for connections that are already gone — surface // that as a soft success so callers can keep their local state in sync. if (error instanceof GatewayHttpError && error.status === 404) { payload = { ok: true, deleted: true, alreadyGone: true }; } else { throw error; } } if (ctx.jsonOutput) { console.log(JSON.stringify(payload, null, 2)); return; } const record = asRecord(payload); if (record?.alreadyGone) { console.log(`Connection ${connectionId} was already disconnected.`); return; } console.log(`Disconnected ${connectionId}.`); } // --------------------------------------------------------------------------- // HTTP plumbing // --------------------------------------------------------------------------- class ToolCliError extends Error { constructor(message: string) { super(message); this.name = "ToolCliError"; } } class MissingApiKeyError extends ToolCliError { constructor() { super( "No Dench gateway credential found. Set DENCH_API_KEY or run " + "`dench signin` for a paid workspace.", ); this.name = "MissingApiKeyError"; } } class GatewayHttpError extends ToolCliError { status: number; body: string; path: string; constructor(args: { status: number; body: string; path: string }) { const truncated = args.body.length > 500 ? `${args.body.slice(0, 500)}…` : args.body; super(`Dench gateway error (${args.status}): ${truncated || "(empty)"}`); this.name = "GatewayHttpError"; this.status = args.status; this.body = args.body; this.path = args.path; } } function resolveGatewayBaseUrl( ctx?: Pick, ): string { if (ctx?.gatewayBaseUrl?.trim()) { return normalizeGatewayBaseUrl(ctx.gatewayBaseUrl.trim()); } const explicit = process.env.DENCH_GATEWAY_URL?.trim() || process.env.GATEWAY_URL?.trim(); if (explicit) return normalizeGatewayBaseUrl(explicit); const host = process.env.DENCH_HOST?.trim(); if (host && /localhost|127\.0\.0\.1/.test(host)) { return "http://localhost:8787"; } return PRODUCTION_GATEWAY_URL; } /** * Best-effort guess at the Next.js host that should receive the * Composio OAuth callback when an agent runs `dench tool connect` * outside the browser. Sandboxes set `DENCH_API_URL` / * `DENCH_INTERNAL_BASE` / `DENCH_HOST` already; pick whichever one is * available so the redirect lands on the right deployment. Falls back * to production. */ function resolveAppPublicOrigin(): string { const candidates = [ process.env.DENCH_API_URL, process.env.DENCH_INTERNAL_BASE, process.env.DENCH_HOST, process.env.NEXT_PUBLIC_APP_URL, process.env.NEXT_PUBLIC_SITE_URL, ]; for (const candidate of candidates) { const trimmed = candidate?.trim(); if (trimmed) return trimmed.replace(/\/+$/, ""); } return PRODUCTION_API_URL; } function requireBearerToken(ctx?: Pick): string { const bearerToken = ctx?.bearerToken?.trim() || process.env.DENCH_API_KEY?.trim(); if (!bearerToken) throw new MissingApiKeyError(); return bearerToken; } async function callGateway( ctx: GatewayAuthContext, method: "GET" | "POST" | "DELETE", path: string, body?: Record, ): Promise { if (ctx.resolveGatewayAuth && !ctx.bearerToken) { const resolve = ctx.resolveGatewayAuth; ctx.resolveGatewayAuth = undefined; Object.assign(ctx, await resolve()); } const bearerToken = requireBearerToken(ctx); const baseUrl = resolveGatewayBaseUrl(ctx); const init: RequestInit = { method, headers: { "content-type": "application/json", authorization: `Bearer ${bearerToken}`, ...(ctx.gatewayHeaders ?? {}), }, }; if (body !== undefined) init.body = JSON.stringify(body); const response = await fetch(`${baseUrl}${path}`, init); if (!response.ok) { const detail = await response.text().catch(() => ""); throw new GatewayHttpError({ status: response.status, body: detail, path }); } if (response.status === 204) return null; const contentType = response.headers.get("content-type") ?? ""; if (!contentType.includes("application/json")) { return response.text(); } return response.json(); } // --------------------------------------------------------------------------- // Result extraction & formatting // --------------------------------------------------------------------------- const ACCOUNT_SELECTION_CODE = "composio_account_selection_required"; const NO_CONNECTION_HINTS = [ "no active connection", "no connection found", "connected_account_id is required", "is not active or does not exist", "account is not active", "account does not exist", "connection has been disabled", "connection is disabled", ]; function isLikelyNoConnection(error: GatewayHttpError): boolean { if (![400, 404, 422].includes(error.status)) return false; const lower = error.body.toLowerCase(); return NO_CONNECTION_HINTS.some((hint) => lower.includes(hint)); } function isAccountSelectionRequired(error: GatewayHttpError): boolean { return error.body.toLowerCase().includes(ACCOUNT_SELECTION_CODE); } function accountSelectionPayload(toolSlug: string, error: GatewayHttpError) { const accounts = accountChoices(error.body); return { ok: false, account_selection_required: true, tool_slug: toolSlug, toolkit: toolkitFromSlug(toolSlug), accounts, error: error.message, next_actions: accounts.length ? accounts.map( (account) => `Re-run with --account ${account.connected_account_id}${account.label ? ` (${account.label})` : ""}`, ) : [ "Re-run with --account . Run `dench tool status --json` to list them.", ], }; } /** * The candidate accounts named in an account-selection error. The gateway * already lists them, so parse them back out rather than spending a round trip * re-listing connections the caller was just handed. * * They arrive as a JSON array inside the JSON-encoded error message, so decode * the body first: that hands the array back unescaped instead of leaving us to * undo the escaping by hand. */ function accountChoices(body: string) { const error = asRecord(asRecord(parseJsonOrUndefined(body))?.error); const message = stringField(error, "message") ?? ""; const start = message.indexOf("["); const end = message.lastIndexOf("]"); if (start === -1 || end <= start) return []; const parsed = parseJsonOrUndefined(message.slice(start, end + 1)); return asRecordArray(parsed).flatMap((entry) => { const id = stringField(entry, "connected_account_id"); if (!id) return []; return [ { connected_account_id: id, label: stringField(entry, "label", "email") }, ]; }); } function parseJsonOrUndefined(value: string): unknown { try { return JSON.parse(value); } catch { return undefined; } } function formatAccountSelection( payload: ReturnType, ) { return [ "Multiple active accounts for this tool — name the one to use.", `Tool: ${payload.tool_slug}`, payload.toolkit ? `Toolkit: ${payload.toolkit}` : null, "", "Next actions:", ...payload.next_actions.map((line) => ` - ${line}`), ] .filter((line) => line !== null) .join("\n"); } function noConnectionPayload(toolSlug: string, error: GatewayHttpError) { const toolkit = toolkitFromSlug(toolSlug); return { ok: false, no_connection: true, tool_slug: toolSlug, toolkit, error: error.message, next_actions: toolkit ? [ `Run \`dench tool connect ${toolkit}\` to start the OAuth flow, then re-run this command.`, ] : [ "Run `dench tool connect ` to start the OAuth flow, then re-run this command.", ], }; } function formatNoConnection(payload: ReturnType) { const lines = [ "No active connection for this tool.", `Tool: ${payload.tool_slug}`, payload.toolkit ? `Toolkit: ${payload.toolkit}` : null, "", "Next actions:", ...payload.next_actions.map((line) => ` - ${line}`), ]; return lines.filter((line) => line !== null).join("\n"); } function toolkitFromSlug(toolSlug: string): string | undefined { const head = toolSlug.split("_")[0]; return head ? head.toLowerCase() : undefined; } function extractConnections(payload: unknown): JsonRecord[] { const root = asRecord(payload); const candidates = [ asRecordArray(root?.connections), asRecordArray(root?.items), asRecordArray(asRecord(root?.data)?.items), asRecordArray(asRecord(root?.data)?.connections), asRecordArray(root?.data), ]; for (const candidate of candidates) { if (candidate.length > 0) return candidate; } if (Array.isArray(payload)) return payload.filter((entry) => asRecord(entry)) as JsonRecord[]; return []; } function extractToolkits(payload: unknown): JsonRecord[] { const root = asRecord(payload); return ( asRecordArray(root?.items) || asRecordArray(root?.toolkits) || asRecordArray(root?.data) || [] ); } /** * The merged row shape. `source` is the discriminator: `app` rows carry a * routable `connectedAccountId` (usable as `dench tool run --account`), while * `dench` rows never do — their account id is an internal credential. */ export type CompactConnection = { source: ConnectionSource; toolkit: string; status: string; connectedAccountId: string | null; accountLabel: string | null; /** Unipile only: whether the connection belongs to this agent's member. */ isOwner?: boolean; ownerName?: string; /** * Unipile only. A teammate's `private` connection is never listed at all, so * `private` here always means the caller's own. */ visibility?: "private" | "shared"; }; function compactConnection(connection: JsonRecord): CompactConnection { return { source: connectionSource(connection.source, "app"), toolkit: stringField(connection, "toolkit_slug", "toolkit") ?? stringField(asRecord(connection.toolkit), "slug", "name") ?? "unknown", status: stringField(connection, "status", "connection_status") ?? "unknown", connectedAccountId: stringField( connection, "connected_account_id", "connectedAccountId", "id", ) ?? null, accountLabel: stringField(connection, "account_label", "accountLabel", "label") ?? null, }; } function compactUnipileConnection(connection: JsonRecord): CompactConnection { return { source: connectionSource(connection.source, "dench"), toolkit: stringField(connection, "toolkit") ?? "unknown", status: stringField(connection, "status") ?? "unknown", connectedAccountId: null, accountLabel: stringField(connection, "accountLabel") ?? null, isOwner: connection.isOwner === true, ownerName: stringField(connection, "ownerName") ?? undefined, visibility: connection.visibility === "private" ? "private" : "shared", }; } function formatConnectionLine(connection: CompactConnection): string { const label = connection.accountLabel ? ` (${connection.accountLabel})` : ""; const id = connection.connectedAccountId ? ` id=${connection.connectedAccountId}` : ""; // "via Dench" both names the broker and explains why the row has no // connected-account id to pass to `dench tool run`. const via = connection.source === "dench" ? " via Dench" : ""; // Whose mailbox/LinkedIn it is only matters when it is not this agent's — // `dench email --mailbox` picks between them. const owner = connection.source === "dench" && connection.isOwner === false && connection.ownerName ? ` · ${connection.ownerName}` : ""; // Only ever the caller's own — a teammate's private connection is filtered // out server-side rather than shown as unusable. const privacy = connection.visibility === "private" ? " · private" : ""; return ` - ${connection.toolkit} [${connection.status}]${label}${id}${via}${owner}${privacy}`; } function formatStatusSummary(summary: { toolkit: string | null; count: number; connections: CompactConnection[]; nativeAvailable?: boolean; nativeError?: string; reason?: string; error?: string; }): string { const lines: string[] = []; if (summary.toolkit) { lines.push(`Connections for ${summary.toolkit}:`); } else { lines.push("Connected apps:"); } if (summary.connections.length === 0) { lines.push(" none"); } else { lines.push(...summary.connections.slice(0, 24).map(formatConnectionLine)); } // Say so rather than silently listing Composio only — otherwise a connected // calendar reads as absent, which is the failure this merge exists to fix. if (summary.nativeAvailable === false) { lines.push( ` (could not check Gmail/Outlook/Calendar/LinkedIn: ${ summary.nativeError ?? "unknown error" })`, ); } if (summary.reason || summary.error) { lines.push( ` (could not check connected apps: ${summary.reason ?? summary.error})`, ); } return lines.join("\n"); } const READ_ONLY_TOOL_ACTIONS = new Set([ "FETCH", "GET", "LIST", "SEARCH", "READ", "FIND", ]); function toolApprovalHint(toolSlug: string | undefined): string { if (!toolSlug) return "approval unknown"; const tokens = toolSlug .toUpperCase() .split(/[^A-Z0-9]+/) .filter(Boolean); const readOnly = tokens.find((token) => READ_ONLY_TOOL_ACTIONS.has(token)); return readOnly ? `read-only (${readOnly})` : "approval likely"; } function formatSearchResult(searchResult: unknown, compact = false): string { const root = asRecord(searchResult); const data = asRecord(root?.data) ?? root; const items = extractSearchItems(data); const connectedSet = connectedToolkitSet(data); if ( items.length === 0 && typeof data?.toolkit === "string" && nativeToolkit(data.toolkit) ) { return `No matching native action for ${data.toolkit}. For email drafts use dench email draft create; otherwise inspect dench tool status ${data.toolkit} --json. Native apps never fall back to Composio.`; } if (items.length === 0) { return [ "No matching tools found.", "", "`dench tool search` is a KEYWORD search over tool names and", "descriptions, not a semantic / natural-language search.", "Retry with one or two concrete tokens that likely appear in the", 'tool slug or its docs ("fetch emails", "send message", "create', 'issue", "list channels"). Drop filter words like "today" /', '"unread" — those go in the EXECUTED tool\'s --args, not in the', "catalog query. Pass --toolkit to narrow further.", "", "Use --json if you need the raw provider response.", ].join("\n"); } const lines = [ `Top tools${stringField(data, "query") ? ` for "${stringField(data, "query")}"` : ""}:`, ]; for (const [index, tool] of items.entries()) { const slug = stringField(tool, "slug", "tool_slug") ?? "unknown_tool"; const name = stringField(tool, "display_name", "name") ?? slug; const toolkit = stringField(tool, "toolkit_slug") ?? stringField(asRecord(tool.toolkit), "slug", "name") ?? "unknown"; const isConnected = booleanField(asRecord(tool.connection_status), "is_connected") ?? connectedSet.has(toolkit.toLowerCase()); const description = compact ? null : (compactLine(tool.description) ?? null); lines.push( `${index + 1}. ${slug} - ${name}`, ` toolkit: ${toolkit} | ${toolApprovalHint(slug)} | ${ isConnected ? "connected" : "not connected" }`, ); if (description) lines.push(` ${description}`); } lines.push( "", "Use dench tool run --args '{...}' when ready.", "Use --json only when you need full schemas or raw arguments.", ); return lines.join("\n"); } function extractSearchItems(data: JsonRecord | undefined): JsonRecord[] { const direct = asRecordArray(data?.items); if (direct.length > 0) return direct; const schemas = asRecord(data?.tool_schemas); if (!schemas) return []; return Object.entries(schemas).flatMap(([slug, value]) => { const tool = asRecord(value); return tool ? [{ ...tool, tool_slug: stringField(tool, "tool_slug") ?? slug }] : []; }); } function connectedToolkitSet(data: JsonRecord | undefined): Set { const set = new Set(); for (const value of asRecordArray(data?.toolkit_connection_statuses)) { if (booleanField(value, "has_active_connection")) { const slug = stringField(value, "toolkit"); if (slug) set.add(slug.toLowerCase()); } } if (Array.isArray(data?.connected_toolkits)) { for (const value of data?.connected_toolkits ?? []) { if (typeof value === "string") set.add(value.toLowerCase()); } } return set; } // --------------------------------------------------------------------------- // Redaction (display-only) // --------------------------------------------------------------------------- const SENSITIVE_KEY_RE = /(?:otp|oneTime|verification|security|passcode|password|secret|api[_-]?key|access[_-]?token|refresh[_-]?token|authorization)/i; function redactForDisplay(value: unknown, key?: string): unknown { if (typeof value === "string") { return key && SENSITIVE_KEY_RE.test(key) ? "[redacted]" : value .replace( /\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, "$1[redacted-code]", ) .replace( /\b((?:api[_\s-]?key|secret|token|password)[^.\n\r]{0,60}?)([A-Za-z0-9_=-]{12,})\b/gi, "$1[redacted-secret]", ); } if (typeof value === "number" && key && SENSITIVE_KEY_RE.test(key)) { return "[redacted]"; } if (Array.isArray(value)) { return value.map((item) => redactForDisplay(item)); } const record = asRecord(value); if (!record) return value; return Object.fromEntries( Object.entries(record).map(([entryKey, entryValue]) => [ entryKey, redactForDisplay(entryValue, entryKey), ]), ); } // --------------------------------------------------------------------------- // Help text // --------------------------------------------------------------------------- export function toolHelpText(): string { return `Dench tool commands Usage: dench integrations [--json] dench tool status [toolkit] [--json] dench tool connect [--callback-url ] [--json] dench tool search "create github issue" [--toolkit github] [--limit 20] [--compact] [--json] dench tool run --args '{"key":"value"}' [--account ] [--approval ] [--json] dench tool disconnect [--json] Notes: All commands authenticate with DENCH_API_KEY or the active \`dench signin\` agent session. Connected-app commands use paid-workspace gateway credentials. Native Gmail/Outlook/Calendar/LinkedIn connect/status need no gateway key; connect requires a workspace member or a member-owned Dench API key. Results carry a "source". "app" rows are third-party connected apps; "dench" rows (gmail, outlook, calendar, linkedin, slack) are connected by Dench itself. ALL of them run through \`dench tool run\` with the same slug + --args contract. \`dench tool connect slack\` prints the in-house Slack install URL (org admin, one click). For gmail/outlook/calendar/linkedin, connect prints a Dench sign-in link using the web app's configuration. Sign in as a workspace member and open the link to authorize the account. Calendar status accepts calendar, google-calendar, or outlook-calendar. Other toolkits connect as connected apps; --callback-url applies only to them. Slack is ALWAYS in-house: SLACK_* slugs (SLACK_SEND_MESSAGE with Block Kit blocks/buttons, SLACK_FETCH_HISTORY, SLACK_LIST_CHANNELS, …) run through the org's own Slack app install and can post to / read any channel. A write tool returns approval_required with an approvalId. Once a human approves it, re-run with --approval ; an approval is bound to that exact tool and argument set. LinkedIn tools are QUEUED, not run inline, so account-safety pacing (daily caps, working hours, spacing) applies. They return an action_id — poll it with LINKEDIN_GET_ACTION_STATUS. \`dench tool connect\` prints the OAuth redirect URL for the human to open. \`dench tool run\` output is redacted for display; pass --json for raw output. Override the gateway base with DENCH_GATEWAY_URL or GATEWAY_URL. `; } export function toolHelp() { console.log(toolHelpText()); } // --------------------------------------------------------------------------- // Tiny utility helpers (local copies — keeps this file self-contained) // --------------------------------------------------------------------------- type JsonRecord = Record; function asRecord(value: unknown): JsonRecord | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) { return undefined; } return value as JsonRecord; } function asRecordArray(value: unknown): JsonRecord[] { return Array.isArray(value) ? value.flatMap((item) => asRecord(item) ?? []) : []; } function stringField( record: JsonRecord | undefined, ...names: string[] ): string | undefined { if (!record) return undefined; for (const name of names) { const value = record[name]; if (typeof value === "string" && value.trim()) return value.trim(); } return undefined; } function booleanField( record: JsonRecord | undefined, ...names: string[] ): boolean | undefined { if (!record) return undefined; for (const name of names) { const value = record[name]; if (typeof value === "boolean") return value; } return undefined; } function compactLine(value: unknown, maxLength = 140): string | undefined { if (typeof value !== "string") return undefined; const text = value.replace(/\s+/g, " ").trim(); if (!text) return undefined; return text.length > maxLength ? `${text.slice(0, maxLength - 1)}...` : text; } function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } function parsePositiveInt(name: string, raw: string): number { const parsed = Number(raw); if (!Number.isFinite(parsed) || parsed <= 0) { throw new ToolCliError(`Invalid ${name} value: ${raw}`); } return Math.floor(parsed); } // Re-export shared helpers used implicitly above so eslint doesn't flag // them as unused when callers tree-shake. export { ToolCliError, GatewayHttpError, MissingApiKeyError }; // Kept reachable so a future caller can build their own dispatcher. export { resolveGatewayBaseUrl, requireBearerToken, callGateway }; // shiftRaw / hasFlag from cli-args are not used in this module yet but // keep the import group consistent with cli/search.ts conventions. void shiftRaw; void hasFlag; void CliArgError;