#!/usr/bin/env tsx import { Buffer } from "node:buffer"; import { spawn } from "node:child_process"; import { createHash } from "node:crypto"; import { chmod, mkdir, readdir, readFile, stat, writeFile, } from "node:fs/promises"; import { hostname, userInfo } from "node:os"; import { basename, dirname, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { ConvexHttpClient } from "convex/browser"; import { makeFunctionReference } from "convex/server"; import { agentKindLabel, normalizeAgentKind } from "./agentKind"; import { apiHostForDenchHost, isLocalHost, LOCAL_HOST, normalizeHost, PRODUCTION_CONVEX_URL, PRODUCTION_HOST, resolveBackendAlias, resolveHostFromArgs, STAGING_HOST, } from "./host"; import { CliArgError, isHelpRequest } from "./lib/cli-args"; import { cliVersion } from "./lib/cli-version"; import { assertDeclaredCommandFlags } from "./lib/command-args"; import { denchConfigDirectory } from "./lib/config-path.mjs"; import { beginCliInvocation, finishCliInvocation, } from "./lib/invocation-observation"; import { resolveOrganizationBySelector } from "./lib/organizationSelector"; import { normalizeParentArgv } from "./lib/parent-argv"; import { backendErrorText, publicErrorDetails } from "./lib/public-error"; import { formatApprovalOpenMessage, openUrl } from "./openUrl"; import { explicitSessionKeyInput, findStoredSessionEntries, listStoredSessionEntries, removeAllStoredSessions, removeStoredSession, resolveSessionScope, type SessionScope, type StoredSessionEntry, selectStoredSession, selectStoredSessionEntry, usesInjectedAgentSession, withCurrentSessionSelection, withSavedSession, } from "./session"; import { connectionSource, fetchConnectedAppsSummary, fetchUnipileConnections, runToolCommand, toolHelp, } from "./tools"; type JsonRecord = Record; class CliError extends Error { payload: JsonRecord; constructor(message: string, payload: JsonRecord = {}) { super(message); this.name = "CliError"; this.payload = payload; } } type StoredSession = { host: string; convexUrl: string; sessionToken: string; organization?: { id: string; name: string; slug?: string }; agent?: { id: string; name: string; kind: string }; sessionScope?: string; sessionScopeLabel?: string; sessionExpiresAt?: number; savedAt: number; }; type ConfigFile = { /** * Explicit user choice of which Dench backend to talk to by default, * set via `dench backend set >`. Takes * precedence over the legacy `currentHost` field (which used to be * silently updated by every `dench signin` and caused the CLI to * silently follow the last-logged-in environment). * * Resolution order in `resolveHost`: * 1. CLI flags (`--prod`, `--staging`, `--backend`, `--host`) * 2. `DENCH_HOST` env var * 3. `defaultBackend` from this config * 4. `PRODUCTION_HOST` (the safe fallback) * * `currentHost` / `currentHosts` / `currentSessionKey` / * `currentSessionKeys` keep their old role of remembering which * stored session to use, but they no longer drive backend choice. */ defaultBackend?: string; currentHost?: string; currentSessionKey?: string; currentHosts?: Record; currentSessionKeys?: Record; sessions?: Record; }; function createCliConvexClient(convexUrl: string): ConvexHttpClient { return new ConvexHttpClient(convexUrl, { logger: false }); } type WorkspaceOverview = { agents: Array<{ _id: string } & JsonRecord>; projects: JsonRecord[]; approvals: Array<{ status?: string } & JsonRecord>; rules: JsonRecord[]; recentArtifacts?: JsonRecord[]; activeRuns?: JsonRecord[]; comments?: JsonRecord[]; dependencies?: JsonRecord[]; memories?: JsonRecord[]; }; type AgentStatus = { workspace?: string; workspaceSlug?: string; agent?: { id?: string } & JsonRecord; counts?: JsonRecord; rules?: JsonRecord[]; approvals?: Array<{ status?: string; agentId?: string } & JsonRecord>; }; const api = { functions: { agentWorkspace: { agentDecideApproval: makeFunctionReference<"mutation">( "functions/agentWorkspace:agentDecideApproval", ), agentListArtifacts: makeFunctionReference<"query">( "functions/agentWorkspace:agentListArtifacts", ), agentRequestApproval: makeFunctionReference<"mutation">( "functions/agentWorkspace:agentRequestApproval", ), agentSaveMemory: makeFunctionReference<"mutation">( "functions/agentWorkspace:agentSaveMemory", ), agentSearchMemory: makeFunctionReference<"query">( "functions/agentWorkspace:agentSearchMemory", ), agentStatus: makeFunctionReference<"query">( "functions/agentWorkspace:agentStatus", ), getAgentBillingStatus: makeFunctionReference<"query">( "functions/agentWorkspace:getAgentBillingStatus", ), createSignInRequest: makeFunctionReference<"mutation">( "functions/agentWorkspace:createSignInRequest", ), devCreateProject: makeFunctionReference<"mutation">( "functions/agentWorkspace:devCreateProject", ), devListWorkspaceOverview: makeFunctionReference<"query">( "functions/agentWorkspace:devListWorkspaceOverview", ), devRequestApproval: makeFunctionReference<"mutation">( "functions/agentWorkspace:devRequestApproval", ), pollSignInRequest: makeFunctionReference<"mutation">( "functions/agentWorkspace:pollSignInRequest", ), whatCanIDoHere: makeFunctionReference<"query">( "functions/agentWorkspace:whatCanIDoHere", ), }, agentConfig: { workspaceContext: makeFunctionReference<"query">( "functions/agentConfig:workspaceContext", ), workspaceInstructions: makeFunctionReference<"query">( "functions/agentConfig:workspaceInstructions", ), devWorkspaceInstructions: makeFunctionReference<"query">( "functions/agentConfig:devWorkspaceInstructions", ), }, files: { listTree: makeFunctionReference<"query">("functions/files:listTree"), getFileContent: makeFunctionReference<"query">( "functions/files:getFileContent", ), editFileFromUI: makeFunctionReference<"action">( "functions/files:editFileFromUI", ), deleteFile: makeFunctionReference<"mutation">( "functions/files:deleteFile", ), generateUploadUrl: makeFunctionReference<"mutation">( "functions/files:generateUploadUrl", ), commitFileUpload: makeFunctionReference<"mutation">( "functions/files:commitFileUpload", ), // Used by `dench stage` to record dir rows for intermediate // parents (commitFileUpload only inserts leaf rows). recordDirCreate: makeFunctionReference<"mutation">( "functions/files:recordDirCreate", ), getFileSignedDownloadUrl: makeFunctionReference<"action">( "functions/files:getFileSignedDownloadUrl", ), }, }, }; const CONFIG_DIR = denchConfigDirectory(); const CONFIG_PATH = join(CONFIG_DIR, "config.json"); // A child process owns every token after `--`, including --host and --json. // Normalize only Dench's parent arguments; pass child argv through byte for byte. const { args, runtimeArgs } = normalizeParentArgv(process.argv.slice(2)); const cliInvocation = beginCliInvocation(runtimeArgs); const json = runtimeArgs.includes("--json"); const filteredArgs = runtimeArgs.filter((arg) => arg !== "--json"); const scopedStatus = runtimeArgs.includes("--mine") || runtimeArgs.includes("--self"); function readEnv(name: string) { const value = process.env[name]?.trim(); if (!value) { throw new Error(`Missing ${name}`); } return value; } function option(name: string) { const index = filteredArgs.indexOf(name); if (index === -1) return undefined; return filteredArgs[index + 1]; } function hasFlag(name: string) { return filteredArgs.includes(name); } const BOOLEAN_FLAGS = new Set([ "--all", "--allow-duplicates", "--allow-active", "--raw", "--check", "--allow-single-braces", "--with-ids", "--yes", "--ack-linkedin-risk", "--stop-on-reply", "--no-stop-on-reply", "--clear-start", "--clear-end", "--reverse", "--with-meta", "--compact", "--dev", "--follow", "--full-page", "--help", "--mine", "--self", "--staging", "--prod", "--no-open", "--use-dev-env", "--version", "--yolo", "--ask-before-publishing", "--ask-before-spending", "--ask-before-external-messages", "--ask-before-production-changes", "--ask-before-secrets", ]); function positionalArgs() { const fileArgs = stripRuntimeFlags(filteredArgs); if (fileArgs[0] === "files") { const switches = new Set(["--recursive", "-r", "--url"]); return fileArgs.filter((value, index) => index < 2 || !switches.has(value)); } const positionals: string[] = []; for (let i = 0; i < filteredArgs.length; i++) { if (filteredArgs[i].startsWith("--")) { if (!BOOLEAN_FLAGS.has(filteredArgs[i])) { i++; } } else { positionals.push(filteredArgs[i]); } } return positionals; } function positional(index: number) { return positionalArgs()[index]; } function positionalsFrom(index: number) { return positionalArgs().slice(index); } function print(value: unknown) { if (json) { console.log(JSON.stringify(value, null, 2)); return; } if (typeof value === "string") { console.log(value); return; } console.log(JSON.stringify(value, null, 2)); } function stringArrayField(record: JsonRecord | undefined, name: string) { const value = record?.[name]; return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; } function throwIfCliError(value: unknown) { if (!value || typeof value !== "object" || Array.isArray(value)) { return; } const record = value as JsonRecord; if (record.ok === false && typeof record.error === "string") { throw new CliError(record.error, publicErrorDetails(record)); } } function normalizeCliError(error: unknown): CliError { if (error instanceof CliError) { error.payload = { ...error.payload, ...publicErrorDetails(error.payload) }; return error; } const message = backendErrorText(error); if (error instanceof CliArgError) return new CliError(message, { code: error.code, retryable: false }); if (message.includes("Invalid dev agent key")) { return new CliError("Invalid dev agent key", { code: "invalid_dev_agent_key", nextActions: [ "Check that DENCH_DEV_AGENT_KEY in your shell matches the dev deployment env.", "Use --dev only for a local/dev deployment configured with that key.", "For normal terminal usage, run dench signin or set DENCH_API_KEY.", ], }); } if (message.includes("Dev CRM access is disabled in production")) { return new CliError("Dev CRM access is disabled in production", { code: "dev_access_disabled", nextActions: [ "Remove --dev and run dench signin for terminal access.", "Or use DENCH_API_KEY for sandbox/automation access.", ], }); } if (message.includes("Sign in as a workspace member to")) { return new CliError( "Outreach needs a workspace member's credentials — sending accounts belong to a person, not the workspace", { code: "outreach_member_required", nextActions: [ "Ask the person you are working for to run this from a chat in the Dench app, or to link their account in Slack, so the run carries their own identity. Connecting a sending account does not change whose credentials this run has.", "In a terminal, run dench signin as a workspace member. An organization-only DENCH_API_KEY cannot run outreach.", ], }, ); } if (message.includes("Invalid Dench API key")) { return new CliError("Invalid Dench API key", { code: "invalid_api_key", nextActions: [ "Check that DENCH_API_KEY is a current key for your workspace.", "Unset DENCH_API_KEY to use a saved dench signin session instead.", "Or run dench signin to create a new terminal session.", ], }); } return new CliError(message, { code: "cli_error", ...publicErrorDetails(error), }); } function _parseJsonObjectOption(name: string) { const raw = option(name); if (!raw) return {}; let parsed: unknown; try { parsed = JSON.parse(raw) as unknown; } catch { throw new CliError(`${name} must be valid JSON`, { code: "malformed_json", option: name, nextActions: [ `Pass ${name} as a quoted JSON object, for example: ${name} '{"key":"value"}'`, "Use --json for structured CLI output.", ], }); } if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { throw new CliError(`${name} must be a JSON object`, { code: "malformed_json", option: name, nextActions: [ `Pass ${name} as an object, for example: ${name} '{"key":"value"}'`, ], }); } return parsed as JsonRecord; } function parseNumberOption(name: string) { const raw = option(name); if (!raw) return undefined; const parsed = Number(raw); if (!Number.isFinite(parsed)) { throw new CliError(`${name} must be a number`, { code: "invalid_number", option: name, nextActions: [`Retry with a numeric value for ${name}.`], }); } return parsed; } function parseCommaOption(name: string) { return ( option(name) ?.split(",") .map((item) => item.trim()) .filter(Boolean) ?? [] ); } function parseMemoryVisibilityOption() { const scope = option("--scope"); const visibility = option("--visibility"); if (scope && visibility && scope !== visibility) { throw new CliError( "--scope and --visibility must match when both are set", { code: "memory_scope_conflict", nextActions: ["Use only --scope user or --scope org."], }, ); } const value = scope ?? visibility ?? "user"; if (value !== "user" && value !== "org") { throw new CliError("--scope must be user or org", { code: "invalid_memory_scope", nextActions: [ "Use --scope user for private user memory or --scope org for shared organization memory.", ], }); } return value; } function logHuman(message: string) { if (!json) { console.error(message); } } function rootUsageText() { return `Dench CLI Usage: dench signin [--kind ] [--name "AI Agent - Project"] [--no-open] [--json] dench signin --new-workspace --org-name "Workspace" [--kind ] [--name "AI Agent - Project"] dench signin --email user@example.com [--new-workspace --org-name "Workspace"] [--kind ] [--name "AI Agent - Project"] dench signin --org [--kind ] [--name "AI Agent - Project"] dench upgrade [--tier pro|max] [--cycle monthly|yearly] [--no-open] [--json] dench sessions [--host ] [--json] dench use [--host ] [--json] dench logout [session-key-or-workspace-slug] [--session ] [--host ] [--all] [--json] dench backend show [--json] dench backend set [--json] dench backend clear [--json] dench context [--instructions] [--json] dench context --sections organisation,user,memory [--json] dench instructions [--json] dench status [--mine|--self] [--json] dench memory search "query" [--limit 8] [--json] dench memory save "text" [--kind fact|decision|preference|goal|tool_note|other] [--tags a,b] [--sensitivity normal|broad|sensitive] [--scope user|org] [--json] dench artifacts [--limit 25] [--json] dench artifacts list [--limit 25] [--json] dench artifacts show [--json] dench artifacts import --to lead|company|job_posting [--view "Q3 outbound"] [--pin] [--json] dench lists [--limit 25] [--json] dench lists show [--json] dench lists save --title "Shortlist" --people-file [--json] dench lists rename "New name" [--json] dench lists update [--title "New name"] [--filters-json '{...}'] [--json] dench lists delete [--json] dench leads find --titles "VP Sales" [--locations "San Francisco"] [--save-as "Q3 targets"] [--limit 500] [--page-token ] [--json] (rich filters: dench leads help) dench leads filters [--filter titles|locations|company-hq|...] [--search "query"] [--json] dench companies find --name "Acme" [--domain acme.com] [--keywords fintech] [--save-as "Target accounts"] [--limit 500] [--page-token ] [--json] dench jobs find --titles "Account Executive" [--locations "San Francisco"] [--save-as "AE openings"] [--limit 500] [--page-token ] [--json] dench suggested-work [--json] dench approval request "message" [--json] dench approval approve [--evidence "User said yes in chat"] [--json] dench approval reject [--evidence "User said no in chat"] [--json] dench billing status [--json] dench billing topup --amount 5 [--no-open] [--json] dench integrations [--json] dench tool status [toolkit] [--json] dench tool connect [--json] dench tool search "create github issue" [--toolkit github] [--limit 20] [--compact] [--json] dench tool run --args '{"key":"value"}' [--account ] [--approval ] [--json] CRM: dench crm objects ... dench crm fields ... dench crm entries ... dench crm cells [value] dench crm cells set-many --updates '[...]' dench crm query --where '...' --select ... --sort ... --limit N dench crm sql 'SELECT "Name" FROM lead WHERE ... LIMIT 10' dench crm aggregate --field dench crm search [--object ] [--limit N] dench crm import --csv [--map '{...}'] [--on-conflict skip|update|error] dench crm export --format csv|jsonl [--limit N] dench crm transaction dench crm people|companies ... dench crm enrich ... dench crm reports generate --object --type pie|bar|line|table --group-by dench crm docs dench crm actions ... dench crm batch --file ops.jsonl dench crm comments ... (record Activity feed; --dench summons the agent) dench crm watch [--off] (follow a record's activity) dench crm tasks ... Batch write helpers: entries create-many/update-many, cells set-many Help: dench crm help Meetings (CRM \`meeting\` records + their transcript, action items, attendance): dench meetings list [--limit N] [--shared] | get dench meetings share [--workspace | --private] dench meetings transcript get|set|clear [--text file | --jsonl file] dench meetings action-items list|add|set|done|delete ... dench meetings participants list|set [--people 'Name ; ...'] dench meetings import --csv calls.csv | --jsonl calls.jsonl [--dedupe-key ] [--on-conflict skip|update|error] Help: dench meetings help Notifications (the bell's inbox): dench notifications list [--filter inbox|unread|archived|all|mentions] [--limit N] [--json] dench notifications count | read [--unread] | read-all | archive dench notifications prefs [--set emailEnabled=false] [--set .email=false] Help: dench notifications help Workspace settings (Settings → Sync: what Dench creates and keeps current on its own): dench settings sync [--json] the eight switches, all on by default dench settings sync set on|off [--json] admin-only; off stops new work, removes nothing Help: dench settings help Workspace members (for CRM \`user\`-type fields, and who's doing what): dench members list [--json] (top-level alias) dench crm members list [--json] (canonical CRM-scoped name) Look up real userIds for the active org so \`Owner\` / \`Assignee\` cells assign to real members instead of being stored as unlinked display-name strings. See \`dench members help\`. dench members get profile: tagline, timezone, presence, open tasks dench members activity their comments + record changes, newest first dench members tasks open tasks assigned to them dench members presence [...] online / away / offline dench members me [--name --tagline --timezone --avatar ] your own profile Live web search (via the Dench Gateway, auths with the active \`dench signin\` agent session or DENCH_API_KEY): dench search "" [--num-results N] [--type auto|fast|deep|deep-reasoning|neural] [--category news|company|people|...] [--include-domains a.com,b.com] [--exclude-domains c.com] [--max-chars 800] [--json] dench search contents [...] [--max-chars 4000] [--summary "..."] [--json] dench search answer "" [--json] Help: dench search help Dench web (page read, brand, sitemap, crawl, screenshot, extract — prefer these over writing your own scraper): dench web scrape [--json] dench web brand [--json] dench web sitemap [--limit N] [--json] dench web crawl [--max-pages N] [--json] dench web screenshot [--full-page] [--json] dench web extract --schema '' [--json] Help: dench web help Cloud browser (runs entirely in Dench Cloud): dench browser open [url] [--json] dench browser inspect [--json] dench browser navigate dench browser click --selector | --text "Button text" dench browser fill --label "Email" --value "me@example.com" dench browser screenshot [--output page.jpg] [--full-page] dench browser stop Help: dench browser help Monetize the AI "thinking…" line (supported hooks only — no file patching): dench ads Print the sponsored ad in a rainbow box dench ads install [--target claude|shell|agentmd|all] [--apply] dench ads refresh (rotate the cached ad; flush impressions) dench ads earnings Show real USD ad earnings dench ads payout setup Connect Stripe for real USD cash payouts dench ads withdraw --amount all dench ads status [--json] dench ads uninstall [--target claude|shell|agentmd|all] Aliases: dench ad, dench adline. Help: dench ads help Managed email campaigns: dench email campaign create --name "Q2 outreach" --mailbox you@gmail.com --template [--json] dench email campaign create --name "Q2 outreach" --identity --template [--json] dench email campaign recipients add --campaign --file recipients.jsonl [--json] dench email campaign submit --campaign [--json] dench email campaign status --campaign [--json] Help: dench email help Outreach campaigns (email + LinkedIn steps on one schedule; leads move New → Contacted → Replied): dench campaign accounts [--json] (sender ids, warm-up and capacity) dench campaign draft --name "Founder outreach" --request-id founders-q4 [--timezone America/Los_Angeles] [--window "Mon-Fri 9-18"] [--json] dench campaign search --filters '{"titles":["Founder"]}' [--not-industries "Staffing and Recruiting"] [--limit 25] [--out audience.json] [--json] (1 cent per person returned) dench campaign add --people-file audience.json | --csv people.csv | --list | --search '{...}' | --object lead --view "Q3 targets" [--json] dench campaign steps get|set|edit [--file steps.json] [--json] (set: drafts only; edit: text and waits, also after launch) dench campaign personalize set --file lines.json (AI-written lines, about 3 cents a person) dench campaign preview [--person ] [--step N] (the exact messages one person gets) dench campaign launch --check [--json] (readiness only; never launches) dench campaign launch [--ack-linkedin-risk] [--json] dench campaign get [--json] (status, funnel, Now/Next, holds, people) dench campaign people|events|replies|pipeline [--json] dench campaign duplicate|pause|resume|archive [--json] dench campaign stop|remove|resume-person --person [--json] dench campaign person show|set --person [--json] · dench campaign sync · dench campaign where dench campaign builder profile|save|get|build|create|opener|autofill|terms|estimate [--json] dench campaign sources get|signals|set|preview [--json] Help: dench campaign help (recipe from a brief to launch: references/CAMPAIGNS.md) Image generation / editing (via the Dench Gateway): dench image generate "" [--size 1024x1024] [--quality auto|low|medium|high] [--format png|jpeg|webp] [--save-path /workspace/path/file.png] [--output ] [--no-write] [--json] dench image edit "" --input [--input ...] [--mask ] [--size ...] [--quality ...] [--format ...] [--save-path ...] [--output ...] [--json] Help: dench image help Past chat threads (own + shared in this workspace): dench chat list [--query ] [--limit N] [--include-archived] [--json] dench chat search "" [--limit N] [--include-current] [--json] dench chat read [--limit N] [--json] dench chat rename [--json] dench chat rename --from-stdin [--json] (batch via JSON {items:[...]}) dench chat delete [...] [--yes] [--json] dench chat delete --ids id1,id2 [--yes] [--json] (PERMANENTLY deletes threads) Spawn a new chat-turn workflow (same as the web UI): dench chat new "" [--environment NAME] [--model ] [--file-context ] [--visibility private|shared] [--title T] [--yolo] [--follow] [--json] dench chat send "" [--model ] [--yolo] [--follow] [--json] dench chat follow [--json] (tail a live thread by id) dench chat tree [] [--limit N] [--json] (parent-child thread tree) Help: dench chat help File sync: dench fs status [--json] [--workspace /workspace] [--no-hash] [--drift-limit N] dench fs sync --initial --org-id [--workspace /workspace] dench fs pull [...] [--wait] [--timeout-ms N] [--json] dench fs stream-open dench fs stream-append dench fs stream-close Workspace files (use these instead of mv/rm in /workspace; rename(2) returns ENOSYS on the FUSE-backed volume mount): dench files ls [--json] dench files mv [--json] dench files rm [--recursive] [--json] dench files download [] [--url] [--json] Persist scratch -> workspace (cp from /tmp into /workspace + cloud sync): dench stage [] [--clean] [--json] Reusable workspace setup: dench env list | show | save --path | run -- | delete dench secrets set --stdin [--kind env|file] dench secrets list | delete metadata only; values are never printed Help: dench env --help, dench secrets --help dench --version dench reference [--json] Offline CLI-version-matched skill/reference dench reference --list [--json] Bundled reference index and revision Standing context and memory: dench context --sections identity,organisation,user,memory,rules,tools [--json] dench context set --stdin dench memory search "" | save "" dench model default dench model thread Context describes preferences and instructions. Environments store setup; secrets store credentials. A memory note does not install or save either. `; } async function help() { const { commandHelp } = await import("./lib/command-help"); console.log(commandHelp([], rootUsageText())); } function contextHelp() { console.log(`Dench context Usage: dench context [--instructions] [--json] dench context --sections organisation,user,memory [--json] dench context set --stdin | --content "text" [--json] Shows the current workspace, current agent, assigned tasks, pending approvals requested by this agent, connected apps when available, and useful next commands. Use --instructions (or dench instructions) to print the standing workspace instructions for the current workspace. Context and memory do not persist credentials or installed software: use dench secrets and dench env. `); } function memoryHelp() { console.log(`Dench memory Usage: dench memory search "query" [--limit 8] [--json] dench memory save "text" [--kind fact|decision|preference|goal|tool_note|other] [--tags a,b] [--sensitivity normal|broad|sensitive] [--scope user|org] [--json] Use memory only for stable facts, decisions, preferences, recurring goals, and tool notes. Do not store secrets or scratch notes. Saved memories are scoped to the signed-in user by default; pass --scope org to share with the organization. `); } function artifactsHelp() { console.log(`Dench artifacts Usage: dench artifacts [--limit 25] [--json] dench artifacts list [--limit 25] [--json] dench artifacts show [--json] dench artifacts import --to lead|company|job_posting [--view "Q3 outbound"] [--pin] [--json] dench suggested-work [--json] \`list\` / \`show\` / \`import\` operate on find results. Bare \`dench artifacts\` lists agent suggested-work artifacts. Rename/delete saved searches with \`dench lists rename|delete\`. `); } async function listsCliHelp() { const { listsHelp } = await import("./leads"); listsHelp(); } function leadsHelp() { console.log(`Dench leads Usage: dench leads find --titles "VP Sales" --locations "San Francisco" [--company-domain acme.com] [--keywords fintech] [--save-as "Q3 targets"] [--limit 500] [--page-token ] [--json] dench leads filters [--filter titles|locations|company-hq|universities|skills|keywords|...] [--search "query"] [--json] Rich filter flags: --past-titles --person-names --seniorities --functions --sub-functions --languages --universities --skills --company-names --past-company-names --company-domains --company-hq --company-types --industries --headcount, plus min:max ranges --years-in-role --years-at-company --days-since-job-change --founded-years. Run \`dench leads filters\` for accepted enum values and curated suggestions. `); } function companiesHelp() { console.log(`Dench companies Usage: dench companies find --name "Acme" [--domain acme.com] [--keywords fintech] [--industries SaaS] [--locations "San Francisco"] [--save-as "Target accounts"] [--limit 500] [--page-token ] [--json] `); } function jobsHelp() { console.log(`Dench jobs Usage: dench jobs find --titles "Account Executive" --locations "San Francisco" [--company-domain acme.com] [--keywords remote] [--save-as "AE openings"] [--limit 500] [--page-token ] [--json] `); } function signinHelp() { console.log(`Dench signin Usage: dench signin [--kind ] [--name "AI Agent - Project"] [--no-open] [--json] dench signin --new-workspace --org-name "Workspace Name" [--kind ] [--name "AI Agent - Project"] dench signin --email user@example.com [--new-workspace --org-name "Workspace Name"] [--kind ] [--name "AI Agent - Project"] dench signin --org [--kind ] [--name "AI Agent - Project"] Signs in a CLI agent to a Dench workspace. Two transport modes: Browser (default): opens a one-click approval URL. The human picks or creates a workspace in the browser, then the CLI saves the session token. Use --no-open to print the link instead. OTP (with --email): prompts for the 6-digit code the user receives by email; never opens a browser. Useful for headless / remote shells. Intents: Sign in to existing workspace (default): the user picks one of their admin workspaces in the browser. With OTP, the CLI prompts when more than one admin workspace exists. --new-workspace --org-name "": creates a new FREE CRM-only workspace with that name. Free workspaces have full CRM access; chat, file sync, enrichment, and image-gen are paywalled — run \`dench upgrade\` to unlock those. Agent identity: --kind accepts any string. Suggested values: claude_code, codex, cursor, hermes, openclaw, or any custom kind. Values are normalized to lowercase snake_case (e.g. "Claude Code" \u2192 claude_code). Defaults to "other". --name is a human-friendly label, e.g. "Cursor Agent - Billing Repo". After sign-in, run \`dench context --json\` to confirm the active workspace and agent, then \`dench crm objects list --json\` to verify CRM access. `); } function statusHelp() { console.log(`Dench status Usage: dench status [--mine|--self] [--json] Notes: --mine and --self return the current workspace, current agent, counts, rules, assigned tasks, and pending approvals requested by this agent. Use dench status --mine --json during setup verification. `); } function logoutHelp() { console.log(`Dench logout Usage: dench logout [session-key-or-workspace-slug] [--session ] [--host ] [--all] [--json] Notes: dench logout removes only the current selected session for this local agent context. Other saved sessions can still be reused by future chats until removed. Use dench logout --all to clear every saved Dench session on this machine. `); } function integrationsHelp() { console.log(`Dench integrations Usage: dench integrations [--json] Lists every app connected to this organization: - Connected apps (GitHub, Notion, HubSpot, …), each with a \`connectedAccountId\` you can pass to \`dench tool run --account\`. - Gmail, Outlook, Calendar, LinkedIn and Slack (\`source: "dench"\`), which Dench connects itself. These have no connected-account id, but they run through the SAME \`dench tool search\` / \`dench tool run\` — find the slug and run it. \`dench tool connect slack\` prints the in-house Slack install URL (org admin); \`dench tool connect \` prints a Dench sign-in link using the web app's configuration. Open that link to authorize the account, then check \`dench tool status\`. Slack is ALWAYS in-house: SLACK_* tools can post to / read any channel, with Block Kit blocks and buttons. For reading, the CRM is usually better: calendar events sync into \`calendar_event\` and recordings into \`meeting\`. Other toolkits connect as connected apps. If \`nativeAvailable\` is false, Dench-connected accounts could not be checked; do not assume an account is disconnected. A failure to check connected apps is reported separately. Authenticates with the active \`dench signin\` agent session or DENCH_API_KEY (Bearer), whichever is set. Same output as \`dench tool status\`. `); } function billingHelp() { console.log(`Dench billing Usage: dench billing status [--json] dench billing topup --amount 5 [--no-open] [--json] Notes: status shows the current AI credit balance for this workspace. topup creates a Stripe Checkout link. The human opens the link and pays. `); } function backendHelp() { console.log(`Dench backend Usage: dench backend # alias for "show" dench backend show [--json] dench backend set > [--json] dench backend clear [--json] Aliases: prod = production, dev / localhost = local, stage = staging. What this does: The default Dench backend is production (${PRODUCTION_HOST}). "dench backend set" stores an explicit override in ~/.dench/config.json so every future "dench" invocation talks to that backend by default. Use it instead of relying on a stale currentHost from a past "dench signin". Setting the backend to "local" also opts the CLI into auto-loading .env and .env.local from the dench.com workspace at startup, which is needed for GATEWAY_URL and similar local-only env vars. One-shot overrides (no config change): dench --prod # one command against production dench --staging # one command against staging dench --backend local # one command against http://localhost:3000 dench --host https://… # one command against a custom URL DENCH_HOST=https://… dench … # same, via env Opt into dev .env auto-loading without changing the default backend: --use-dev-env # one-shot flag DENCH_USE_DEV_ENV=1 # env var (e.g. via direnv) `); } function resolveHost(config: ConfigFile | undefined, _scope: SessionScope) { // `defaultBackend` is only ever set via `dench backend set`, so it // represents an explicit user choice. Production wins on fresh // installs (no flags, no env, no config) — which is the safe default // for an agent CLI. The per-scope `currentHosts[scope.key]` map is // intentionally NOT consulted here so that a past `dench signin // --host http://localhost:3000` doesn't silently keep routing every // future command at local dev. return resolveHostFromArgs(filteredArgs, config?.defaultBackend); } async function loadConfig(): Promise { try { return JSON.parse(await readFile(CONFIG_PATH, "utf8")) as ConfigFile; } catch { return {}; } } async function saveConfig(nextConfig: ConfigFile) { await mkdir(CONFIG_DIR, { recursive: true }); await writeFile(CONFIG_PATH, `${JSON.stringify(nextConfig, null, 2)}\n`, { mode: 0o600, }); await chmod(CONFIG_PATH, 0o600).catch(() => {}); } async function saveSession(session: StoredSession, scope: SessionScope) { const config = await loadConfig(); await saveConfig(withSavedSession(config, session, scope).config); } async function getStoredSession(host: string, scope: SessionScope) { const config = await loadConfig(); return selectStoredSession(config, host, scope); } function formatTime(value: number | undefined) { return value ? new Date(value).toISOString() : null; } function workspaceLabel(session: StoredSession) { if (!session.organization) return "unknown workspace"; const slug = session.organization.slug; return slug ? `${session.organization.name} (${slug})` : session.organization.name; } function ambiguousSessionMessage( config: ConfigFile, scope: SessionScope, host: string, ) { return `Multiple Dench sessions exist for ${host}, but none match this local agent context. ${formatSessionChoices(config, scope, host)} Run dench sessions to list sessions. Run dench use to select one. For long-lived agents or when the human asks, use DENCH_SESSION_KEY= at login time.`; } function summarizeSessionEntry( entry: StoredSessionEntry, currentKey: string | undefined, ) { return { sessionKey: entry.key, current: entry.key === currentKey, host: entry.host, workspace: entry.session.organization ? { name: entry.session.organization.name, slug: entry.session.organization.slug ?? null, id: entry.session.organization.id, } : null, agent: entry.session.agent ? { name: entry.session.agent.name, kind: entry.session.agent.kind, id: entry.session.agent.id, } : null, scope: { label: entry.session.sessionScopeLabel ?? null, key: entry.session.sessionScope ?? null, }, savedAt: entry.session.savedAt ?? null, savedAtIso: formatTime(entry.session.savedAt), expiresAt: entry.session.sessionExpiresAt ?? null, expiresAtIso: formatTime(entry.session.sessionExpiresAt), }; } function formatSessionLine( entry: StoredSessionEntry, currentKey: string | undefined, ) { const marker = entry.key === currentKey ? "* " : " "; const agent = entry.session.agent ? `${entry.session.agent.name} [${entry.session.agent.kind}]` : "unknown agent"; const scope = entry.session.sessionScopeLabel ? `${entry.session.sessionScopeLabel}: ${entry.session.sessionScope ?? "unknown"}` : (entry.session.sessionScope ?? "unknown scope"); const saved = formatTime(entry.session.savedAt) ?? "unknown"; const expires = formatTime(entry.session.sessionExpiresAt) ?? "unknown"; return `${marker}${entry.key} host: ${entry.host} workspace: ${workspaceLabel(entry.session)} agent: ${agent} scope: ${scope} saved: ${saved} expires: ${expires}`; } function sessionLines(config: ConfigFile, scope: SessionScope, host?: string) { const resolvedHost = host ?? resolveHost(config, scope); const selected = selectStoredSessionEntry(config, resolvedHost, scope); const currentKey = selected.status === "found" ? selected.key : undefined; return listStoredSessionEntries(config, host).map((entry) => formatSessionLine(entry, currentKey), ); } function formatSessionChoices( config: ConfigFile, scope: SessionScope, host?: string, ) { const lines = sessionLines(config, scope, host); return lines.length > 0 ? lines.join("\n") : " No Dench sessions found."; } function backendLabelForHost(host: string) { const normalized = normalizeHost(host); if (normalized === PRODUCTION_HOST) return "production"; if (normalized === STAGING_HOST) return "staging"; if (isLocalHost(normalized)) return "local"; return "custom"; } function backendSnapshot(config: ConfigFile) { const resolved = resolveHost(config, { key: "auto:backend-show", label: "cwd", }); const stored = config.defaultBackend?.trim() ? normalizeHost(config.defaultBackend.trim()) : null; const envHost = process.env.DENCH_HOST?.trim() || null; const argBackend = option("--backend") ?? (hasFlag("--prod") ? "production" : hasFlag("--staging") ? "staging" : (option("--host") ?? null)); let source: "flag" | "env" | "config" | "default"; if (argBackend) source = "flag"; else if (envHost) source = "env"; else if (stored) source = "config"; else source = "default"; return { host: resolved, label: backendLabelForHost(resolved), source, defaultBackend: stored, envHost, knownBackends: { production: PRODUCTION_HOST, staging: STAGING_HOST, local: LOCAL_HOST, }, }; } function backendSourceHint(source: "flag" | "env" | "config" | "default") { return source === "flag" ? "(set via CLI flag for this invocation)" : source === "env" ? "(set via DENCH_HOST env var)" : source === "config" ? "(set via `dench backend set`)" : "(default — no explicit choice in env or config)"; } function formatBackendShow(snapshot: ReturnType) { const sourceHint = backendSourceHint(snapshot.source); const lines = [ `Active backend: ${snapshot.host} [${snapshot.label}] ${sourceHint}`, ]; if (snapshot.defaultBackend && snapshot.source !== "config") { lines.push(`Stored default backend: ${snapshot.defaultBackend}`); } if (snapshot.envHost) { lines.push(`DENCH_HOST env: ${snapshot.envHost}`); } lines.push( "", "Manage default backend:", " dench backend set production # default fallback", " dench backend set staging", " dench backend set local # http://localhost:3000", " dench backend set https://example.com # custom URL", " dench backend clear # revert to production default", "", "One-shot override flags also work on every command:", " dench --prod dench --staging ", " dench --backend local dench --host https://example.com ", ); return lines.join("\n"); } async function backendShow() { const config = await loadConfig(); const snapshot = backendSnapshot(config); print(json ? snapshot : formatBackendShow(snapshot)); } async function backendSet() { const target = positional(2); if (!target) { throw new CliError( "Missing backend value. Pass local, staging, prod, or a URL.", { code: "backend_set_missing_value", nextActions: [ "Run dench backend set production.", "Run dench backend set local.", "Run dench backend set https://your-host.example.com.", ], }, ); } let resolved: string; try { resolved = resolveBackendAlias(target); } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new CliError(message, { code: "backend_set_invalid_value", nextActions: [ "Run dench backend set production / staging / local.", "Or pass an explicit URL: dench backend set https://your-host.example.com.", ], }); } const config = await loadConfig(); await saveConfig({ ...config, defaultBackend: resolved }); const label = backendLabelForHost(resolved); if (json) { print({ ok: true, defaultBackend: resolved, label, message: `Default Dench backend set to ${resolved} (${label}).`, }); return; } print(`Default Dench backend set to ${resolved} (${label}).`); if (label === "local") { print( "Local backend selected. dench will now also load .env / .env.local from the workspace so GATEWAY_URL etc. are picked up.", ); } print( "Future `dench` commands without --prod/--staging/--host will target this backend.", ); } async function backendClear() { const config = await loadConfig(); if (!config.defaultBackend) { print( json ? { ok: true, cleared: false, defaultBackend: null } : "No default backend is set. dench already falls back to production.", ); return; } const previous = config.defaultBackend; const next: ConfigFile = { ...config }; delete next.defaultBackend; await saveConfig(next); print( json ? { ok: true, cleared: true, previous, defaultBackend: null, fallback: PRODUCTION_HOST, } : `Cleared default backend (was ${previous}). dench will fall back to ${PRODUCTION_HOST}.`, ); } async function runBackendCommand() { const sub = positional(1); if (!sub || sub === "show" || sub === "status") { await backendShow(); return; } if (sub === "set") { await backendSet(); return; } if (sub === "clear" || sub === "reset" || sub === "unset") { await backendClear(); return; } if (sub === "help" || sub === "--help" || sub === "-h") { backendHelp(); return; } throw new CliError(`Unknown dench backend subcommand: ${sub}`, { code: "unknown_backend_subcommand", nextActions: [ "Run dench backend show to print the active backend.", "Run dench backend set >.", "Run dench backend clear to revert to production.", ], }); } async function listSessions() { const config = await loadConfig(); const scope = resolveSessionScope({ args: filteredArgs }); const host = option("--host") ? resolveHostFromArgs(filteredArgs) : undefined; const selected = selectStoredSessionEntry( config, host ?? resolveHost(config, scope), scope, ); const currentKey = selected.status === "found" ? selected.key : undefined; const entries = listStoredSessionEntries(config, host); if (json) { print(entries.map((entry) => summarizeSessionEntry(entry, currentKey))); return; } if (entries.length === 0) { print("No Dench sessions found."); return; } print( entries.map((entry) => formatSessionLine(entry, currentKey)).join("\n"), ); } function removeSessionEntries( config: ConfigFile, entries: Array>, ) { const removedKeys = new Set(entries.map((entry) => entry.key)); const removedHosts = new Set(entries.map((entry) => entry.host)); const sessions = { ...(config.sessions ?? {}) }; for (const key of removedKeys) { delete sessions[key]; } const currentSessionKeys = { ...(config.currentSessionKeys ?? {}) }; const removedScopeKeys = new Set(); for (const [scopeKey, key] of Object.entries(currentSessionKeys)) { if (removedKeys.has(key)) { delete currentSessionKeys[scopeKey]; removedScopeKeys.add(scopeKey); } } const currentHosts = { ...(config.currentHosts ?? {}) }; for (const [scopeKey, host] of Object.entries(currentHosts)) { if (removedScopeKeys.has(scopeKey)) { delete currentHosts[scopeKey]; continue; } const hasRemainingForHost = Object.values(sessions).some( (session) => normalizeHost(session.host) === normalizeHost(host), ); if (!hasRemainingForHost) { delete currentHosts[scopeKey]; } } const nextConfig: ConfigFile = { ...config }; if ( nextConfig.currentSessionKey && removedKeys.has(nextConfig.currentSessionKey) ) { delete nextConfig.currentSessionKey; } if (Object.keys(sessions).length > 0) { nextConfig.sessions = sessions; } else { delete nextConfig.sessions; } if (Object.keys(currentSessionKeys).length > 0) { nextConfig.currentSessionKeys = currentSessionKeys; } else { delete nextConfig.currentSessionKeys; } if (Object.keys(currentHosts).length > 0) { nextConfig.currentHosts = currentHosts; } else { delete nextConfig.currentHosts; } if ( nextConfig.currentHost && removedHosts.has(normalizeHost(nextConfig.currentHost)) && !Object.values(sessions).some( (session) => normalizeHost(session.host) === normalizeHost(nextConfig.currentHost ?? ""), ) ) { delete nextConfig.currentHost; } return nextConfig; } function plural(count: number, singular: string, pluralForm = `${singular}s`) { return `${count} ${count === 1 ? singular : pluralForm}`; } function logoutRemainingSummary(config: ConfigFile, host?: string) { return { total: listStoredSessionEntries(config).length, forHost: host ? listStoredSessionEntries(config, host).length : undefined, }; } function logoutNextActions( remaining: { total: number; forHost?: number }, host?: string, ) { if (remaining.total === 0) return []; const actions = [ "Run dench sessions --json to inspect remaining saved sessions.", ]; if (host && (remaining.forHost ?? 0) > 0) { actions.push( `Run dench logout --all --host ${host} to clear saved sessions for this host.`, ); } actions.push( "Run dench logout --all to clear every saved Dench session on this machine.", ); return actions; } function formatLogoutMessage( primary: string, remaining: { total: number; forHost?: number }, host?: string, ) { const lines = [primary]; if (host && (remaining.forHost ?? 0) > 0) { lines.push( `There ${remaining.forHost === 1 ? "is" : "are"} still ${plural( remaining.forHost ?? 0, "saved Dench session", )} for ${host}. A future chat can reuse one if it is selected.`, ); } else if (remaining.total > 0) { lines.push( `There ${remaining.total === 1 ? "is" : "are"} still ${plural( remaining.total, "saved Dench session", )} on this machine.`, ); } const actions = logoutNextActions(remaining, host); if (actions.length > 0) { lines.push("Next actions:", ...actions.map((action) => ` - ${action}`)); } return lines.join("\n"); } async function selectSession() { const selector = positional(1); if (!selector) { throw new Error( "Missing session key or workspace slug. Run: dench sessions", ); } const config = await loadConfig(); const scope = resolveSessionScope({ args: filteredArgs }); const host = option("--host") ? resolveHostFromArgs(filteredArgs) : undefined; const matches = findStoredSessionEntries(config, selector, host); if (matches.length === 0) { throw new Error( `No Dench session matched "${selector}". Run dench sessions to see available sessions.`, ); } if (matches.length > 1) { throw new Error( `More than one Dench session matched "${selector}". Choose the exact session key:\n${matches .map((entry) => formatSessionLine(entry, undefined)) .join("\n")}`, ); } const [entry] = matches; // `dench use` only records WHICH session is selected — it never moves the // backend. If the machine's command target (flags > DENCH_HOST > // `dench backend set` > production default) points at a DIFFERENT host // than the selected session, every later command resolves that target // host, finds no session there, and fails `login_required` — while this // command would have reported success. That silent trap is exactly the // "use dench-com ok, context login_required, sessions all current:false" // incident (defaultBackend was still http://localhost:3000), so refuse // the no-op selection loudly instead. const snapshot = backendSnapshot(config); if (normalizeHost(snapshot.host) !== entry.host) { const alias = backendLabelForHost(entry.host) === "custom" ? entry.host : backendLabelForHost(entry.host); throw new CliError( `Session ${entry.key} is saved for ${entry.host}, but dench commands currently target ${snapshot.host} ${backendSourceHint(snapshot.source)}. Selecting it would have no effect: commands on this backend would still fail with login_required.`, { code: "session_host_mismatch", sessionHost: entry.host, targetHost: snapshot.host, targetSource: snapshot.source, nextActions: [ `Run dench backend set ${alias} to point commands at ${entry.host}, then re-run dench use ${selector}.`, ...(snapshot.source === "env" ? ["Or unset DENCH_HOST so the saved backend applies."] : []), ...(snapshot.source === "flag" ? ["Or drop the --host/--backend/--prod/--staging flag."] : []), `Or sign in on the current backend: dench signin --host ${snapshot.host}.`, ], }, ); } await saveConfig({ ...withCurrentSessionSelection(config, { host: entry.host, scope, sessionKey: entry.key, }), }); print( json ? { ok: true, session: summarizeSessionEntry(entry, entry.key) } : `Now using Dench session ${entry.key} for ${workspaceLabel(entry.session)}.`, ); } async function logout() { const config = await loadConfig(); const scope = resolveSessionScope({ args: filteredArgs }); const host = option("--host") ? resolveHostFromArgs(filteredArgs) : undefined; const selector = option("--session") ?? positional(1); if (hasFlag("--all")) { if (selector) { throw new Error("Use either --all or a specific session, not both."); } const entries = listStoredSessionEntries(config, host); const removed = entries.length; const nextConfig = host ? removeSessionEntries(config, entries) : removeAllStoredSessions(config); const remaining = logoutRemainingSummary(nextConfig, host); if ( removed > 0 || config.currentHost || config.currentSessionKey || config.currentHosts || config.currentSessionKeys ) { await saveConfig(nextConfig); } print( json ? { ok: true, removed, all: true, host: host ?? null, remainingSessions: remaining.total, remainingHostSessions: remaining.forHost ?? null, nextActions: logoutNextActions(remaining, host), } : removed > 0 ? formatLogoutMessage( `Logged out of ${plural(removed, "Dench session")}.`, remaining, host, ) : "No Dench sessions found.", ); return; } if (selector) { const matches = findStoredSessionEntries(config, selector, host); if (matches.length === 0) { throw new Error( `No Dench session matched "${selector}". Run dench sessions to see available sessions.`, ); } if (matches.length > 1) { throw new Error( `More than one Dench session matched "${selector}". Choose the exact session key:\n${matches .map((entry) => formatSessionLine(entry, undefined)) .join("\n")}`, ); } const nextConfig = removeSessionEntries(config, matches); const remaining = logoutRemainingSummary(nextConfig, matches[0].host); await saveConfig(nextConfig); print( json ? { ok: true, removed: 1, all: false, sessionKey: matches[0].key, remainingSessions: remaining.total, remainingHostSessions: remaining.forHost ?? null, nextActions: logoutNextActions(remaining, matches[0].host), } : formatLogoutMessage( `Logged out of Dench session ${matches[0].key}.`, remaining, matches[0].host, ), ); return; } const resolvedHost = host ?? resolveHost(config, scope); const result = removeStoredSession(config, resolvedHost, scope); if (result.status === "ambiguous") { throw new Error(ambiguousSessionMessage(config, scope, resolvedHost)); } if (result.status === "missing") { print( json ? { ok: true, removed: 0, all: false, host: resolvedHost, sessionScope: scope.label, } : `No Dench session found for ${resolvedHost} in this local agent context.`, ); return; } await saveConfig(result.config); const remaining = logoutRemainingSummary(result.config, resolvedHost); print( json ? { ok: true, removed: 1, all: false, host: resolvedHost, sessionScope: scope.label, sessionKey: result.removedKey, remainingSessions: remaining.total, remainingHostSessions: remaining.forHost ?? null, nextActions: logoutNextActions(remaining, resolvedHost), } : formatLogoutMessage( `Logged out of Dench session ${result.removedKey}.`, remaining, resolvedHost, ), ); } function randomUrlSafe(bytes = 32) { const values = new Uint8Array(bytes); crypto.getRandomValues(values); return Buffer.from(values) .toString("base64") .replaceAll("+", "-") .replaceAll("/", "_") .replaceAll("=", ""); } function cleanNameSegment(value: string) { return value .trim() .replace(/[^a-zA-Z0-9._-]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 24); } function localUsername() { const fromEnv = process.env.USER ?? process.env.USERNAME; if (fromEnv?.trim()) return fromEnv; try { return userInfo().username; } catch { return undefined; } } function defaultAgentName(kind: string) { const username = cleanNameSegment(localUsername() ?? ""); const machine = cleanNameSegment(hostname().split(".")[0] ?? ""); const localPart = [username, machine].filter(Boolean).join("@"); const uniquePart = randomUrlSafe(3).slice(0, 4).toLowerCase(); const suffix = [localPart, uniquePart].filter(Boolean).join("-"); return `${agentKindLabel(kind)} (${suffix})`; } function resolveAgentName(kind: string) { return option("--name")?.trim() || defaultAgentName(kind); } async function sha256Hex(input: string) { const bytes = new TextEncoder().encode(input); const digest = await crypto.subtle.digest("SHA-256", bytes); return [...new Uint8Array(digest)] .map((byte) => byte.toString(16).padStart(2, "0")) .join(""); } function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } async function discoverConvexUrl(host: string) { const explicit = option("--convex-url"); if (explicit) return explicit.trim(); if (isLocalHost(host) && process.env.NEXT_PUBLIC_CONVEX_URL) { return process.env.NEXT_PUBLIC_CONVEX_URL.trim(); } const response = await fetch(`${host}/api/agent-config`); if (!response.ok) { throw new Error(`Could not load Dench config from ${host}`); } const payload = (await response.json()) as { convexUrl?: string }; if (!payload.convexUrl) { throw new Error(`Dench config at ${host} is missing convexUrl`); } return payload.convexUrl; } /** * Read a single line from stdin. Used by `dench signin --email` for * the OTP code and (when the user has multiple admin workspaces) the * org-picker prompt. Returns the trimmed line or throws if stdin * isn't a TTY — callers can pre-supply the value via flags to avoid * the prompt in CI / non-interactive shells. */ async function promptLine(question: string): Promise { if (!process.stdin.isTTY) { throw new CliError( `Non-interactive shell: pass the answer to "${question}" via a CLI flag instead of prompting.`, { code: "stdin_not_tty" }, ); } process.stdout.write(question); return await new Promise((resolve, reject) => { let data = ""; const onData = (chunk: Buffer | string) => { data += chunk.toString(); if (data.includes("\n")) { cleanup(); resolve(data.trim()); } }; const onErr = (err: Error) => { cleanup(); reject(err); }; const cleanup = () => { process.stdin.removeListener("data", onData); process.stdin.removeListener("error", onErr); process.stdin.pause(); }; process.stdin.resume(); process.stdin.on("data", onData); process.stdin.on("error", onErr); }); } type SignInIntent = "join_existing" | "create_workspace"; function signinNextCommandsMessage( session: StoredSession, stableSessionKey: string | undefined, ) { const workspaceName = session.organization?.name ?? "unknown workspace"; const workspaceSlug = session.organization?.slug ?? "unknown"; const agentName = session.agent?.name ?? "unknown agent"; const stableKeyText = stableSessionKey ? ` Stable session key: ${stableSessionKey}.` : ""; return `Signed in. Next commands will use workspace ${workspaceName} (${workspaceSlug}) as ${agentName}.${stableKeyText}`; } /** * `dench signin` — the single unified CLI auth entry point. * * Two transports (browser link or OTP) × two intents (join existing * workspace or create a new free CRM-only workspace). Replaces the * deprecated `dench login`, `dench onboard`, `dench setup`, * `dench what-can-i-do`, and `dench register` commands. * * Flag matrix: * * (no flags) Browser link, join existing. * --new-workspace --org-name "X" Browser link, create new free workspace. * --email OTP flow, join existing. * --email + --new-workspace ... OTP flow, create new free workspace. * --org Skip the org picker (existing workspaces). * * Both transports save the resulting `dch_agent_*` session to * `~/.dench/config.json` via `saveSession()` — identical shape to the * previous login flow, so `dench logout` / `dench use` keep working. */ async function signin() { const scope = resolveSessionScope({ args: filteredArgs }); const explicitKey = explicitSessionKeyInput({ args: filteredArgs }); const host = resolveHostFromArgs(filteredArgs, undefined); const convexUrl = await discoverConvexUrl(host); const client = createCliConvexClient(convexUrl); const sessionToken = `dch_agent_${randomUrlSafe(32)}`; const sessionTokenHash = await sha256Hex(sessionToken); const agentKind = normalizeAgentKind(option("--kind")); const agentName = resolveAgentName(agentKind); const email = option("--email")?.trim().toLowerCase() || null; const newWorkspace = hasFlag("--new-workspace"); const orgNameFlag = option("--org-name")?.trim() || null; const orgFlag = option("--org")?.trim() || null; if (newWorkspace && !orgNameFlag) { throw new CliError( "--new-workspace requires --org-name ''.", { code: "missing_org_name" }, ); } if (newWorkspace && orgFlag) { throw new CliError( "Pass either --new-workspace --org-name '' OR --org , not both.", { code: "conflicting_intent" }, ); } const intent: SignInIntent = newWorkspace ? "create_workspace" : "join_existing"; if (email) { await signinViaOtp({ host, scope, explicitKey, sessionToken, sessionTokenHash, agentName, agentKind, email, intent, proposedOrganizationName: orgNameFlag, orgSelector: orgFlag, }); return; } await signinViaBrowser({ client, host, convexUrl, scope, explicitKey, sessionToken, sessionTokenHash, agentName, agentKind, intent, proposedOrganizationName: orgNameFlag, orgSelector: orgFlag, }); } /** * Browser-link variant of `dench signin`. Creates a sign-in request * row in Convex, opens the approval URL in the user's browser, then * polls until the human approves the request (and picks/creates the * target workspace on the approval page). */ async function signinViaBrowser(input: { client: ConvexHttpClient; host: string; convexUrl: string; scope: SessionScope; explicitKey: ReturnType; sessionToken: string; sessionTokenHash: string; agentName: string; agentKind: string; intent: SignInIntent; proposedOrganizationName: string | null; orgSelector: string | null; }) { const code = randomUrlSafe(18); const codeHash = await sha256Hex(code); const request = await input.client.mutation( api.functions.agentWorkspace.createSignInRequest, { codeHash, sessionTokenHash: input.sessionTokenHash, agentName: input.agentName, agentKind: input.agentKind, intent: input.intent, proposedOrganizationName: input.proposedOrganizationName ?? undefined, requestedOrganizationSelector: input.orgSelector ?? undefined, userAgent: "@dench.com/cli", }, ); const approvalUrl = `${input.host}/agent-signin/${encodeURIComponent(code)}`; const openResult = await openUrl(approvalUrl, { noOpen: hasFlag("--no-open"), json, env: process.env, }); logHuman(formatApprovalOpenMessage(approvalUrl, openResult)); if (input.intent === "create_workspace") { logHuman( `On the approval page, confirm the workspace name and connect ${input.agentName}.`, ); } else if (input.orgSelector) { logHuman( `On the approval page, confirm connecting ${input.agentName} to ${input.orgSelector}.`, ); } else { logHuman( `On the approval page, pick which workspace to connect ${input.agentName} to (or create a new one).`, ); } logHuman( `This request expires at ${new Date(request.expiresAt).toLocaleString()}.`, ); const timeoutMs = Number(option("--timeout-ms") ?? 15 * 60 * 1000); const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const poll = await input.client.mutation( api.functions.agentWorkspace.pollSignInRequest, { codeHash, sessionTokenHash: input.sessionTokenHash }, ); if (poll.status === "approved") { const storedSession: StoredSession = { host: input.host, convexUrl: input.convexUrl, sessionToken: input.sessionToken, organization: poll.organization, agent: poll.agent, sessionExpiresAt: poll.sessionExpiresAt, savedAt: Date.now(), }; await saveSession(storedSession, input.scope); const message = signinNextCommandsMessage( storedSession, input.explicitKey?.source === "DENCH_SESSION_KEY" ? input.explicitKey.value : undefined, ); print( json ? { ok: true, host: input.host, organization: poll.organization, agent: poll.agent, sessionExpiresAt: poll.sessionExpiresAt, message, } : message, ); return; } if (poll.status === "rejected" || poll.status === "expired") { throw new Error(`Sign-in ${poll.status}`); } await sleep(2000); } throw new Error( `Sign-in timed out. Open ${approvalUrl} and run \`dench signin\` again.`, ); } type OtpVerifyResult = | { kind: "completed"; organization?: { id: string; name: string; slug: string }; sessionExpiresAt?: number; } | { kind: "requiresOrgChoice"; authToken: string; organizations: Array<{ id: string; name: string; slug: string }>; expiresAt: number; }; /** * OTP variant of `dench signin`. Sends the OTP via the Next.js * agent-signin routes, prompts the human for the 6-digit code, and * mints a session against the org they pick (or create) in one or * two HTTP round-trips. */ async function signinViaOtp(input: { host: string; scope: SessionScope; explicitKey: ReturnType; sessionToken: string; sessionTokenHash: string; agentName: string; agentKind: string; email: string; intent: SignInIntent; proposedOrganizationName: string | null; orgSelector: string | null; }) { const base = input.host.replace(/\/+$/, ""); // Step 1 — send the OTP. const startResponse = await fetch(`${base}/api/agent-signin/otp-start`, { method: "POST", headers: { "content-type": "application/json", accept: "application/json" }, body: JSON.stringify({ email: input.email }), }); if (!startResponse.ok) { const body = await startResponse.text(); throw new CliError( `OTP send failed (${startResponse.status}): ${body || "unknown error"}`, { code: "otp_send_failed", status: startResponse.status }, ); } logHuman(`We sent a 6-digit sign-in code to ${input.email}.`); // Step 2 — verify the OTP. Either the user is unambiguously // resolvable (single admin org or create_workspace intent) and the // server hands us a session, OR we get an auth token + list and // need a third call. const code = option("--otp-code")?.trim() || (await promptLine("Paste OTP: ")); if (!/^\d{6}$/.test(code)) { throw new CliError("OTP code must be exactly 6 digits.", { code: "invalid_otp_code", }); } const verifyResponse = await fetch(`${base}/api/agent-signin/otp-verify`, { method: "POST", headers: { "content-type": "application/json", accept: "application/json" }, body: JSON.stringify({ email: input.email, code, agentName: input.agentName, agentKind: input.agentKind, sessionTokenHash: input.sessionTokenHash, intent: input.intent, proposedOrganizationName: input.proposedOrganizationName ?? undefined, organizationSelector: input.orgSelector ?? undefined, userAgent: "@dench.com/cli", }), }); if (!verifyResponse.ok) { const body = await verifyResponse.text(); throw new CliError( `OTP verify failed (${verifyResponse.status}): ${body || "unknown error"}`, { code: "otp_verify_failed", status: verifyResponse.status }, ); } const verifyPayload = (await verifyResponse.json()) as { ok: boolean; result: OtpVerifyResult; }; const result = verifyPayload.result; // Branch A: server minted the session in one shot. if (result.kind === "completed") { assertOtpCompletedOrgMatchesSelector( result.organization, input.orgSelector, ); await finalizeOtpSession( input, result.organization, result.sessionExpiresAt, ); return; } // Branch B: human has multiple admin orgs — pick one (or create new). const finalizeChoice = await resolveOrgChoiceForOtp({ organizations: result.organizations, orgSelector: input.orgSelector, }); const finalizeResponse = await fetch( `${base}/api/agent-signin/otp-finalize`, { method: "POST", headers: { "content-type": "application/json", accept: "application/json", }, body: JSON.stringify({ authToken: result.authToken, organizationId: finalizeChoice.organizationId, newOrganizationName: finalizeChoice.newOrganizationName, }), }, ); if (!finalizeResponse.ok) { const body = await finalizeResponse.text(); throw new CliError( `OTP finalize failed (${finalizeResponse.status}): ${body || "unknown error"}`, { code: "otp_finalize_failed", status: finalizeResponse.status }, ); } const finalizePayload = (await finalizeResponse.json()) as { ok: boolean; result: { organization?: { id: string; name: string; slug: string }; sessionExpiresAt?: number; }; }; await finalizeOtpSession( input, finalizePayload.result.organization, finalizePayload.result.sessionExpiresAt, ); } function assertOtpCompletedOrgMatchesSelector( organization: { id: string; name: string; slug: string } | undefined, orgSelector: string | null, ) { if (!orgSelector) return; const normalized = orgSelector.trim().toLowerCase(); if (!organization) { throw new CliError( `The OTP flow completed without an organization, but --org ${orgSelector} was requested.`, { code: "org_not_found" }, ); } const matches = organization.id.toLowerCase() === normalized || organization.slug.toLowerCase() === normalized || organization.name.trim().toLowerCase() === normalized; if (!matches) { throw new CliError( `The OTP flow resolved ${organization.name} (${organization.slug}), but --org ${orgSelector} was requested.`, { code: "org_mismatch" }, ); } } async function finalizeOtpSession( input: { host: string; scope: SessionScope; explicitKey: ReturnType; sessionToken: string; }, organization: { id: string; name: string; slug: string } | undefined, sessionExpiresAt: number | undefined, ) { const convexUrl = await discoverConvexUrl(input.host); const storedSession: StoredSession = { host: input.host, convexUrl, sessionToken: input.sessionToken, organization, sessionExpiresAt, savedAt: Date.now(), }; await saveSession(storedSession, input.scope); const message = signinNextCommandsMessage( storedSession, input.explicitKey?.source === "DENCH_SESSION_KEY" ? input.explicitKey.value : undefined, ); print( json ? { ok: true, host: input.host, organization, sessionExpiresAt, message, } : message, ); } async function resolveOrgChoiceForOtp(input: { organizations: Array<{ id: string; name: string; slug: string }>; orgSelector: string | null; }): Promise<{ organizationId?: string; newOrganizationName?: string }> { if (input.orgSelector) { const match = resolveOrganizationBySelector( input.organizations, input.orgSelector, ); if (!match) { throw new CliError( `--org "${input.orgSelector}" did not match any of your admin workspaces.`, { code: "org_not_found" }, ); } return { organizationId: match.id }; } // Interactive picker. logHuman(""); logHuman("Which workspace should this agent connect to?"); for (let i = 0; i < input.organizations.length; i++) { const org = input.organizations[i]; logHuman(` ${i + 1}) ${org.name} (${org.slug})`); } logHuman(` ${input.organizations.length + 1}) Create a new free workspace`); logHuman(""); const answer = await promptLine("Pick a number: "); const choice = Number(answer); if ( !Number.isFinite(choice) || choice < 1 || choice > input.organizations.length + 1 ) { throw new CliError(`Invalid choice: ${answer}`, { code: "invalid_org_choice", }); } if (choice <= input.organizations.length) { return { organizationId: input.organizations[choice - 1].id }; } const newName = await promptLine("New workspace name: "); if (!newName.trim()) { throw new CliError("Workspace name cannot be empty.", { code: "missing_org_name", }); } return { newOrganizationName: newName.trim() }; } async function parseResponseBody(response: Response) { const text = await response.text(); if (!text) return {}; try { return JSON.parse(text) as unknown; } catch { return text; } } function formatUsd(cents: number | undefined) { const value = typeof cents === "number" && Number.isFinite(cents) ? cents : 0; return `$${(value / 100).toFixed(2)}`; } function formatBillingStatus(value: unknown) { const record = asRecord(value); if (!record) return JSON.stringify(value, null, 2); const workspace = asRecord(record.workspace); const workspaceName = stringField(workspace, "name") ?? "Workspace"; const workspaceSlug = stringField(workspace, "slug"); const lines = [ `Workspace: ${workspaceName}${workspaceSlug ? ` (${workspaceSlug})` : ""}`, `AI credits: ${formatUsd(numberField(record, "availableCreditsCents"))} available`, `Used credits: ${formatUsd(numberField(record, "creditConsumedCents"))}`, ]; const status = stringField(record, "subscriptionStatus"); if (status) lines.push(`Billing status: ${status}`); lines.push("", "Top up: dench billing topup --amount 5"); return lines.join("\n"); } function parseBillingTopupAmountUsd() { const raw = option("--amount") ?? positional(2); const amount = Number(raw); if (!raw || !Number.isFinite(amount) || amount <= 0) { throw new CliError("Missing or invalid top-up amount", { code: "invalid_topup_amount", nextActions: ["Run dench billing topup --amount 5."], }); } return amount; } async function billingStatus(runtime: Runtime) { const authedRuntime = requireAuthenticatedRuntime( runtime, "dench billing status", ); const status = await authedRuntime.client.query( api.functions.agentWorkspace.getAgentBillingStatus, { sessionToken: authedRuntime.sessionToken }, ); print(json ? status : formatBillingStatus(status)); } async function billingTopup(runtime: Runtime) { const authedRuntime = requireAuthenticatedRuntime( runtime, "dench billing topup", ); const amountUsd = parseBillingTopupAmountUsd(); const response = await fetch( `${authedRuntime.host.replace(/\/+$/, "")}/api/stripe/create-ai-topup`, { method: "POST", headers: { accept: "application/json", "content-type": "application/json", authorization: `Bearer ${authedRuntime.sessionToken}`, }, body: JSON.stringify({ amountUsd, requestId: crypto.randomUUID(), }), }, ); const payload = await parseResponseBody(response); if (!response.ok) { const errorRecord = asRecord(payload); throw new CliError( stringField(errorRecord, "error", "message") ?? `Could not create top-up checkout (${response.status})`, { code: stringField(errorRecord, "code") ?? "topup_failed", status: response.status, }, ); } const url = stringField(asRecord(payload), "url"); const openResult = url ? await openUrl(url, { noOpen: hasFlag("--no-open"), json, env: process.env, }) : ({ status: "skipped", reason: "missing-url" } as const); const openMessage = url && openResult.status === "opened" ? `Attempted to open Stripe Checkout in your browser. If it did not open, use this link: ${url}` : url && openResult.status === "failed" ? `Could not open browser automatically. Open this Stripe Checkout link: ${url}` : url ? `Open this Stripe Checkout link: ${url}` : "Stripe Checkout link created."; const output = { ok: true, amountUsd, url, openResult, message: openMessage, }; print(json ? output : output.message); } /** * `dench upgrade` — turns a free CRM-only workspace into a paid Pro or * Max plan by handing the human a Stripe Checkout link. Mirrors the * `billing topup` flow (POST with the agent session as a Bearer * token, surface the returned URL) but hits the existing * `/api/stripe/create-checkout` endpoint that the marketing pricing * page already uses. * * Two flag-driven paths: * * --tier pro | max Which paid tier to target. Defaults to * "pro" (the most common upgrade from * free). * --cycle monthly | yearly Billing cycle. Defaults to "monthly". * * The endpoint can also return `{ updated: true }` for in-place * Pro \u2192 Max upgrades on an already-subscribed org (no Checkout URL * needed in that case). We surface either outcome cleanly. */ async function denchUpgrade(runtime: Runtime) { const authedRuntime = requireAuthenticatedRuntime(runtime, "dench upgrade"); const tierFlag = option("--tier")?.trim().toLowerCase() || "pro"; const cycleFlag = option("--cycle")?.trim().toLowerCase() || option("--billing-cycle")?.trim().toLowerCase() || "monthly"; if (tierFlag !== "pro" && tierFlag !== "max") { throw new CliError("--tier must be 'pro' or 'max'.", { code: "invalid_tier", }); } if (cycleFlag !== "monthly" && cycleFlag !== "yearly") { throw new CliError("--cycle must be 'monthly' or 'yearly'.", { code: "invalid_cycle", }); } // Internal tier strings still use the legacy "desktop" / "cloud" // names from before the Daytona rewrite. Map the CLI's // customer-facing flag onto them so the existing checkout endpoint // doesn't need to learn a new vocabulary. const internalTier = tierFlag === "pro" ? "desktop" : "cloud"; const response = await fetch( `${authedRuntime.host.replace(/\/+$/, "")}/api/stripe/create-checkout`, { method: "POST", headers: { accept: "application/json", "content-type": "application/json", authorization: `Bearer ${authedRuntime.sessionToken}`, }, body: JSON.stringify({ tier: internalTier, billingCycle: cycleFlag, requestId: crypto.randomUUID(), }), }, ); const payload = await parseResponseBody(response); if (!response.ok) { const errorRecord = asRecord(payload); throw new CliError( stringField(errorRecord, "error", "message") ?? `Could not create upgrade checkout (${response.status})`, { code: stringField(errorRecord, "code") ?? "upgrade_failed", status: response.status, }, ); } const record = asRecord(payload); const url = stringField(record, "url"); const updatedInPlace = record?.updated === true; if (updatedInPlace) { const message = `Upgraded to Dench ${tierFlag === "pro" ? "Pro" : "Max"} in place — no Stripe Checkout needed.`; print( json ? { ok: true, updated: true, tier: tierFlag, message } : message, ); return; } const openResult = url ? await openUrl(url, { noOpen: hasFlag("--no-open"), json, env: process.env, }) : ({ status: "skipped", reason: "missing-url" } as const); const openMessage = url && openResult.status === "opened" ? `Attempted to open Stripe Checkout in your browser. If it did not open, use this link: ${url}` : url && openResult.status === "failed" ? `Could not open browser automatically. Open this Stripe Checkout link: ${url}` : url ? `Open this Stripe Checkout link: ${url}` : "Stripe Checkout link created."; const output = { ok: true, tier: tierFlag, billingCycle: cycleFlag, url, openResult, message: openMessage, }; print(json ? output : output.message); } function hasDevEnv() { return Boolean( process.env.NEXT_PUBLIC_CONVEX_URL && process.env.DENCH_WORKSPACE && process.env.DENCH_DEV_AGENT_KEY, ); } /** * Resolve the Convex URL the API-key runtime should talk to. Sandboxes * bake `CONVEX_URL` (see `src/lib/secrets/sandbox-tokens.ts`); local * dev typically only has `NEXT_PUBLIC_CONVEX_URL` exported. We accept * either so the same code path works in both shells without callers * needing to know which env var is set. * * When neither env var is set we fall back to the baked-in production * Convex URL (`PRODUCTION_CONVEX_URL`). This is a deliberate * defense-in-depth move: if a sandbox is provisioned while a Vercel * deployment is half-configured and `CONVEX_URL` lands as empty * string, the CLI inside it still ends up pointed at the production * deployment instead of crashing on a "missing Convex URL" error. * Dev / staging shells override this by exporting `CONVEX_URL` or * `NEXT_PUBLIC_CONVEX_URL` explicitly. */ function resolveApiKeyConvexUrl(): string | undefined { return ( process.env.CONVEX_URL?.trim() || process.env.NEXT_PUBLIC_CONVEX_URL?.trim() || PRODUCTION_CONVEX_URL ); } /** * Resolve the Next.js API host the CLI should hit for HTTP calls (e.g. * Stripe billing topup, the gateway proxy). Same defense-in-depth * pattern as `resolveApiKeyConvexUrl`: env wins, then the canonical API * origin for the Dench host the runtime is targeting. */ function resolveApiHost(fallbackHost: string): string { return ( process.env.DENCH_API_URL?.trim() || process.env.DENCH_INTERNAL_BASE?.trim() || apiHostForDenchHost(fallbackHost) ); } function loginRequiredError( command = "this command", context?: { targetHost: string; targetSource: "flag" | "env" | "config" | "default"; otherHostSessions: Array>; }, ) { // Saved sessions for a DIFFERENT host are the most common reason this // error is a lie ("requires dench signin" while three valid sessions sit // in the config): the backend target moved (usually a stale // `dench backend set local` or DENCH_HOST) after the sessions were saved. // Name that directly so agents stop burning turns re-authing. const mismatchActions = context && context.otherHostSessions.length > 0 ? [ `You have ${plural(context.otherHostSessions.length, "saved Dench session")} for ${[ ...new Set(context.otherHostSessions.map((entry) => entry.host)), ].join( ", ", )}, but commands are targeting ${context.targetHost} ${backendSourceHint(context.targetSource)}.`, "Run dench backend show to inspect the target, then dench backend set to match the saved sessions.", ] : []; return new CliError(`${command} requires dench signin`, { code: "login_required", ...(context && context.otherHostSessions.length > 0 ? { targetHost: context.targetHost, savedSessionHosts: [ ...new Set(context.otherHostSessions.map((entry) => entry.host)), ], } : {}), nextActions: [ ...mismatchActions, "Set DENCH_API_KEY in your env if running inside a sandbox or CI.", 'Or run dench signin --kind --name "AI Agent - Project" to mint an agent session.', "If already approved, run dench sessions --json, then dench use .", ], }); } function missingDevEnvError() { const required = [ "NEXT_PUBLIC_CONVEX_URL", "DENCH_WORKSPACE", "DENCH_DEV_AGENT_KEY", ]; return new CliError("Missing Dench dev environment", { code: "missing_dev_env", missingEnv: required.filter((name) => !process.env[name]?.trim()), nextActions: [ "Set NEXT_PUBLIC_CONVEX_URL, DENCH_WORKSPACE, and DENCH_DEV_AGENT_KEY.", "Or remove --dev and run dench signin to use a real agent session.", ], }); } async function getRuntime() { const scope = resolveSessionScope({ args: filteredArgs }); if (!hasFlag("--dev")) { const config = await loadConfig(); const host = resolveHost(config, scope); // An injected agent-session token outranks whatever `dench signin` last // saved here — see `usesInjectedAgentSession` for why the API key does not // get the same treatment. const stored = usesInjectedAgentSession() ? ({ status: "missing" } as const) : await getStoredSession(host, scope); if (stored.status === "found") { return { mode: "session" as const, host: resolveApiHost(host), convexUrl: stored.session.convexUrl, client: createCliConvexClient(stored.session.convexUrl), sessionToken: stored.session.sessionToken, organization: stored.session.organization, }; } if (stored.status === "ambiguous") { throw new CliError(ambiguousSessionMessage(config, scope, host), { code: "session_ambiguous", nextActions: [ "Run dench sessions --json to inspect saved sessions.", "Run dench use to select one.", ], }); } const sandboxConvexUrl = resolveApiKeyConvexUrl(); const orgId = process.env.DENCH_ORG_ID?.trim(); const orgSlug = process.env.DENCH_ORG_SLUG?.trim(); const sandboxOrganization = orgId && orgSlug ? { id: orgId, name: orgSlug, slug: orgSlug } : undefined; // Sandbox / automation path. PREFER the unified Dench API key over the // `dch_agent_*` agent-session token. // // Both are baked into sandbox envVars (src/lib/secrets/sandbox-tokens.ts) // and the server's `requireCrmAccess` / `requireCliWorkspaceAccess` // accept EITHER for the full CRM + files + integration surface. But the // agent-session token EXPIRES (SANDBOX_AGENT_SESSION_TTL_MS, 7d) while // the API key is org-secret-keyed with no TTL. The old order tried the // agent token first and — critically — `getRuntime` keys the session // branch purely on the token being PRESENT, never on validity, so an // expired token in a long-lived / reused sandbox would be used blindly // and the server would reject it, even though a perfectly valid API key // sat right next to it. The server itself documents the API key as the // "preferred path for dench-cli and any automation outside the browser" // and the agent token as legacy back-compat that "new sandboxes do not // need". So prefer the key; it removes the expired-token failure mode // entirely for unattended cron/heartbeat runs. const apiKey = process.env.DENCH_API_KEY?.trim(); if (apiKey && sandboxConvexUrl) { const apiHost = resolveApiHost(host); return { mode: "apiKey" as const, host: apiHost, convexUrl: sandboxConvexUrl, client: createCliConvexClient(sandboxConvexUrl), sessionToken: apiKey, organization: sandboxOrganization, }; } // No API key present: an older sandbox image, a custom setup, or Dench // Code, which injects only this token. Still subject to expiry, but it is // the only credential available in this branch. const sandboxAgentToken = process.env.DENCH_AGENT_SESSION_TOKEN?.trim(); if (sandboxAgentToken && sandboxConvexUrl) { const apiHost = resolveApiHost(host); return { mode: "session" as const, host: apiHost, convexUrl: sandboxConvexUrl, client: createCliConvexClient(sandboxConvexUrl), sessionToken: sandboxAgentToken, organization: sandboxOrganization, }; } const snapshot = backendSnapshot(config); throw loginRequiredError("this command", { targetHost: snapshot.host, targetSource: snapshot.source, otherHostSessions: listStoredSessionEntries(config).filter( (entry) => entry.host !== normalizeHost(host), ), }); } if (!hasDevEnv()) { throw missingDevEnvError(); } return { mode: "dev" as const, client: createCliConvexClient(readEnv("NEXT_PUBLIC_CONVEX_URL")), workspaceArgs: { devKey: readEnv("DENCH_DEV_AGENT_KEY"), workspaceSlug: readEnv("DENCH_WORKSPACE"), }, }; } type Runtime = Awaited>; /** * Drop the runtime-selection flags before handing argv to a sub-dispatcher * (`dench crm`, `dench members`, …). `--json` is deliberately NOT stripped * here: every sub-dispatcher resolves its own output mode with * `hasFlag(args, "--json")`, so removing it up front made * `dench members list --json` print the human table and `dench crm … --json` * fall back to text on empty results (`(no rows)`), breaking `| jq` pipelines. */ function stripRuntimeFlags(args: string[], validateValues = true) { const booleanFlags = new Set([ "--dev", "--prod", "--staging", "--use-dev-env", ]); const valueFlags = new Set([ "--convex-url", "--host", "--backend", "--session-key", "--session", ]); const stripped: string[] = []; for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg === "--") { stripped.push(...args.slice(i)); break; } if (booleanFlags.has(arg)) continue; if (valueFlags.has(arg)) { if (!args[i + 1] || args[i + 1].startsWith("-")) { if (validateValues) throw new CliArgError(`Missing value for ${arg}`); continue; } i++; continue; } stripped.push(arg); } return stripped; } /** * Org-scoped commands (`dench crm`, `dench files`, etc.) accept either * a `dch_agent_*` agent session token OR a unified Dench API key * (`dench_*` / `gw_sk_*` / etc.) baked into sandbox envVars. The * server-side `requireCrmAccess` helper dispatches on the prefix. */ type AuthenticatedRuntime = Extract; function requireAuthenticatedRuntime( runtime: Runtime, command: string, ): AuthenticatedRuntime { if (runtime.mode === "session" || runtime.mode === "apiKey") return runtime; throw loginRequiredError(command); } async function resolveFindCommandAuth(command: string) { const runtime = await getRuntime(); if (runtime.mode === "dev") { const gatewayAuth = await resolveGatewayCommandAuth( runtime, `${command} find`, ); return { convex: runtime.client, sessionToken: encodeDevCrmSessionToken(runtime.workspaceArgs), enrichmentGateway: { apiKey: gatewayAuth.bearerToken, baseUrl: gatewayAuth.gatewayBaseUrl, }, }; } const authedRuntime = requireAuthenticatedRuntime(runtime, command); const gatewayAuth = await resolveGatewayCommandAuth( authedRuntime, `${command} find`, ); return { convex: authedRuntime.client, sessionToken: authedRuntime.sessionToken, enrichmentGateway: { apiKey: gatewayAuth.bearerToken, baseUrl: gatewayAuth.gatewayBaseUrl, }, }; } type GatewayCommandAuth = { bearerToken: string; gatewayBaseUrl?: string; gatewayHeaders?: Record; }; function envGatewayBaseUrlOverride(): string | undefined { const explicit = process.env.DENCH_GATEWAY_URL?.trim() || process.env.GATEWAY_URL?.trim(); if (!explicit) return undefined; return explicit.replace(/\/+$/, "").replace(/\/v1$/, ""); } function gatewayBaseUrlForCli(value: unknown): string | undefined { if (typeof value !== "string") return undefined; const trimmed = value.trim().replace(/\/+$/, ""); if (!trimmed) return undefined; // The desktop endpoint returns a `/v1` base; CLI gateway modules append // `/v1/...` paths themselves, so normalize to the root origin here. return trimmed.replace(/\/v1$/, ""); } async function parseJsonResponseBody(response: Response): Promise { const text = await response.text().catch(() => ""); if (!text.trim()) return {}; try { const parsed = JSON.parse(text); return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as JsonRecord) : {}; } catch { return { error: text }; } } function gatewayAuthError( command: string, response: Response, body: JsonRecord, ) { const code = typeof body.error === "string" && body.error.trim() ? body.error.trim() : `gateway_key_${response.status}`; if (response.status === 403 && code === "no_active_subscription") { return new CliError(`${command} requires a paid Dench workspace`, { code: "paid_workspace_required", nextActions: [ "Run dench upgrade --tier pro or dench upgrade --tier max, then retry.", ], }); } if (response.status === 401) { return new CliError( `${command} could not exchange the active Dench session for a gateway key (${code})`, { code, status: response.status, nextActions: [ "Run dench sessions --json to confirm the selected session.", 'Run dench signin --kind --name "AI Agent - Project" to refresh the agent session if it expired.', ], }, ); } return new CliError(`${command} could not get a gateway key (${code})`, { code, status: response.status, nextActions: [ "Confirm this agent is signed in to the intended workspace.", "If the workspace is paid, retry after a minute in case gateway key provisioning is still finishing.", ], }); } function gatewayAuthFailureReason(error: unknown): string { const nextActions = error instanceof CliError ? error.payload.nextActions : []; return [ errorMessage(error), ...(Array.isArray(nextActions) ? nextActions.filter((action) => typeof action === "string") : []), ].join(" "); } async function resolveGatewayCommandAuth( runtime: Runtime, command: string, ): Promise { if (runtime.mode === "apiKey") { return { bearerToken: runtime.sessionToken, gatewayBaseUrl: envGatewayBaseUrlOverride(), }; } if (runtime.mode === "session") { const response = await fetch( `${runtime.host.replace(/\/+$/, "")}/api/desktop/organizations/gateway-key`, { headers: { accept: "application/json", authorization: `Bearer ${runtime.sessionToken}`, }, }, ); const body = await parseJsonResponseBody(response); if (!response.ok) { throw gatewayAuthError(command, response, body); } const gatewayKey = typeof body.gatewayKey === "string" ? body.gatewayKey.trim() : ""; if (!gatewayKey) { throw new CliError(`${command} could not get a gateway key`, { code: "missing_gateway_key", }); } return { bearerToken: gatewayKey, gatewayBaseUrl: envGatewayBaseUrlOverride() ?? gatewayBaseUrlForCli(body.gatewayBaseUrl), gatewayHeaders: body.gatewayHeaders && typeof body.gatewayHeaders === "object" && !Array.isArray(body.gatewayHeaders) ? (body.gatewayHeaders as Record) : undefined, }; } const envApiKey = process.env.DENCH_API_KEY?.trim(); if (envApiKey) { return { bearerToken: envApiKey, gatewayBaseUrl: envGatewayBaseUrlOverride(), }; } throw loginRequiredError(command); } function encodeDevCrmSessionToken(args: { workspaceSlug: string; devKey: string; }) { return `dch_dev_${encodeURIComponent(args.workspaceSlug)}:${encodeURIComponent(args.devKey)}`; } /** * Convex credentials for the Unipile half of a connection listing. * * Gmail, Outlook, Calendar and LinkedIn are Dench's own connections and live in * Convex, so listing them needs a CRM bearer — not the gateway key that carries * the Composio half. Every runtime already holds both; this just picks the * shape `requireCrmReadAccess` accepts. */ function unipileQueryAuth(runtime: Runtime): { convex: ConvexHttpClient; sessionToken: string; } { return { convex: runtime.client, sessionToken: runtime.mode === "dev" ? encodeDevCrmSessionToken(runtime.workspaceArgs) : runtime.sessionToken, }; } 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 stringValue(value: unknown) { return typeof value === "string" && value.trim() ? value.trim() : undefined; } function stringField(record: JsonRecord | undefined, ...names: string[]) { if (!record) return undefined; for (const name of names) { const value = stringValue(record[name]); if (value) return value; } return undefined; } function numberField(record: JsonRecord | undefined, ...names: string[]) { if (!record) return undefined; for (const name of names) { const value = record[name]; if (typeof value === "number" && Number.isFinite(value)) return value; } return undefined; } function _booleanField(record: JsonRecord | undefined, ...names: string[]) { 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) { const text = stringValue(value)?.replace(/\s+/g, " "); if (!text) return undefined; return text.length > maxLength ? `${text.slice(0, maxLength - 1)}...` : text; } function errorMessage(error: unknown) { return error instanceof Error ? error.message : String(error); } function isMissingConvexFunction(error: unknown) { return /Could not find public function/i.test(errorMessage(error)); } function workspaceSlugFromContext(context: JsonRecord) { const workspace = asRecord(context.workspace); return ( stringField(workspace, "slug") ?? stringField(context, "workspaceSlug") ); } function workspaceUrls(host: string, workspaceSlug: string | undefined) { if (!workspaceSlug) return {}; const base = normalizeHost(host); return { dashboardUrl: `${base}/${workspaceSlug}`, approvalsUrl: `${base}/${workspaceSlug}/approvals`, }; } function nextContextCommands() { return [ "dench context --json", "dench crm entries list task --json", 'dench tool search "what you need" --toolkit ', 'dench approval request "Risky action to approve"', ]; } async function connectedAppsContext(runtime: Runtime) { try { const gatewayAuth = await resolveGatewayCommandAuth( runtime, "dench context", ); return fetchConnectedAppsSummary({ bearerToken: gatewayAuth.bearerToken, gatewayBaseUrl: gatewayAuth.gatewayBaseUrl, gatewayHeaders: gatewayAuth.gatewayHeaders, ...unipileQueryAuth(runtime), }); } catch (error) { const unipile = await fetchUnipileConnections(unipileQueryAuth(runtime)); return { available: false, reason: errorMessage(error), count: unipile.connections.length, connections: unipile.connections, nativeAvailable: unipile.available, ...(unipile.error ? { nativeError: unipile.error } : {}), }; } } async function buildContext(runtime: Runtime) { let mine: JsonRecord; let urls: JsonRecord; if (runtime.mode === "session" || runtime.mode === "apiKey") { mine = (await runtime.client.query( api.functions.agentWorkspace.whatCanIDoHere, { sessionToken: runtime.sessionToken }, )) as JsonRecord; urls = workspaceUrls(runtime.host, workspaceSlugFromContext(mine)); } else if (runtime.mode === "dev") { mine = summarizeDevMine( runtime.workspaceArgs.workspaceSlug, await devOverview(runtime), ); urls = {}; } else { throw loginRequiredError("dench context"); } return { ...mine, ...urls, connectedApps: await connectedAppsContext(runtime), nextCommands: nextContextCommands(), }; } function formatCount(value: unknown) { return typeof value === "number" ? String(value) : "0"; } function agentDisplayName(agent: JsonRecord | undefined) { return ( stringField(agent, "name") ?? stringField(agent, "slug") ?? stringField(agent, "id") ?? stringField(agent, "_id") ?? "unknown agent" ); } function formatContext(context: JsonRecord) { const counts = asRecord(context.counts); const agent = asRecord(context.agent); const workspace = asRecord(context.workspace); const connectedApps = asRecord(context.connectedApps); const connections = asRecordArray(connectedApps?.connections); const approvals = asRecordArray(context.pendingApprovals); const suggestedWork = asRecordArray(context.suggestedWork); const memories = asRecordArray(context.memory); const nextActions = Array.isArray(context.nextActions) ? context.nextActions.filter( (item): item is string => typeof item === "string", ) : []; const lines = [ "Dench context", `Workspace: ${ stringField(workspace, "name") ?? stringField(context, "workspace") ?? "unknown" }${ (stringField(workspace, "slug") ?? stringField(context, "workspaceSlug")) ? ` (${stringField(workspace, "slug") ?? stringField(context, "workspaceSlug")})` : "" }`, `Agent: ${agentDisplayName(agent)}${ stringField(agent, "kind") ? ` [${stringField(agent, "kind")}]` : "" }`, `Counts: ${formatCount( counts?.myPendingApprovals ?? approvals.length, )} pending approvals`, ]; const dashboardUrl = stringField(context, "dashboardUrl"); const approvalsUrl = stringField(context, "approvalsUrl"); if (dashboardUrl || approvalsUrl) { lines.push( `Dashboard: ${dashboardUrl ?? "unknown"}`, `Approvals: ${approvalsUrl ?? "unknown"}`, ); } lines.push("", "Pending approvals requested by me:"); if (approvals.length === 0) { lines.push(" none"); } else { lines.push( ...approvals .slice(0, 5) .map( (approval) => ` - ${stringField(approval, "title") ?? stringField(approval, "id") ?? "untitled"} [${stringField(approval, "risk") ?? "unknown"}]`, ), ); } lines.push("", "Connected apps:"); // The two brokers fail independently. Listing whichever half we DID get, and // naming the half we did not, beats collapsing both into "unavailable" — // that is how a connected calendar came to read as absent. if (connections.length === 0) { lines.push( connectedApps?.available === false ? ` unavailable: ${ stringField(connectedApps, "error", "reason") ?? "could not check apps" }` : " none", ); } else { lines.push( ...connections.slice(0, 8).map((connection) => { const label = stringField(connection, "accountLabel"); const via = connectionSource(stringField(connection, "source"), "app") === "dench" ? " via Dench" : ""; return ` - ${stringField(connection, "toolkit") ?? "unknown"} [${ stringField(connection, "status") ?? "unknown" }]${label ? ` ${label}` : ""}${via}`; }), ); if (connectedApps?.available === false) { lines.push( ` (could not check connected apps: ${ stringField(connectedApps, "error", "reason") ?? "unknown error" })`, ); } } if (connectedApps?.nativeAvailable === false) { lines.push( ` (could not check Gmail/Outlook/Calendar/LinkedIn: ${ stringField(connectedApps, "nativeError") ?? "unknown error" })`, ); } lines.push("", "Suggested work:"); if (suggestedWork.length === 0) { lines.push(" none"); } else { lines.push( ...suggestedWork .slice(0, 5) .map( (item) => ` - ${stringField(item, "title") ?? stringField(item, "id") ?? "untitled"}`, ), ); } lines.push("", "Memory:"); if (memories.length === 0) { lines.push(" none"); } else { lines.push( ...memories .slice(0, 5) .map( (memory) => ` - ${stringField(memory, "key") ?? "memory"}: ${compactLine(stringField(memory, "text"), 100) ?? ""}`, ), ); } if (nextActions.length > 0) { lines.push( "", "Next actions:", ...nextActions.map((action) => ` - ${action}`), ); } lines.push("", "Useful next commands:"); lines.push( ...nextContextCommands().map((command) => ` - ${command}`), "", "Use --json for structured output.", ); return lines.join("\n"); } async function buildWorkspaceInstructionsContext( runtime: Awaited>, sections?: string[], ) { if (runtime.mode === "dev") { return (await runtime.client.query( api.functions.agentConfig.devWorkspaceInstructions, { ...runtime.workspaceArgs, ...(sections ? { sections } : {}) }, )) as JsonRecord; } if (runtime.mode !== "session" && runtime.mode !== "apiKey") { throw loginRequiredError("dench context --instructions"); } return (await runtime.client.query( sections ? api.functions.agentConfig.workspaceContext : api.functions.agentConfig.workspaceInstructions, { sessionToken: runtime.sessionToken, ...(sections ? { sections } : {}) }, )) as JsonRecord; } async function devOverview(runtime: Awaited>) { if (runtime.mode !== "dev") { throw new Error("Not in dev mode"); } return (await runtime.client.query( api.functions.agentWorkspace.devListWorkspaceOverview, runtime.workspaceArgs, )) as WorkspaceOverview; } function summarizeDevOverview(workspaceSlug: string, data: WorkspaceOverview) { return { workspace: workspaceSlug, agents: data.agents.length, projects: data.projects.length, approvals: data.approvals.filter( (approval) => approval.status === "pending", ).length, rules: data.rules.length, }; } function compactApproval(approval: JsonRecord) { return { id: approval.id ?? approval._id, title: approval.title, risk: approval.risk, status: approval.status, createdAt: approval.createdAt, }; } function summarizeAgentStatus(status: AgentStatus, host?: string) { const agentId = status.agent?.id; const pendingApprovals = (status.approvals ?? []).filter( (approval) => approval.status === "pending" && approval.agentId === agentId, ); const urls = host ? workspaceUrls(host, status.workspaceSlug) : {}; return { workspace: status.workspace, workspaceSlug: status.workspaceSlug, ...urls, agent: status.agent, counts: { ...(status.counts ?? {}), myPendingApprovals: pendingApprovals.length, }, rules: status.rules ?? [], pendingApprovals: pendingApprovals.map(compactApproval), }; } function summarizeDevMine(workspaceSlug: string, data: WorkspaceOverview) { const agentId = option("--agent") ?? data.agents[0]?._id; const agent = data.agents.find((item) => item._id === agentId) ?? null; const pendingApprovals = data.approvals.filter( (approval) => approval.status === "pending" && approval.agentId === agentId, ); return { workspace: workspaceSlug, agent, counts: { agents: data.agents.length, projects: data.projects.length, pendingApprovals: data.approvals.filter( (approval) => approval.status === "pending", ).length, rules: data.rules.length, myPendingApprovals: pendingApprovals.length, }, rules: data.rules, pendingApprovals: pendingApprovals.map(compactApproval), }; } async function listArtifacts(runtime: Runtime, limit?: number) { if (runtime.mode === "session" || runtime.mode === "apiKey") { try { return (await runtime.client.query( api.functions.agentWorkspace.agentListArtifacts, { sessionToken: runtime.sessionToken, limit, }, )) as JsonRecord[]; } catch (error) { if (!isMissingConvexFunction(error)) throw error; const context = await buildContext(runtime); return asRecordArray(asRecord(context)?.recentArtifacts).slice( 0, limit ?? 25, ); } } const data = await devOverview(runtime); return asRecordArray(data.recentArtifacts); } async function listSuggestedWork(runtime: Runtime) { if (runtime.mode === "session" || runtime.mode === "apiKey") { const context = await buildContext(runtime); return asRecordArray(asRecord(context)?.suggestedWork); } return suggestedWorkFromArtifacts(await listArtifacts(runtime, 50)); } function suggestedWorkFromArtifacts(artifacts: JsonRecord[]) { return artifacts.filter( (artifact) => stringField(artifact, "type") === "task_suggestion" && stringField(artifact, "status") === "draft", ); } /** * The fs daemon authenticates purely from `CONVEX_URL` + `DENCH_API_KEY` * env vars (sandboxes bake them in). Local shells running off an OAuth * agent session have neither — resolve the active session and inject * its convexUrl + `dch_agent_*` token. The server-side files functions * accept the agent-session token in the same `apiKey` slot (prefix * dispatch, like `requireCrmAccess`), so the daemon works unchanged. */ async function resolveFsDaemonEnv(): Promise { if (process.env.CONVEX_URL?.trim() && process.env.DENCH_API_KEY?.trim()) { return process.env; } try { const runtime = await getRuntime(); if (runtime.mode === "session" || runtime.mode === "apiKey") { return { ...process.env, CONVEX_URL: process.env.CONVEX_URL?.trim() || runtime.convexUrl, DENCH_API_KEY: process.env.DENCH_API_KEY?.trim() || runtime.sessionToken, }; } } catch { // No saved session / dev env — fall through with the unmodified env; // the daemon prints its own missing-credential error for subcommands // that need Convex access. } return process.env; } async function runFsCommand() { const subArgs = stripRuntimeFlags(args.slice(args.indexOf("fs") + 1)); const configuredBinary = process.env.DENCH_FS_DAEMON_BIN?.trim(); const binary = configuredBinary || "dench-fs-daemon"; const daemonEnv = await resolveFsDaemonEnv(); let exitCode: number; try { exitCode = await spawnInherited(binary, subArgs, daemonEnv); } catch (error) { if (configuredBinary || !isCommandNotFound(error)) { throw error; } const localDaemon = fileURLToPath( new URL("./fs-daemon.ts", import.meta.url), ); exitCode = await spawnInherited( "bun", [localDaemon, ...subArgs], daemonEnv, ); } if (exitCode !== 0) { process.exitCode = exitCode; } } // ── dench files ──────────────────────────────────────────────── // // Escape hatches the model needs every time something on /workspace // can't be done with `mv` / `rm` — Mountpoint-S3 doesn't implement // rename(2) and the file daemon needs a hint to prune Convex rows. // All three commands take + emit POSIX paths under /workspace. function normalizeFilesPath(input: string): string { if (!input || input === "/") return "/"; let cleaned = input.replace(/\\/g, "/").replace(/\/+/g, "/"); if (cleaned.startsWith("/workspace/")) cleaned = cleaned.slice("/workspace".length); else if (cleaned === "/workspace") cleaned = "/"; if (!cleaned.startsWith("/")) cleaned = `/${cleaned}`; if (cleaned.length > 1 && cleaned.endsWith("/")) cleaned = cleaned.slice(0, -1); return cleaned; } async function requireFilesApiKey( runtime: Runtime, command: string, ): Promise { // The Convex files functions accept either a unified Dench API key OR a // `dch_agent_*` OAuth agent-session token in their `apiKey` arg (the // server dispatches on prefix, same as `requireCrmAccess`). So the // runtime's own bearer works directly — no gateway-key exchange, which // used to gate OAuth sessions behind a paid workspace and an extra // host round-trip. if (runtime.mode === "session" || runtime.mode === "apiKey") { return runtime.sessionToken; } const envApiKey = process.env.DENCH_API_KEY?.trim(); if (envApiKey) return envApiKey; throw loginRequiredError(command); } type FileTreeRow = { path: string; contentHash?: string; isDir: boolean; size?: number; mtime?: number; }; async function listConvexTreeAt( runtime: Runtime, apiKey: string, prefix: string, ): Promise { return (await runtime.client.query(api.functions.files.listTree, { prefix, apiKey, } as never)) as FileTreeRow[]; } async function listConvexTreeRecursive( runtime: Runtime, apiKey: string, prefix: string, ): Promise { const rows = await listConvexTreeAt(runtime, apiKey, prefix); const out: FileTreeRow[] = [...rows]; for (const row of rows) { if (row.isDir) { const children = await listConvexTreeRecursive(runtime, apiKey, row.path); out.push(...children); } } return out; } async function downloadConvexFileBytes( runtime: Runtime, apiKey: string, path: string, ): Promise { const url = (await runtime.client.action( api.functions.files.getFileSignedDownloadUrl, { path, apiKey } as never, )) as string | null; if (!url) return null; const response = await fetch(url); if (!response.ok) { throw new Error( `signed download failed (${response.status}): ${await response .text() .catch(() => "")}`, ); } return new Uint8Array(await response.arrayBuffer()); } async function uploadConvexFileBytes( runtime: Runtime, apiKey: string, args: { path: string; bytes: Uint8Array }, ): Promise<{ path: string; storageId: string; contentHash: string }> { const uploadUrl = (await runtime.client.mutation( api.functions.files.generateUploadUrl, { path: args.path, apiKey } as never, )) as string; const response = await fetch(uploadUrl, { method: "POST", headers: { "content-type": "application/octet-stream" }, body: new Blob([args.bytes as BlobPart]), }); if (!response.ok) { throw new Error( `workspace storage upload failed (${response.status}): ${await response .text() .catch(() => "")}`, ); } const { storageId } = (await response.json()) as { storageId: string }; const hash = sha256HexSync(args.bytes); const inlineEligible = args.bytes.byteLength <= 100_000; const inlineText = inlineEligible ? new TextDecoder("utf-8", { fatal: false }).decode(args.bytes) : undefined; await runtime.client.mutation(api.functions.files.commitFileUpload, { path: args.path, storageId, contentHash: hash, size: args.bytes.byteLength, text: inlineText, lastModifiedBy: "cli", apiKey, } as never); return { path: args.path, storageId, contentHash: hash }; } async function runFilesLs() { const runtime = await getRuntime(); const apiKey = await requireFilesApiKey(runtime, "dench files ls"); const target = normalizeFilesPath(positional(2) ?? "/"); const recursive = hasFlag("--recursive") || hasFlag("-r"); const rows = recursive ? await listConvexTreeRecursive(runtime, apiKey, target) : await listConvexTreeAt(runtime, apiKey, target); // Inline handlers share the top-level flag; filteredArgs omits --json. if (json) { print({ ok: true, prefix: target, recursive, count: rows.length, entries: rows.map((row) => ({ path: row.path, isDir: row.isDir, size: row.size, mtime: row.mtime, contentHash: row.contentHash, })), }); return; } if (rows.length === 0) { process.stdout.write(`(no entries under ${target})\n`); return; } for (const row of rows) { const kind = row.isDir ? "DIR " : "FILE"; const size = row.isDir ? " -" : (row.size ?? 0).toString().padStart(8, " "); process.stdout.write(`${kind} ${size} ${row.path}\n`); } } async function runFilesMv() { const runtime = await getRuntime(); const apiKey = await requireFilesApiKey(runtime, "dench files mv"); const fromRaw = positional(2); const toRaw = positional(3); if (!fromRaw || !toRaw) { throw new CliError("Usage: dench files mv ", { code: "files_mv_usage", nextActions: ["Pass both source and destination paths."], }); } const from = normalizeFilesPath(fromRaw); const to = normalizeFilesPath(toRaw); if (from === to) { throw new CliError("Source and destination are the same path", { code: "files_mv_noop", }); } const sourceRows = await listConvexTreeRecursive(runtime, apiKey, from); const sourceSelf = ( await listConvexTreeAt(runtime, apiKey, parentOfPath(from)) ).find((row) => row.path === from); if (!sourceSelf && sourceRows.length === 0) { throw new CliError(`Source not found in workspace storage: ${from}`, { code: "files_mv_not_found", nextActions: [ "Run `dench files ls` on the parent directory to confirm.", "If the file only exists on the sandbox FS, run the daemon (or `dench fs sync --initial`) first.", ], }); } const moved: Array<{ from: string; to: string }> = []; if (sourceSelf && !sourceSelf.isDir) { const bytes = await downloadConvexFileBytes(runtime, apiKey, from); if (!bytes) { throw new CliError(`Could not download bytes for ${from}`, { code: "files_mv_download", }); } await uploadConvexFileBytes(runtime, apiKey, { path: to, bytes }); await runtime.client.mutation(api.functions.files.deleteFile, { path: from, apiKey, } as never); moved.push({ from, to }); } else { for (const row of sourceRows) { if (row.isDir) continue; const rel = row.path.slice(from.length).replace(/^\/+/, ""); const dest = rel ? `${to}/${rel}` : to; const bytes = await downloadConvexFileBytes(runtime, apiKey, row.path); if (!bytes) continue; await uploadConvexFileBytes(runtime, apiKey, { path: dest, bytes }); await runtime.client.mutation(api.functions.files.deleteFile, { path: row.path, apiKey, } as never); moved.push({ from: row.path, to: dest }); } if (sourceSelf?.isDir) { await runtime.client.mutation(api.functions.files.deleteFile, { path: from, apiKey, } as never); } } print({ ok: true, from, to, moved }); } async function runFilesRm() { const runtime = await getRuntime(); const apiKey = await requireFilesApiKey(runtime, "dench files rm"); const targetRaw = positional(2); if (!targetRaw) { throw new CliError("Usage: dench files rm [--recursive]", { code: "files_rm_usage", }); } const target = normalizeFilesPath(targetRaw); const recursive = hasFlag("--recursive") || hasFlag("-r"); const parentRows = await listConvexTreeAt( runtime, apiKey, parentOfPath(target), ); const self = parentRows.find((row) => row.path === target); if (!self) { if (json) { print({ ok: true, path: target, removedFiles: 0, removedDirs: 0 }); } else { process.stdout.write( `dench files rm: ${target} not present in workspace storage (already absent)\n`, ); } return; } if (self.isDir && !recursive) { throw new CliError( `${target} is a directory; pass --recursive to delete it.`, { code: "files_rm_recursive" }, ); } const toDelete = self.isDir ? await listConvexTreeRecursive(runtime, apiKey, target) : []; const fileRows = toDelete.filter((row) => !row.isDir); const dirRows = toDelete.filter((row) => row.isDir); // Children first, parents last. dirRows.sort((a, b) => b.path.length - a.path.length); for (const row of fileRows) { await runtime.client.mutation(api.functions.files.deleteFile, { path: row.path, apiKey, } as never); } for (const row of dirRows) { await runtime.client.mutation(api.functions.files.deleteFile, { path: row.path, apiKey, } as never); } await runtime.client.mutation(api.functions.files.deleteFile, { path: target, apiKey, } as never); print({ ok: true, path: target, removedFiles: fileRows.length + (self.isDir ? 0 : 1), removedDirs: dirRows.length + (self.isDir ? 1 : 0), }); } /** * Print a workspace file's text. * * `download` cannot reach the magic files (IDENTITY.md, ORGANISATION.md, …) * because they render from agentConfig and have no storage blob, so this is * the only way to read them from outside a browser session. */ async function runFilesCat() { const runtime = await getRuntime(); const apiKey = await requireFilesApiKey(runtime, "dench files cat"); const targetRaw = positional(2); if (!targetRaw) { throw new CliError("Usage: dench files cat [--json]", { code: "files_cat_usage", }); } const target = normalizeFilesPath(targetRaw); const file = (await runtime.client.query(api.functions.files.getFileContent, { path: target, apiKey, } as never)) as { path: string; isDir: boolean; virtual: boolean; size: number | null; content: string | null; downloadable: boolean; } | null; if (!file) { throw new CliError(`Not found in workspace storage: ${target}`, { code: "files_cat_not_found", nextActions: [ "Run `dench files ls` on the parent directory to confirm.", "If the file exists only on local disk, upload it with `dench stage `.", ], }); } if (file.isDir) { throw new CliError(`${target} is a directory`, { code: "files_cat_directory", nextActions: ["Run `dench files ls` to list its entries."], }); } if (file.content === null) { throw new CliError(`No inline text for ${target}`, { code: "files_cat_not_text", nextActions: file.downloadable ? ["Run `dench files download` to fetch the bytes."] : ["The file has no stored bytes yet."], }); } if (json) { print({ ok: true, ...file }); return; } process.stdout.write( file.content.endsWith("\n") ? file.content : `${file.content}\n`, ); } /** * Replace a workspace file's text, reading the new content from stdin. * * `stage` copies from a sandbox path and `commitFileUpload` needs an upload * round-trip, so neither works for "change these few lines from a terminal". */ async function runFilesWrite() { const runtime = await getRuntime(); const apiKey = await requireFilesApiKey(runtime, "dench files write"); const targetRaw = positional(2); if (!targetRaw) { throw new CliError("Usage: dench files write < file.md", { code: "files_write_usage", nextActions: ["Pipe the new contents in on stdin."], }); } const target = normalizeFilesPath(targetRaw); if (process.stdin.isTTY) { throw new CliError("dench files write reads the new contents from stdin", { code: "files_write_stdin_tty", nextActions: [ "Pipe the file in: `dench files write < local.md`.", "Or use `dench stage ` to copy a local file.", ], }); } const chunks: Buffer[] = []; for await (const chunk of process.stdin as AsyncIterable) { chunks.push(chunk); } const content = Buffer.concat(chunks).toString("utf8"); const result = (await runtime.client.action( api.functions.files.editFileFromUI, { path: target, content, apiKey, } as never, )) as { path: string; contentHash: string }; if (json) { print({ ok: true, ...result, bytes: Buffer.byteLength(content, "utf8") }); return; } process.stdout.write( `dench files write: ${result.path} (${Buffer.byteLength(content, "utf8")}B)\n`, ); } async function writeLocalFileBytes(localPath: string, bytes: Uint8Array) { const parent = dirname(localPath); if (parent && parent !== ".") { await mkdir(parent, { recursive: true }); } await writeFile(localPath, bytes); } async function runFilesDownload() { const runtime = await getRuntime(); const apiKey = await requireFilesApiKey(runtime, "dench files download"); const targetRaw = positional(2); if (!targetRaw) { throw new CliError( "Usage: dench files download [] [--url]", { code: "files_download_usage" }, ); } const target = normalizeFilesPath(targetRaw); const jsonOut = json; if (hasFlag("--url")) { const url = (await runtime.client.action( api.functions.files.getFileSignedDownloadUrl, { path: target, apiKey } as never, )) as string | null; if (!url) { throw new CliError(`No downloadable bytes for ${target}`, { code: "files_download_url_not_found", nextActions: [ "Directories have no signed URL; run `dench files download ` without --url to fetch the tree.", "Run `dench files ls` on the parent directory to confirm the path.", ], }); } if (jsonOut) { print({ ok: true, path: target, url, note: "Signed URL expires." }); } else { process.stdout.write(`${url}\n`); } return; } const self = target === "/" ? null : (await listConvexTreeAt(runtime, apiKey, parentOfPath(target))).find( (row) => row.path === target, ); const isDir = target === "/" || self?.isDir === true; if (!isDir) { const bytes = await downloadConvexFileBytes(runtime, apiKey, target); if (!bytes) { throw new CliError(`Not found in workspace storage: ${target}`, { code: "files_download_not_found", nextActions: [ "Run `dench files ls` on the parent directory to confirm.", "If the file exists only on local disk, upload it with `dench stage `.", ], }); } let dest = resolve(positional(3) ?? basename(target)); const destStat = await stat(dest).catch(() => null); if (destStat?.isDirectory()) { dest = join(dest, basename(target)); } await writeLocalFileBytes(dest, bytes); if (jsonOut) { print({ ok: true, path: target, dest, bytes: bytes.byteLength }); } else { process.stdout.write( `dench files download: ${target} -> ${dest} (${bytes.byteLength}B)\n`, ); } return; } // Directory: recreate the tree locally under (defaults to // the directory's basename in the cwd). const destRoot = resolve( positional(3) ?? (target === "/" ? "workspace" : basename(target)), ); const rows = await listConvexTreeRecursive(runtime, apiKey, target); const prefixLength = target === "/" ? 1 : target.length + 1; let downloaded = 0; let bytesTotal = 0; const skipped: string[] = []; await mkdir(destRoot, { recursive: true }); for (const row of rows) { const rel = row.path.slice(prefixLength); if (!rel) continue; const local = join(destRoot, rel); if (row.isDir) { await mkdir(local, { recursive: true }); continue; } const bytes = await downloadConvexFileBytes(runtime, apiKey, row.path); if (!bytes) { skipped.push(row.path); continue; } await writeLocalFileBytes(local, bytes); downloaded += 1; bytesTotal += bytes.byteLength; if (!jsonOut && downloaded % 25 === 0) { process.stdout.write( `dench files download: ${downloaded} files, ${bytesTotal}B…\n`, ); } } if (jsonOut) { print({ ok: true, path: target, dest: destRoot, files: downloaded, bytes: bytesTotal, skipped, }); } else { process.stdout.write( `dench files download: ${downloaded} files (${bytesTotal}B) -> ${destRoot}${ skipped.length > 0 ? ` (${skipped.length} skipped)` : "" }\n`, ); } } async function runFilesCommand() { const sub = positional(1); if (!sub || sub === "help" || hasFlag("--help")) { const { commandHelp } = await import("./lib/command-help"); process.stdout.write(`${commandHelp(["files"], rootUsageText())}\n`); return; } if (sub === "ls") return await runFilesLs(); if (sub === "cat") return await runFilesCat(); if (sub === "write") return await runFilesWrite(); if (sub === "mv") return await runFilesMv(); if (sub === "rm") return await runFilesRm(); if (sub === "download") return await runFilesDownload(); throw new CliError(`Unknown subcommand: dench files ${sub}`, { code: "files_unknown_subcommand", nextActions: ["Run dench files help for usage."], }); } // ── dench stage ────────────────────────────────────────────────────────── // // Copy a tree from a /tmp scratch path into /workspace AND update the // Convex `fileTree` mirror so the UI tree reflects the new files // immediately. The agent's `stage_to_workspace` typed tool calls into // the same primitive server-side; this CLI variant exists so a human at // a sandbox shell (or a script) can run the same operation manually // without bouncing through the chat API. // // Why upload to Convex (instead of `cp -r local /workspace`): // /workspace is FUSE-backed (Mountpoint-S3) and the file daemon // reconciles between the volume and Convex. Uploading via Convex // guarantees the UI tree updates immediately (no wait for the // daemon's polling tick) and avoids stomping a daemon-in-flight // change on the same path. const STAGE_IGNORED_BASENAMES = new Set([ ".DS_Store", ".ds_store", "Thumbs.db", "thumbs.db", "desktop.ini", ]); const STAGE_IGNORED_DIRS = new Set([ ".git", ".next", ".vercel", "__MACOSX", "build", "coverage", "dist", "node_modules", ]); function shouldIgnoreStagePath(relPath: string): boolean { const parts = relPath.split("/").filter(Boolean); if (parts.some((p) => STAGE_IGNORED_DIRS.has(p))) return true; const base = parts.at(-1) ?? ""; if (STAGE_IGNORED_BASENAMES.has(base)) return true; if (base.startsWith("._")) return true; if ( base === ".env" || (base.startsWith(".env.") && ![".env.example", ".env.sample", ".env.template"].includes(base)) ) { return true; } if (base.endsWith(".pem") || base.endsWith(".key")) return true; return false; } type DirEntryLike = { name: string; isDirectory(): boolean; isFile(): boolean; }; async function* walkLocalFiles( rootAbs: string, ): AsyncGenerator<{ absPath: string; rel: string; size: number }> { const queue: string[] = [rootAbs]; while (queue.length > 0) { const dir = queue.shift(); if (!dir) continue; let entries: DirEntryLike[]; try { // The Node typings for readdir({ withFileTypes: true }) lean on // generics that bun's stub doesn't surface cleanly; cast through // unknown so we get the {name, isDirectory, isFile} contract we // actually depend on without dragging Dirent generics through. entries = (await readdir(dir, { withFileTypes: true, })) as unknown as DirEntryLike[]; } catch { continue; } for (const entry of entries) { const abs = join(dir, entry.name); const rel = relative(rootAbs, abs).replace(/\\/g, "/"); if (shouldIgnoreStagePath(rel)) continue; if (entry.isDirectory()) { queue.push(abs); continue; } if (!entry.isFile()) continue; const stats = await stat(abs).catch(() => null); if (!stats) continue; yield { absPath: abs, rel, size: stats.size }; } } } async function runStage() { const runtime = await getRuntime(); const apiKey = await requireFilesApiKey(runtime, "dench stage"); const localRaw = positional(1); const destRaw = positional(2); if (!localRaw) { throw new CliError( "Usage: dench stage [] [--clean] [--json]", { code: "stage_usage", nextActions: [ "Pass an absolute local path (typically under /tmp) to stage from.", "Optional second arg is the /workspace destination (defaults to /).", ], }, ); } const localAbs = resolve(localRaw); if (localAbs.startsWith("/workspace/") || localAbs === "/workspace") { throw new CliError( `localPath must not be inside /workspace (got ${localAbs}). Stage from /tmp scratch instead.`, { code: "stage_local_in_workspace" }, ); } let localStat: Awaited>; try { localStat = await stat(localAbs); } catch (error) { throw new CliError( `Local path not found: ${localAbs} (${error instanceof Error ? error.message : String(error)})`, { code: "stage_local_missing" }, ); } // Default workspace destination = /. The agent // typed tool requires explicit dest; the CLI gives a friendly default // because humans usually want "stage this dir to /workspace/". const destWorkspace = destRaw ? normalizeFilesPath(destRaw) : normalizeFilesPath(`/${basename(localAbs)}`); const clean = hasFlag("--clean"); // `--json` is consumed at the top of the file into the module-level // `json` flag (and filtered out of `args`); read that, not `hasFlag`. const jsonOut = json; if (clean) { // Wipe Convex rows under destWorkspace so a clean install really // is clean. The daemon will mirror the deletes onto the volume // on its next reconcile tick. const existing = await listConvexTreeRecursive( runtime, apiKey, destWorkspace, ); const fileRows = existing.filter((r) => !r.isDir); const dirRows = existing.filter((r) => r.isDir); dirRows.sort((a, b) => b.path.length - a.path.length); for (const row of fileRows) { await runtime.client.mutation(api.functions.files.deleteFile, { path: row.path, apiKey, } as never); } for (const row of dirRows) { await runtime.client.mutation(api.functions.files.deleteFile, { path: row.path, apiKey, } as never); } } const staged: Array<{ from: string; to: string; bytes: number }> = []; const recordedDirs = new Set(); let bytesStaged = 0; // `commitFileUpload` only inserts the leaf file row — it does NOT // create rows for the file's intermediate parent dirs. Without // those, `listTree(prefix)` won't surface a `sub` directory in the // UI tree even when `sub/notes.txt` exists. We record each unique // parent dir explicitly via `recordDirCreate` so the right-pane // tree mirrors the staged structure 1:1. async function ensureDirRowsFor(filePath: string): Promise { let parent = parentOfPath(filePath); while (parent && parent !== "/" && !recordedDirs.has(parent)) { recordedDirs.add(parent); try { await runtime.client.mutation(api.functions.files.recordDirCreate, { path: parent, apiKey, } as never); } catch (error) { // Best-effort: a transient failure on a single dir shouldn't // fail the entire stage. The leaf files are already persisted; // worst case the daemon's reconciler picks up the dir on its // next tick. if (!jsonOut) { process.stderr.write( `dench stage: warn — recordDirCreate ${parent} failed: ${ error instanceof Error ? error.message : String(error) }\n`, ); } } parent = parentOfPath(parent); } } if (localStat.isFile()) { const bytes = await readFile(localAbs); const dest = destWorkspace; await ensureDirRowsFor(dest); await uploadConvexFileBytes(runtime, apiKey, { path: dest, bytes: new Uint8Array(bytes), }); staged.push({ from: localAbs, to: dest, bytes: bytes.byteLength }); bytesStaged += bytes.byteLength; } else if (localStat.isDirectory()) { for await (const entry of walkLocalFiles(localAbs)) { const dest = entry.rel ? normalizeFilesPath(`${destWorkspace}/${entry.rel}`) : destWorkspace; await ensureDirRowsFor(dest); const bytes = await readFile(entry.absPath); await uploadConvexFileBytes(runtime, apiKey, { path: dest, bytes: new Uint8Array(bytes), }); staged.push({ from: entry.absPath, to: dest, bytes: bytes.byteLength, }); bytesStaged += bytes.byteLength; if (!jsonOut && staged.length % 25 === 0) { process.stdout.write( `dench stage: ${staged.length} files, ${bytesStaged}B…\n`, ); } } } else { throw new CliError( `Local path is neither a file nor a directory: ${localAbs}`, { code: "stage_local_unsupported" }, ); } if (jsonOut) { print({ ok: true, localPath: localAbs, workspacePath: destWorkspace, filesStaged: staged.length, bytesStaged, files: staged, clean, }); return; } process.stdout.write( `dench stage: ${staged.length} files (${bytesStaged}B) staged from ${localAbs} -> ${destWorkspace}${ clean ? " [clean install]" : "" }\n`, ); } function parentOfPath(input: string): string { const normalized = normalizeFilesPath(input); if (normalized === "/") return "/"; const idx = normalized.lastIndexOf("/"); return idx <= 0 ? "/" : normalized.slice(0, idx); } function sha256HexSync(bytes: Uint8Array): string { // Used by the dench files commands so we can compute the hash // alongside the upload synchronously. Bun + Node both ship // node:crypto, so this is free. return createHash("sha256").update(bytes).digest("hex"); } async function spawnInherited( binary: string, commandArgs: string[], env: NodeJS.ProcessEnv = process.env, ) { return await new Promise((resolve, reject) => { const child = spawn(binary, commandArgs, { stdio: "inherit", env, }); child.on("error", reject); child.on("close", (code) => resolve(code ?? 1)); }); } function isCommandNotFound(error: unknown) { return ( error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT" ); } async function main() { if (isHelpRequest(runtimeArgs)) { const helpArgs = stripRuntimeFlags(runtimeArgs, false).filter( (arg) => !["--json", "--help", "-h"].includes(arg), ); if (helpArgs[0] === "help") helpArgs.shift(); if (!helpArgs.length) { await help(); return; } const path = helpArgs.filter( (arg) => !["help", "--help", "-h"].includes(arg), ); if (path[0] === "daily") { console.log( `dench ${path.join(" ")} was retired. Search history with dench chat search or read existing daily files with dench files download. Harness A does not automatically write daily logs.`, ); return; } // Root help retains domain-specific setup, account and pagination guidance. // These are explicit help-only paths; never pass mutation arguments to a handler. if (path.length === 1) { const roots: Record void | Promise> = { reference: async () => console.log((await import("./reference")).REFERENCE_HELP), env: async () => console.log((await import("./environments")).environmentHelpText()), secrets: async () => console.log( (await import("./workspace-secrets")).workspaceSecretsHelpText(), ), tool: toolHelp, integrations: integrationsHelp, backend: backendHelp, signin: signinHelp, login: signinHelp, onboard: signinHelp, setup: signinHelp, "what-can-i-do": signinHelp, register: signinHelp, memory: memoryHelp, billing: billingHelp, logout: logoutHelp, context: contextHelp, artifacts: artifactsHelp, "suggested-work": artifactsHelp, lists: listsCliHelp, leads: leadsHelp, companies: companiesHelp, jobs: jobsHelp, status: statusHelp, web: async () => (await import("./web")).webHelp(), crm: async () => (await import("./crm")).runCrmCommand({ convex: {} as ConvexHttpClient, args: ["help"], }), apps: async () => (await import("./apps")).runAppsCommand({ convex: {} as ConvexHttpClient, args: ["help"], }), }; if (roots[path[0]]) { await roots[path[0]](); return; } } if (helpArgs[0] === "reference") { console.log((await import("./reference")).REFERENCE_HELP); return; } // Preserve the small, established CRM read help including its cursor contract. if (helpArgs[0] === "crm" && ["query", "aggregate"].includes(helpArgs[1])) { const { runCrmCommand } = await import("./crm"); await runCrmCommand({ convex: {} as ConvexHttpClient, args: [...helpArgs.slice(1), "--help"], }); return; } const { commandHelp, normalizeHelpCommand } = await import( "./lib/command-help" ); const family = normalizeHelpCommand(helpArgs)[0]; const helpText: Record Promise> = { tool: async () => (await import("./tools")).toolHelpText(), email: async () => (await import("./email")).emailHelpText(), crm: async () => (await import("./crm")).crmHelpText(), env: async () => (await import("./environments")).environmentHelpText(), secrets: async () => (await import("./workspace-secrets")).workspaceSecretsHelpText(), chat: async () => (await import("./chat")).chatHelpText(), cron: async () => (await import("./cron")).cronHelpText(), apps: async () => (await import("./apps")).appsHelpText(), members: async () => (await import("./members")).membersHelpText(), web: async () => (await import("./web")).webHelpText(), meetings: async () => (await import("./meetings")).meetingsHelpText(), lists: async () => (await import("./leads")).listsHelpText(), browser: async () => (await import("./browser")).browserHelpText(), search: async () => (await import("./search")).searchHelpText(), image: async () => (await import("./image")).imageHelpText(), ads: async () => (await import("./ads")).adsHelpText(), campaign: async () => (await import("./campaign")).campaignHelpText(), }; const usage = helpText[family] ? await helpText[family]() : rootUsageText(); console.log(commandHelp(helpArgs, usage)); return; } const command = positional(0); const subcommand = positional(1); assertDeclaredCommandFlags(stripRuntimeFlags(args)); // New setup commands validate before authentication and never consume child flags. if (command === "env" || command === "secrets") { const rawSubArgs = args.slice(args.indexOf(command) + 1); const helpRequested = runtimeArgs.includes("--help") || runtimeArgs.includes("-h") || subcommand === "help"; const subArgs = helpRequested ? rawSubArgs : stripRuntimeFlags(rawSubArgs); const mod = command === "env" ? await import("./environments") : await import("./workspace-secrets"); if ("validateEnvironmentArgs" in mod) { const parsed = mod.validateEnvironmentArgs(subArgs); if (parsed.verb === "help") { console.log(mod.environmentHelpText()); return; } const runtime = requireAuthenticatedRuntime( await getRuntime(), "dench env", ); await mod.runEnvironmentCommand({ convex: runtime.client, args: subArgs, sessionToken: runtime.sessionToken, apiUrl: runtime.host, }); } else { const parsed = mod.validateWorkspaceSecretsArgs(subArgs); if (parsed.verb === "help") { console.log(mod.workspaceSecretsHelpText()); return; } const runtime = requireAuthenticatedRuntime( await getRuntime(), "dench secrets", ); await mod.runWorkspaceSecretsCommand({ convex: runtime.client, args: subArgs, sessionToken: runtime.sessionToken, }); } return; } if ( command === "context" && (runtimeArgs.includes("--help") || runtimeArgs.includes("-h") || subcommand === "help") ) { contextHelp(); return; } if (command === "daily") { const guidance = "Daily-log commands were retired. Search actual history with dench chat search or read existing daily files with dench files download. Nothing writes daily logs automatically in Harness A."; if ( runtimeArgs.includes("--help") || runtimeArgs.includes("-h") || subcommand === "help" ) { console.log(guidance); return; } throw new CliError(guidance, { code: "deprecated_command", retryable: false, }); } if (command === "context" && subcommand === "set") { const { validateContextSet, runContextSet } = await import( "./context-settings" ); const subArgs = stripRuntimeFlags(args.slice(args.indexOf(command) + 1)); validateContextSet(subArgs); const runtime = requireAuthenticatedRuntime( await getRuntime(), "dench context set", ); await runContextSet({ convex: runtime.client, args: subArgs, sessionToken: runtime.sessionToken, }); return; } if ( command === "memory" && (runtimeArgs.includes("--help") || runtimeArgs.includes("-h")) ) { memoryHelp(); return; } if (command === "memory" && subcommand === "save") { const kind = option("--kind") ?? "fact"; if ( ![ "fact", "decision", "preference", "goal", "tool_note", "other", "convention", "project_context", "agent_lesson", "docs_knowledge", "open_goal", ].includes(kind) ) throw new CliError( "Unsupported memory kind. Use memory for facts/preferences; use dench secrets set NAME --stdin for credentials and dench env save for reusable setup.", { code: "validation_failed", retryable: false }, ); } if (command === "reference") { await (await import("./reference")).runReferenceCommand( stripRuntimeFlags(args.slice(args.indexOf("reference") + 1)), ); return; } if ( command === "version" || stripRuntimeFlags(runtimeArgs)[0] === "--version" ) { console.log(await cliVersion()); return; } if (!command || command === "help") { await help(); return; } if ( command === "tool" && (!subcommand || subcommand === "help" || hasFlag("--help")) ) { toolHelp(); return; } if ( command === "integrations" && (subcommand === "help" || hasFlag("--help")) ) { integrationsHelp(); return; } if ( command === "browser" && (!subcommand || subcommand === "help" || hasFlag("--help")) ) { const { runBrowserCommand } = await import("./browser"); await runBrowserCommand({ args: subcommand ? [subcommand] : ["help"], runtime: { host: "", bearerToken: "" }, }); return; } if ( command === "email" && (!subcommand || subcommand === "help" || hasFlag("--help")) ) { const { runEmailCommand } = await import("./email"); await runEmailCommand({ // biome-ignore lint/suspicious/noExplicitAny: harmless help-only stub convex: {} as any, args: subcommand ? [subcommand] : ["help"], jsonOutput: json, }); return; } if ( command === "billing" && (!subcommand || subcommand === "help" || hasFlag("--help")) ) { billingHelp(); return; } const isDeprecatedAuthCommand = command === "login" || command === "onboard" || command === "setup" || command === "what-can-i-do" || command === "register"; if ( (command === "signin" || isDeprecatedAuthCommand) && (subcommand === "help" || hasFlag("--help")) ) { signinHelp(); return; } // `dench login`, `dench onboard`, `dench setup`, `dench what-can-i-do`, // and `dench register` were removed in @dench.com/cli v2. Surface a // clean redirect to the new unified surface so old `npx` caches and // pre-existing agent prompts get a one-line nudge instead of an // opaque "Unknown command" error. if (isDeprecatedAuthCommand) { throw new CliError( `\`dench ${command}\` was removed in @dench.com/cli v2. Run \`dench signin\` instead.`, { code: "deprecated_command", nextActions: [ "Run `dench signin` to connect this agent to an existing workspace.", 'Run `dench signin --new-workspace --org-name ""` to create a free CRM-only workspace.', "Upgrade the CLI globally with `npm install -g @dench.com/cli@latest`.", ], }, ); } if (command === "context" && hasFlag("--help")) { contextHelp(); return; } if ( command === "memory" && (!subcommand || subcommand === "help" || hasFlag("--help")) ) { memoryHelp(); return; } if ( (command === "artifacts" || command === "suggested-work") && (subcommand === "help" || hasFlag("--help")) ) { artifactsHelp(); return; } if (command === "lists" && (subcommand === "help" || hasFlag("--help"))) { await listsCliHelp(); return; } if (command === "leads" && (subcommand === "help" || hasFlag("--help"))) { leadsHelp(); return; } if (command === "companies" && (subcommand === "help" || hasFlag("--help"))) { companiesHelp(); return; } if (command === "jobs" && (subcommand === "help" || hasFlag("--help"))) { jobsHelp(); return; } if (command === "status" && hasFlag("--help")) { statusHelp(); return; } if (command === "logout" && hasFlag("--help")) { logoutHelp(); return; } if (command === "backend" && (subcommand === "help" || hasFlag("--help"))) { backendHelp(); return; } if (command === "crm") { const subArgs = stripRuntimeFlags(args.slice(args.indexOf("crm") + 1)); const sub = subArgs[0]; const { runCrmCommand, isCrmReadHelpRequest } = await import("./crm"); // Short-circuit help so users don't need a session to read the // surface. The crm dispatcher's own help resolves --json + the // subcommand list inline. if ( !sub || sub === "help" || sub === "--help" || isCrmReadHelpRequest(subArgs) ) { // runCrmCommand only needs `convex` for actual queries; help // doesn't touch it. Pass a minimal stub. // biome-ignore lint/suspicious/noExplicitAny: harmless help-only stub await runCrmCommand({ convex: {} as any, args: subArgs }); return; } const runtime = await getRuntime(); // CRM is org-scoped — accept either an agent session token // (`dch_agent_*` from `dench signin`) or a unified Dench API key // (`DENCH_API_KEY` baked into sandbox envVars). Server-side // `requireCrmAccess` (convex/lib/crm/access.ts) dispatches on the // bearer prefix and resolves the org for both. if (runtime.mode === "dev") { const gatewayAuth = subArgs[0] === "enrich" ? await resolveGatewayCommandAuth(runtime, "dench crm enrich") : undefined; await runCrmCommand({ convex: runtime.client, args: subArgs, sessionToken: encodeDevCrmSessionToken(runtime.workspaceArgs), host: resolveApiHost(LOCAL_HOST), workspaceSlug: runtime.workspaceArgs.workspaceSlug, enrichmentGateway: gatewayAuth ? { apiKey: gatewayAuth.bearerToken, baseUrl: gatewayAuth.gatewayBaseUrl, } : undefined, }); return; } const authedRuntime = requireAuthenticatedRuntime(runtime, "dench crm"); const gatewayAuth = subArgs[0] === "enrich" ? await resolveGatewayCommandAuth(authedRuntime, "dench crm enrich") : undefined; await runCrmCommand({ convex: authedRuntime.client, args: subArgs, sessionToken: authedRuntime.sessionToken, host: authedRuntime.host, workspaceSlug: authedRuntime.organization?.slug ?? process.env.DENCH_ORG_SLUG?.trim() ?? undefined, enrichmentGateway: gatewayAuth ? { apiKey: gatewayAuth.bearerToken, baseUrl: gatewayAuth.gatewayBaseUrl, } : undefined, }); return; } if (command === "meetings") { // Meetings are CRM records; this surface adds the side tables the CRM // verbs cannot reach (transcript, action items, attendance) and the // composite `import`. Same org-scoped auth shape as `dench crm`. const subArgs = stripRuntimeFlags(args.slice(args.indexOf("meetings") + 1)); const sub = subArgs[0]; if (!sub || sub === "help" || sub === "--help") { const { runMeetingsCommand } = await import("./meetings"); // biome-ignore lint/suspicious/noExplicitAny: harmless help-only stub await runMeetingsCommand({ convex: {} as any, args: subArgs }); return; } const { runMeetingsCommand } = await import("./meetings"); const runtime = await getRuntime(); if (runtime.mode === "dev") { await runMeetingsCommand({ convex: runtime.client, args: subArgs, sessionToken: encodeDevCrmSessionToken(runtime.workspaceArgs), host: resolveApiHost(LOCAL_HOST), workspaceSlug: runtime.workspaceArgs.workspaceSlug, }); return; } const authedRuntime = requireAuthenticatedRuntime( runtime, "dench meetings", ); await runMeetingsCommand({ convex: authedRuntime.client, args: subArgs, sessionToken: authedRuntime.sessionToken, host: authedRuntime.host, workspaceSlug: authedRuntime.organization?.slug ?? process.env.DENCH_ORG_SLUG?.trim() ?? undefined, }); return; } if (command === "leads") { const { runLeadsCommand, assertLeadsFindArgs, runFindFiltersCommand } = await import("./leads"); const leadArgs = stripRuntimeFlags(args.slice(args.indexOf("leads") + 1)); // `filters` prints bundled catalogs — no session or network needed. if (leadArgs[0] === "filters") { runFindFiltersCommand({ args: leadArgs.slice(1), jsonOutput: json }); return; } assertLeadsFindArgs(leadArgs); const auth = await resolveFindCommandAuth("dench leads"); await runLeadsCommand({ convex: auth.convex, args: leadArgs, jsonOutput: json, sessionToken: auth.sessionToken, enrichmentGateway: auth.enrichmentGateway, }); return; } if (command === "companies") { const { runCompaniesCommand, assertCompaniesFindArgs } = await import( "./leads" ); const companyArgs = stripRuntimeFlags( args.slice(args.indexOf("companies") + 1), ); assertCompaniesFindArgs(companyArgs); const auth = await resolveFindCommandAuth("dench companies"); await runCompaniesCommand({ convex: auth.convex, args: companyArgs, jsonOutput: json, sessionToken: auth.sessionToken, enrichmentGateway: auth.enrichmentGateway, }); return; } if (command === "jobs") { const { runJobsCommand, assertJobsFindArgs } = await import("./leads"); const jobArgs = stripRuntimeFlags(args.slice(args.indexOf("jobs") + 1)); assertJobsFindArgs(jobArgs); const auth = await resolveFindCommandAuth("dench jobs"); await runJobsCommand({ convex: auth.convex, args: jobArgs, jsonOutput: json, sessionToken: auth.sessionToken, enrichmentGateway: auth.enrichmentGateway, }); return; } if (command === "members") { // Top-level convenience surface for the same Convex query that // `dench crm members list` calls. Lives here (rather than only // under `dench crm`) so it shows up in top-level help and tab // completion — members aren't strictly a CRM concept, even though // the canonical caller today is the AI agent looking up userIds // for `user`-typed CRM cells. const subArgs = stripRuntimeFlags(args.slice(args.indexOf("members") + 1)); const sub = subArgs[0]; if (!sub || sub === "help" || sub === "--help") { const { runMembersCommand } = await import("./members"); // biome-ignore lint/suspicious/noExplicitAny: harmless help-only stub await runMembersCommand({ convex: {} as any, args: subArgs }); return; } const { runMembersCommand } = await import("./members"); const runtime = await getRuntime(); // Member listing is org-scoped via `requireCrmAccess`, identical // auth shape to `dench crm`. Dev workspaces forward the encoded // dev token so local --dev runs work the same way. if (runtime.mode === "dev") { await runMembersCommand({ convex: runtime.client, args: subArgs, sessionToken: encodeDevCrmSessionToken(runtime.workspaceArgs), }); return; } const authedRuntime = requireAuthenticatedRuntime(runtime, "dench members"); await runMembersCommand({ convex: authedRuntime.client, args: subArgs, sessionToken: authedRuntime.sessionToken, }); return; } if (command === "notifications") { // The bell's inbox from the terminal. Same auth shape as `dench crm`: // agent sessions read their owner's inbox; org API keys are refused // server-side because there is no person behind them. const subArgs = stripRuntimeFlags( args.slice(args.indexOf("notifications") + 1), ); const sub = subArgs[0]; const { runNotificationsCommand } = await import("./notifications"); if (!sub || sub === "help" || sub === "--help") { // biome-ignore lint/suspicious/noExplicitAny: harmless help-only stub await runNotificationsCommand({ convex: {} as any, args: subArgs }); return; } const runtime = await getRuntime(); if (runtime.mode === "dev") { await runNotificationsCommand({ convex: runtime.client, args: subArgs, sessionToken: encodeDevCrmSessionToken(runtime.workspaceArgs), }); return; } const authedRuntime = requireAuthenticatedRuntime( runtime, "dench notifications", ); await runNotificationsCommand({ convex: authedRuntime.client, args: subArgs, sessionToken: authedRuntime.sessionToken, }); return; } if (command === "settings") { // Settings → Sync from the terminal. Same auth shape as `dench crm`; // the write is admin-only server-side. const subArgs = stripRuntimeFlags(args.slice(args.indexOf("settings") + 1)); const sub = subArgs[0]; const { runSettingsCommand } = await import("./settings"); if (!sub || sub === "help" || sub === "--help") { // biome-ignore lint/suspicious/noExplicitAny: harmless help-only stub await runSettingsCommand({ convex: {} as any, args: subArgs }); return; } const runtime = await getRuntime(); if (runtime.mode === "dev") { await runSettingsCommand({ convex: runtime.client, args: subArgs, sessionToken: encodeDevCrmSessionToken(runtime.workspaceArgs), }); return; } const authedRuntime = requireAuthenticatedRuntime( runtime, "dench settings", ); await runSettingsCommand({ convex: authedRuntime.client, args: subArgs, sessionToken: authedRuntime.sessionToken, }); return; } if (command === "fs") { await runFsCommand(); return; } if (command === "files") { await runFilesCommand(); return; } if (command === "stage") { await runStage(); return; } if (command === "web") { const subArgs = stripRuntimeFlags(args.slice(args.indexOf("web") + 1)); const filteredSubArgs = subArgs.filter((a) => a !== "--json"); const { runWebCommand, webHelp } = await import("./web"); const sub = filteredSubArgs[0]; if (!sub || sub === "help" || sub === "--help" || sub === "-h") { webHelp(); return; } const runtime = await getRuntime(); const gatewayAuth = await resolveGatewayCommandAuth(runtime, "dench web"); await runWebCommand({ args: filteredSubArgs, jsonOutput: json, bearerToken: gatewayAuth.bearerToken, gatewayBaseUrl: gatewayAuth.gatewayBaseUrl, }); return; } if (command === "search") { const subArgs = stripRuntimeFlags(args.slice(args.indexOf("search") + 1)); const filteredSubArgs = subArgs.filter((a) => a !== "--json"); const { runSearchCommand } = await import("./search"); const sub = filteredSubArgs[0]; if (!sub || sub === "help" || sub === "--help" || sub === "-h") { await runSearchCommand({ args: filteredSubArgs, jsonOutput: json }); return; } const runtime = await getRuntime(); const gatewayAuth = await resolveGatewayCommandAuth( runtime, "dench search", ); await runSearchCommand({ args: filteredSubArgs, jsonOutput: json, bearerToken: gatewayAuth.bearerToken, gatewayBaseUrl: gatewayAuth.gatewayBaseUrl, }); return; } if (command === "image") { const subArgs = stripRuntimeFlags(args.slice(args.indexOf("image") + 1)); const filteredSubArgs = subArgs.filter((a) => a !== "--json"); const { runImageCommand } = await import("./image"); const sub = filteredSubArgs[0]; if (!sub || sub === "help" || sub === "--help" || sub === "-h") { await runImageCommand({ args: filteredSubArgs, jsonOutput: json }); return; } const runtime = await getRuntime(); const gatewayAuth = await resolveGatewayCommandAuth(runtime, "dench image"); await runImageCommand({ args: filteredSubArgs, jsonOutput: json, bearerToken: gatewayAuth.bearerToken, gatewayBaseUrl: gatewayAuth.gatewayBaseUrl, }); return; } if (command === "email") { const subArgs = stripRuntimeFlags(args.slice(args.indexOf("email") + 1)); const { runEmailCommand } = await import("./email"); const runtime = await getRuntime(); const authedRuntime = requireAuthenticatedRuntime(runtime, "dench email"); let gatewayAuth: | Awaited> | undefined; try { if ( !["draft", "inbox", "send"].includes(subArgs[0]) && !subArgs.some( (arg) => arg === "--mailbox" || arg.startsWith("--mailbox="), ) ) { gatewayAuth = await resolveGatewayCommandAuth(runtime, "dench email"); } } catch { gatewayAuth = undefined; } await runEmailCommand({ convex: authedRuntime.client, args: subArgs.filter((arg) => arg !== "--json"), jsonOutput: json, sessionToken: authedRuntime.sessionToken, bearerToken: gatewayAuth?.bearerToken ?? authedRuntime.sessionToken, gatewayBaseUrl: gatewayAuth?.gatewayBaseUrl, gatewayHeaders: gatewayAuth?.gatewayHeaders, }); return; } // `dench integrations` — every app connected to this workspace, from BOTH // brokers: Composio (via the gateway) and Dench's own Unipile connections // for Gmail, Outlook, Calendar and LinkedIn (via Convex). Composio tool // search/run/connect stay under `dench tool`. if (command === "integrations") { const runtime = await getRuntime(); let gatewayAuthError: string | undefined; const gatewayAuth = await resolveGatewayCommandAuth( runtime, "dench integrations", ).catch((error) => { gatewayAuthError = gatewayAuthFailureReason(error); return null; }); await runToolCommand({ args: ["status"], jsonOutput: json, ...(gatewayAuth ?? {}), gatewayAuthError, ...unipileQueryAuth(runtime), }); return; } // `dench apps` — the App Store (create / install / publish / list …). if (command === "apps") { const { runAppsCommand } = await import("./apps"); const subArgs = stripRuntimeFlags(args.slice(args.indexOf("apps") + 1)); if (!subArgs[0] || subArgs[0] === "help" || subArgs[0] === "--help") { // biome-ignore lint/suspicious/noExplicitAny: help-only stub, no client needed await runAppsCommand({ convex: {} as any, args: subArgs }); return; } const runtime = await getRuntime(); if (runtime.mode === "dev") { await runAppsCommand({ convex: runtime.client, args: subArgs, sessionToken: encodeDevCrmSessionToken(runtime.workspaceArgs), }); return; } const authedRuntime = requireAuthenticatedRuntime(runtime, "dench apps"); await runAppsCommand({ convex: authedRuntime.client, args: subArgs, sessionToken: authedRuntime.sessionToken, }); return; } if (command === "tool") { const subArgs = stripRuntimeFlags(args.slice(args.indexOf("tool") + 1)); const filteredSubArgs = subArgs.filter((a) => a !== "--json"); const sub = filteredSubArgs[0]; if (!sub || sub === "help" || sub === "--help" || sub === "-h") { await runToolCommand({ args: filteredSubArgs, jsonOutput: json }); return; } if (sub === "run") { const { parseJson } = await import("./lib/cli-args"); const argsIndex = filteredSubArgs.indexOf("--args"); if (argsIndex !== -1) parseJson(filteredSubArgs[argsIndex + 1]); } const runtime = await getRuntime(); await runToolCommand({ args: filteredSubArgs, jsonOutput: json, resolveGatewayAuth: async () => { try { return await resolveGatewayCommandAuth(runtime, "dench tool"); } catch (error) { return { gatewayAuthError: gatewayAuthFailureReason(error) }; } }, ...unipileQueryAuth(runtime), ...(runtime.mode !== "dev" ? { appOrigin: runtime.host, workspaceSlug: runtime.organization?.slug, } : {}), }); return; } if (command === "browser") { const subArgs = stripRuntimeFlags(args.slice(args.indexOf("browser") + 1)); const { runBrowserCommand } = await import("./browser"); const runtime = await getRuntime(); const authedRuntime = requireAuthenticatedRuntime(runtime, "dench browser"); const filteredSubArgs = subArgs.filter((a) => a !== "--json"); const gatewayAuth = filteredSubArgs[0] === "session" ? await resolveGatewayCommandAuth(runtime, "dench browser session") : undefined; await runBrowserCommand({ args: subArgs, runtime: { host: authedRuntime.host, bearerToken: authedRuntime.sessionToken, gatewayBearerToken: gatewayAuth?.bearerToken, gatewayBaseUrl: gatewayAuth?.gatewayBaseUrl, gatewayHeaders: gatewayAuth?.gatewayHeaders, }, }); return; } // `dench ads` is canonical; `dench ad` and `dench adline` are aliases for // the same command (one code path in ./ads). if (command === "ads" || command === "ad" || command === "adline") { const subArgs = stripRuntimeFlags(args.slice(args.indexOf(command) + 1)); const { runAdsCommand } = await import("./ads"); // Earning is opt-in: resolve the agent session token best-effort so a // signed-in developer gets credited on flush, but never require login — // the ad must still render for everyone. getRuntime throws when there's // no session; swallow that and run anonymously. // // We deliberately attribute earnings ONLY for an interactive, stored // `session` token, NOT for `apiKey` mode or the sandbox agent-session // fallback. Those mean a sandbox / unattended automation context (cron, // CI, the chat-turn harness) — there's no human looking at the terminal, // so counting those impressions would be the exact spam vector we're // trying to close. Headless runs show the ad but earn nothing by design. let authToken: string | undefined; let convexUrl: string | undefined; let host: string | undefined; try { const runtime = await getRuntime(); if (runtime.mode === "session") { const sandboxAgentToken = process.env.DENCH_AGENT_SESSION_TOKEN?.trim(); const isSandboxAgentSession = sandboxAgentToken && runtime.sessionToken === sandboxAgentToken; if (!isSandboxAgentSession) { authToken = runtime.sessionToken; convexUrl = runtime.convexUrl; host = runtime.host; } } } catch { // not signed in — show the ad without attributing earnings } await runAdsCommand({ args: subArgs, authToken, convexUrl, host }); return; } if (command === "chat") { const subArgs = stripRuntimeFlags(args.slice(args.indexOf("chat") + 1)); const sub = subArgs[0]; if (!sub || sub === "help" || sub === "--help") { const { runChatCommand } = await import("./chat"); // biome-ignore lint/suspicious/noExplicitAny: harmless help-only stub await runChatCommand({ convex: {} as any, args: subArgs }); return; } // `chat new` / `chat send` / `chat follow` use HTTP routes // (/api/chat/cli for spawn, /api/chat/[runId]/stream and // /api/chat/stream for follow) so they need an authenticated host // + bearer token, not a Convex client. The remaining subcommands // (list / search / read / tree) are Convex queries and keep going // through `runChatCommand`. if (sub === "new" || sub === "send" || sub === "follow") { const { runChatSpawnCommand } = await import("./chat-spawn"); const runtime = await getRuntime(); const authedRuntime = requireAuthenticatedRuntime( runtime, `dench chat ${sub}`, ); // Drop the leading subcommand from subArgs — runChatSpawnCommand // expects the remaining tokens (prompt + flags). await runChatSpawnCommand({ runtime: { host: authedRuntime.host, bearerToken: authedRuntime.sessionToken, }, args: subArgs.slice(1), subcommand: sub, }); return; } const { runChatCommand } = await import("./chat"); const runtime = await getRuntime(); // Past-chat queries are org+user-scoped: the server resolves the // user from the session token, OR from `runId` when authenticating // with a Dench API key. Pass DENCH_RUN_ID through automatically so // `dench chat …` works inside a sandbox without extra flags. const authedRuntime = requireAuthenticatedRuntime(runtime, "dench chat"); await runChatCommand({ convex: authedRuntime.client, args: subArgs, sessionToken: authedRuntime.sessionToken, runId: process.env.DENCH_RUN_ID?.trim() || undefined, }); return; } if (command === "agent") { const subArgs = stripRuntimeFlags(args.slice(args.indexOf("agent") + 1)); const sub = subArgs[0]; if ( !sub || sub === "help" || subArgs.includes("--help") || subArgs.includes("-h") ) { console.log( "Use dench chat new, send, follow, or tree. agent new/send/follow remain compatibility aliases. In Harness A, use spawn_task/read_task for child tasks.", ); return; } if (sub !== "new" && sub !== "send" && sub !== "follow") throw new CliError( "Unsupported agent command. Use dench chat new/send/follow/tree or the harness subagent tools.", { code: "unsupported_capability", retryable: false }, ); const { runChatSpawnCommand } = await import("./chat-spawn"); const runtime = requireAuthenticatedRuntime( await getRuntime(), `dench chat ${sub}`, ); await runChatSpawnCommand({ runtime: { host: runtime.host, bearerToken: runtime.sessionToken }, args: subArgs.slice(1), subcommand: sub, }); return; } // `dench campaign` — outreach campaigns (email + LinkedIn steps). Convex // RPC only, member-attributed auth (an organization-only key is refused // server-side; normalizeCliError explains what to do). if (command === "campaign" || command === "campaigns") { const subArgs = stripRuntimeFlags(args.slice(args.indexOf(command) + 1)); const sub = subArgs[0]; if ( !sub || sub === "help" || subArgs.some((token) => token === "--help" || token === "-h") ) { const { runCampaignCommand } = await import("./campaign"); // biome-ignore lint/suspicious/noExplicitAny: help-only stub, never queries await runCampaignCommand({ convex: {} as any, args: subArgs }); return; } const { runCampaignCommand } = await import("./campaign"); const runtime = await getRuntime(); const authedRuntime = requireAuthenticatedRuntime( runtime, `dench ${command}`, ); await runCampaignCommand({ convex: authedRuntime.client, args: subArgs, jsonOutput: json, sessionToken: authedRuntime.sessionToken, host: authedRuntime.host, workspaceSlug: authedRuntime.organization?.slug ?? process.env.DENCH_ORG_SLUG?.trim() ?? undefined, }); return; } // `dench routine` / `dench routines` are UI-friendly aliases for // `dench cron`. Same dispatch, same auth, same handler — only the // top-level command name differs to match the workspace UI rename. if (command === "cron" || command === "routine" || command === "routines") { const subArgs = stripRuntimeFlags(args.slice(args.indexOf(command) + 1)); const sub = subArgs[0]; // Help is a no-auth path so users can read the surface without a // session — mirrors `dench crm help`. if (!sub || sub === "help" || sub === "--help") { const { runCronCommand } = await import("./cron"); // biome-ignore lint/suspicious/noExplicitAny: help-only stub, never queries await runCronCommand({ convex: {} as any, args: subArgs }); return; } const { runCronCommand } = await import("./cron"); const runtime = await getRuntime(); // Cron CRUD is org-scoped — accept either an agent session token // (`dch_agent_*` from `dench signin`) or a unified Dench API key // (`DENCH_API_KEY` baked into sandbox envVars). Server-side // `requireCronAccess` (convex/lib/cronAccess.ts) dispatches on the // bearer prefix and resolves the org for both. The Bearer token // must carry `crons:read` (queries) or `crons:write` (mutations) // scope, or the wildcard `*`. const authedRuntime = requireAuthenticatedRuntime( runtime, `dench ${command}`, ); await runCronCommand({ convex: authedRuntime.client, args: subArgs, sessionToken: authedRuntime.sessionToken, }); return; } // ── OpenClaw-parity self-update CLI surface ────────────────────────── // // Dispatch all `dench identity / organisation / user / tools / mem / // bootstrap / model` traffic through the shared // agent-config CLI module. Each command requires the same auth shape // as `dench cron` (Bearer DENCH_API_KEY or `dench signin` session // token) so server-side `requireCronAccess` accepts both. const AGENT_CONFIG_COMMANDS = new Set([ "identity", "organisation", "user", "tools", "mem", "bootstrap", "model", ]); if (AGENT_CONFIG_COMMANDS.has(command)) { const subArgs = stripRuntimeFlags(args.slice(args.indexOf(command) + 1)); if ( subArgs[0] === "help" || subArgs.includes("--help") || subArgs.includes("-h") ) { const { printAgentConfigCliHelp } = await import("./agent-config"); printAgentConfigCliHelp(); return; } const runtime = await getRuntime(); const authedRuntime = requireAuthenticatedRuntime( runtime, `dench ${command}`, ); const ctxBase = { convex: authedRuntime.client, args: subArgs, jsonOutput: json, sessionToken: authedRuntime.sessionToken, }; const mod = await import("./agent-config"); if (command === "identity") { await mod.runIdentityCommand(ctxBase); return; } if (command === "organisation") { await mod.runOrganisationCommand(ctxBase); return; } if (command === "user") { await mod.runUserCommand(ctxBase); return; } if (command === "tools") { await mod.runToolsCommand(ctxBase); return; } if (command === "mem") { await mod.runMemoryAggregateCommand(ctxBase); return; } if (command === "bootstrap") { await mod.runBootstrapCommand(ctxBase); return; } if (command === "model") { await mod.runModelCommand(ctxBase); return; } } if (command === "signin") { await signin(); return; } if (command === "sessions") { await listSessions(); return; } if (command === "use") { await selectSession(); return; } if (command === "logout") { await logout(); return; } if (command === "backend") { await runBackendCommand(); return; } const runtime = await getRuntime(); if (command === "billing") { if (subcommand === "status") { await billingStatus(runtime); return; } if (subcommand === "topup") { await billingTopup(runtime); return; } billingHelp(); return; } if (command === "upgrade") { await denchUpgrade(runtime); return; } if (command === "context" && runtimeArgs.includes("--sections")) { const raw = args[args.indexOf("--sections") + 1]; const sections = raw ?.split(",") .map((section) => section.trim().toLowerCase()) .filter(Boolean); const allowed = [ "identity", "organisation", "user", "memory", "rules", "tools", ]; if ( !sections?.length || sections.some((section) => !allowed.includes(section)) ) throw new Error( `Usage: dench context --sections ${allowed.join(",")} [--json]`, ); const context = await buildWorkspaceInstructionsContext(runtime, [ ...new Set(sections), ]); const documents = Array.isArray(context.documents) ? (context.documents as Array<{ name: string; content?: string }>) : []; print( json ? context : documents .filter((doc) => doc.content !== undefined) .map((doc) => `## ${doc.name}\n${doc.content || "(empty)"}`) .join("\n\n"), ); return; } if (command === "context" && runtimeArgs.includes("--instructions")) { const instructions = await buildWorkspaceInstructionsContext(runtime); print( json ? instructions : (stringField(instructions, "instructions") ?? ""), ); return; } if (command === "instructions") { const instructions = await buildWorkspaceInstructionsContext(runtime); print( json ? instructions : (stringField(instructions, "instructions") ?? ""), ); return; } if (command === "context") { const context = await buildContext(runtime); print(json ? context : formatContext(context)); return; } if (command === "memory") { const authedRuntime = requireAuthenticatedRuntime(runtime, "dench memory"); if (subcommand === "search") { const query = positionalsFrom(2).join(" ").trim(); if (!query) throw new Error('Usage: dench memory search "query"'); print( await authedRuntime.client.query( api.functions.agentWorkspace.agentSearchMemory, { sessionToken: authedRuntime.sessionToken, query, limit: parseNumberOption("--limit"), }, ), ); return; } if (subcommand === "save") { const key = positional(2); const text = positionalsFrom(3).join(" ").trim(); if (!key || !text) { throw new Error('Usage: dench memory save "text"'); } print( await authedRuntime.client.mutation( api.functions.agentWorkspace.agentSaveMemory, { sessionToken: authedRuntime.sessionToken, key, kind: (option("--kind") ?? "fact") as never, text, tags: parseCommaOption("--tags"), sensitivity: (option("--sensitivity") ?? "normal") as never, visibility: parseMemoryVisibilityOption() as never, }, ), ); return; } throw new Error(`Unknown memory command: ${filteredArgs.join(" ")}`); } if (command === "artifacts") { const runtime = await getRuntime(); const artifactArgs = stripRuntimeFlags( args.slice(args.indexOf("artifacts") + 1), ); if ( runtime.mode === "dev" || runtime.mode === "session" || runtime.mode === "apiKey" ) { const { runWorkspaceArtifactsCommand } = await import("./leads"); const handled = await runWorkspaceArtifactsCommand({ convex: runtime.client, args: artifactArgs, jsonOutput: json, sessionToken: runtime.mode === "dev" ? encodeDevCrmSessionToken(runtime.workspaceArgs) : runtime.sessionToken, }); if (handled) return; } print(await listArtifacts(runtime, parseNumberOption("--limit"))); return; } if (command === "lists") { const runtime = await getRuntime(); const listsArgs = stripRuntimeFlags(args.slice(args.indexOf("lists") + 1)); const { runListsCommand } = await import("./leads"); // Same auth surface as `dench crm`: agent session tokens and unified // API keys hit requireCrmAccess directly; dev mode rides the encoded // dev CRM token so local workspaces work without a signin. const sessionToken = runtime.mode === "dev" ? encodeDevCrmSessionToken(runtime.workspaceArgs) : requireAuthenticatedRuntime(runtime, "dench lists").sessionToken; const handled = await runListsCommand({ convex: runtime.client, args: listsArgs, jsonOutput: json, sessionToken, }); if (!handled) await listsCliHelp(); return; } if (command === "suggested-work") { print(await listSuggestedWork(runtime)); return; } if (command === "status") { if (runtime.mode === "session" || runtime.mode === "apiKey") { const status = (await runtime.client.query( api.functions.agentWorkspace.whatCanIDoHere, { sessionToken: runtime.sessionToken, }, )) as AgentStatus; if (scopedStatus) { print(summarizeAgentStatus(status, runtime.host)); return; } print(status); return; } const data = await devOverview(runtime); const output = scopedStatus ? summarizeDevMine(runtime.workspaceArgs.workspaceSlug, data) : json ? data : summarizeDevOverview(runtime.workspaceArgs.workspaceSlug, data); print(output); return; } if (command === "agents") { if (runtime.mode === "session" || runtime.mode === "apiKey") { const status = await runtime.client.query( api.functions.agentWorkspace.whatCanIDoHere, { sessionToken: runtime.sessionToken, }, ); print(status.agents); return; } const data = await devOverview(runtime); print(data.agents); return; } if (command === "approvals") { if (runtime.mode === "session" || runtime.mode === "apiKey") { const status = await runtime.client.query( api.functions.agentWorkspace.whatCanIDoHere, { sessionToken: runtime.sessionToken, }, ); print(status.approvals); return; } const data = await devOverview(runtime); print(data.approvals); return; } if (command === "project" && subcommand === "create") { if (runtime.mode !== "dev") { throw new Error( "project create is not available for agent sessions yet.", ); } const name = positional(2); if (!name) throw new Error("Missing project name"); const projectId = await runtime.client.mutation( api.functions.agentWorkspace.devCreateProject, { ...runtime.workspaceArgs, name, repositoryUrl: option("--repo"), rootPath: option("--root"), }, ); print({ ok: true, projectId }); return; } if (command === "approval" && subcommand === "request") { const title = positional(2); if (!title) throw new Error("Missing approval title"); if (runtime.mode === "session" || runtime.mode === "apiKey") { print( await runtime.client.mutation( api.functions.agentWorkspace.agentRequestApproval, { sessionToken: runtime.sessionToken, title, reason: option("--reason"), risk: (option("--risk") ?? "high") as "low" | "medium" | "high", }, ), ); return; } const approvalId = await runtime.client.mutation( api.functions.agentWorkspace.devRequestApproval, { ...runtime.workspaceArgs, title, reason: option("--reason") ?? "Agent requests human approval before a risky action.", risk: (option("--risk") ?? "high") as "low" | "medium" | "high", agentId: option("--agent") as never, }, ); print({ ok: true, approvalId }); return; } if ( command === "approval" && (subcommand === "approve" || subcommand === "reject") ) { const approvalId = positional(2); if (!approvalId) throw new Error("Missing approval id"); const authedRuntime = requireAuthenticatedRuntime( runtime, "dench approval approve/reject", ); const decision = subcommand === "approve" ? "approved" : "rejected"; const result = await authedRuntime.client.mutation( api.functions.agentWorkspace.agentDecideApproval, { sessionToken: authedRuntime.sessionToken, approvalId: approvalId as never, decision, evidence: option("--evidence"), }, ); throwIfCliError(result); print(result); return; } throw new Error(`Unknown command: ${filteredArgs.join(" ")}`); } main() .then(() => { const exitCode = typeof process.exitCode === "number" ? process.exitCode : Number(process.exitCode ?? 0); finishCliInvocation(cliInvocation, { exitCode, ...(exitCode === 0 ? {} : { errorType: "child_exit_nonzero" }), }); }) .catch((error) => { const normalized = normalizeCliError(error); const extra = normalized.payload; const payload: JsonRecord = { ok: false, ...extra, error: normalized.message, }; if (json) { console.error(JSON.stringify(payload, null, 2)); } else { console.error(`Error: ${payload.error}`); const nextActions = stringArrayField(payload, "nextActions"); if (nextActions.length > 0) { console.error("Next actions:"); for (const action of nextActions) { console.error(` - ${action}`); } } } finishCliInvocation(cliInvocation, { exitCode: 1, errorType: normalized.payload.code, retryable: normalized.payload.retryable, }); process.exit(1); });