@dench.com/cli 2.0.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/crm.ts CHANGED
@@ -26,7 +26,7 @@
26
26
  * crm docs list / create / link
27
27
  * crm actions list / run / runs
28
28
  */
29
- import { ConvexHttpClient } from "convex/browser";
29
+ import type { ConvexHttpClient } from "convex/browser";
30
30
  import { makeFunctionReference } from "convex/server";
31
31
  import {
32
32
  assertNoUnknownFlags as assertNoUnknownFlagsRaw,
@@ -40,18 +40,19 @@ import {
40
40
  import {
41
41
  autoDetectInputField,
42
42
  detectEnrichmentCategory,
43
- extractEnrichmentValue,
44
- findEnrichmentColumn,
45
- getRequiredFieldsForApolloPath,
46
43
  type EnrichmentCategory,
47
44
  type EnrichmentColumnDef,
45
+ extractEnrichmentValue,
48
46
  type FieldCandidate,
47
+ findEnrichmentColumn,
48
+ getRequiredFieldsForApolloPath,
49
49
  } from "./lib/crm-enrichment";
50
50
  import {
51
51
  aviatoEnrichCompany,
52
52
  aviatoEnrichPerson,
53
- enrichCompany,
54
53
  EnrichmentGatewayError,
54
+ type EnrichmentGatewayOptions,
55
+ enrichCompany,
55
56
  enrichPersonByIdentifier,
56
57
  } from "./lib/enrichment-gateway";
57
58
 
@@ -82,6 +83,7 @@ type CrmCliContext = {
82
83
  * organization context.
83
84
  */
84
85
  sessionToken?: string;
86
+ enrichmentGateway?: Pick<EnrichmentGatewayOptions, "apiKey" | "baseUrl">;
85
87
  };
86
88
 
87
89
  // Adapt the shared helpers to throw CrmCliError so existing call sites
@@ -310,12 +312,14 @@ function out(ctx: CrmCliContext, value: unknown): void {
310
312
  console.log(JSON.stringify(value, null, 2));
311
313
  }
312
314
 
313
- function getFieldEnrichmentFlag(args: string[]): {
314
- category: EnrichmentCategory;
315
- key: string;
316
- apolloPath: string;
317
- inputFieldName: string;
318
- } | undefined {
315
+ function getFieldEnrichmentFlag(args: string[]):
316
+ | {
317
+ category: EnrichmentCategory;
318
+ key: string;
319
+ apolloPath: string;
320
+ inputFieldName: string;
321
+ }
322
+ | undefined {
319
323
  const category = parseCategoryFlag(getFlag(args, "--enrichment-category"));
320
324
  const key = getFlag(args, "--enrichment-key");
321
325
  const apolloPath = getFlag(args, "--apollo-path");
@@ -431,6 +435,7 @@ export async function runCrmCommand(opts: {
431
435
  convex: ConvexHttpClient;
432
436
  args: string[];
433
437
  sessionToken?: string;
438
+ enrichmentGateway?: Pick<EnrichmentGatewayOptions, "apiKey" | "baseUrl">;
434
439
  }): Promise<void> {
435
440
  const args = [...opts.args];
436
441
  const jsonOutput = hasFlag(args, "--json");
@@ -439,6 +444,7 @@ export async function runCrmCommand(opts: {
439
444
  args,
440
445
  jsonOutput,
441
446
  sessionToken: opts.sessionToken,
447
+ enrichmentGateway: opts.enrichmentGateway,
442
448
  };
443
449
  const subcommand = args.shift();
444
450
  if (!subcommand || subcommand === "help" || subcommand === "--help") {
@@ -1531,13 +1537,18 @@ type ObjectEnrichmentArgs = Omit<CellEnrichmentArgs, "entryId"> & {
1531
1537
  limit?: number;
1532
1538
  };
1533
1539
 
1534
- function parseCategoryFlag(value: string | undefined): EnrichmentCategory | undefined {
1540
+ function parseCategoryFlag(
1541
+ value: string | undefined,
1542
+ ): EnrichmentCategory | undefined {
1535
1543
  if (value === undefined) return undefined;
1536
1544
  if (value === "people" || value === "company") return value;
1537
1545
  throw new CrmCliError("--category must be people or company");
1538
1546
  }
1539
1547
 
1540
- function parsePositiveInt(flag: string, value: string | undefined): number | undefined {
1548
+ function parsePositiveInt(
1549
+ flag: string,
1550
+ value: string | undefined,
1551
+ ): number | undefined {
1541
1552
  if (value === undefined) return undefined;
1542
1553
  const parsed = Number(value);
1543
1554
  if (!Number.isInteger(parsed) || parsed < 1) {
@@ -1556,7 +1567,9 @@ function asFields(value: unknown): CrmFieldForEnrichment[] {
1556
1567
  .filter((field): field is CrmFieldForEnrichment => {
1557
1568
  if (!field || typeof field !== "object") return false;
1558
1569
  const candidate = field as Record<string, unknown>;
1559
- return typeof candidate.name === "string" && typeof candidate.type === "string";
1570
+ return (
1571
+ typeof candidate.name === "string" && typeof candidate.type === "string"
1572
+ );
1560
1573
  })
1561
1574
  .map((field) => ({
1562
1575
  id: typeof field.id === "string" ? field.id : undefined,
@@ -1601,7 +1614,9 @@ function asEntry(value: unknown): CrmEntryForEnrichment | null {
1601
1614
 
1602
1615
  function asEntries(value: unknown): CrmEntryForEnrichment[] {
1603
1616
  if (!Array.isArray(value)) return [];
1604
- return value.map(asEntry).filter((entry): entry is CrmEntryForEnrichment => !!entry);
1617
+ return value
1618
+ .map(asEntry)
1619
+ .filter((entry): entry is CrmEntryForEnrichment => !!entry);
1605
1620
  }
1606
1621
 
1607
1622
  function resolveEnrichmentColumn(args: {
@@ -1668,7 +1683,8 @@ async function resolveEnrichmentTarget(
1668
1683
  apolloPath: args.apolloPath ?? outputField.enrichment?.apolloPath,
1669
1684
  });
1670
1685
 
1671
- const inputFieldName = args.inputFieldName ?? outputField.enrichment?.inputFieldName;
1686
+ const inputFieldName =
1687
+ args.inputFieldName ?? outputField.enrichment?.inputFieldName;
1672
1688
  const inputField = inputFieldName
1673
1689
  ? fields.find((field) => field.name === inputFieldName)
1674
1690
  : autoDetectInputField(category, fields);
@@ -1693,6 +1709,7 @@ async function callEnrichmentGatewayForEntry(args: {
1693
1709
  entry: CrmEntryForEnrichment;
1694
1710
  /** Explicit provider override. Defaults: people=fullenrich, company=aviato. */
1695
1711
  provider?: string;
1712
+ gateway?: Pick<EnrichmentGatewayOptions, "apiKey" | "baseUrl">;
1696
1713
  }): Promise<Record<string, unknown>> {
1697
1714
  const input = args.entry.fields[args.target.inputField.name];
1698
1715
  if (!hasCellValue(input)) {
@@ -1702,7 +1719,8 @@ async function callEnrichmentGatewayForEntry(args: {
1702
1719
  }
1703
1720
  const inputValue = String(input);
1704
1721
  const isCompany = args.target.category === "company";
1705
- const resolvedProvider = args.provider ?? (isCompany ? "aviato" : "fullenrich");
1722
+ const resolvedProvider =
1723
+ args.provider ?? (isCompany ? "aviato" : "fullenrich");
1706
1724
 
1707
1725
  if (resolvedProvider === "aviato") {
1708
1726
  // Guard: Aviato does not return phone or email.
@@ -1718,25 +1736,37 @@ async function callEnrichmentGatewayForEntry(args: {
1718
1736
  const isLinkedin = /linkedin\.com/i.test(inputValue);
1719
1737
  return aviatoEnrichCompany(
1720
1738
  isLinkedin ? { linkedinUrl: inputValue } : { website: inputValue },
1739
+ args.gateway,
1721
1740
  );
1722
1741
  } else {
1723
1742
  // For people, inputValue may be a LinkedIn URL, email, or LinkedIn ID
1724
1743
  const isLinkedin = /linkedin\.com/i.test(inputValue);
1725
1744
  const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(inputValue);
1726
- if (isLinkedin) return aviatoEnrichPerson({ linkedinUrl: inputValue });
1727
- if (isEmail) return aviatoEnrichPerson({ email: inputValue });
1728
- return aviatoEnrichPerson({ linkedinID: inputValue });
1745
+ if (isLinkedin) {
1746
+ return aviatoEnrichPerson({ linkedinUrl: inputValue }, args.gateway);
1747
+ }
1748
+ if (isEmail) {
1749
+ return aviatoEnrichPerson({ email: inputValue }, args.gateway);
1750
+ }
1751
+ return aviatoEnrichPerson({ linkedinID: inputValue }, args.gateway);
1729
1752
  }
1730
1753
  }
1731
1754
 
1732
1755
  // fullenrich path (default for people, explicit override for companies)
1733
1756
  if (isCompany) {
1734
- return enrichCompany({
1735
- domain: inputValue,
1736
- requiredFields: args.target.requiredFields,
1737
- });
1757
+ return enrichCompany(
1758
+ {
1759
+ domain: inputValue,
1760
+ requiredFields: args.target.requiredFields,
1761
+ },
1762
+ args.gateway,
1763
+ );
1738
1764
  }
1739
- return enrichPersonByIdentifier(inputValue, args.target.requiredFields);
1765
+ return enrichPersonByIdentifier(
1766
+ inputValue,
1767
+ args.target.requiredFields,
1768
+ args.gateway,
1769
+ );
1740
1770
  }
1741
1771
 
1742
1772
  async function enrichOneEntry(
@@ -1751,6 +1781,7 @@ async function enrichOneEntry(
1751
1781
  target: args.target,
1752
1782
  entry: args.entry,
1753
1783
  provider: args.provider,
1784
+ gateway: ctx.enrichmentGateway,
1754
1785
  });
1755
1786
  const value = extractEnrichmentValue(payload, args.target.column);
1756
1787
  if (value === null) {
@@ -1779,7 +1810,10 @@ async function enrichOneEntry(
1779
1810
  ...writeResult,
1780
1811
  };
1781
1812
  } catch (error) {
1782
- if (error instanceof EnrichmentGatewayError || error instanceof CrmCliError) {
1813
+ if (
1814
+ error instanceof EnrichmentGatewayError ||
1815
+ error instanceof CrmCliError
1816
+ ) {
1783
1817
  return {
1784
1818
  entryId: args.entry.id,
1785
1819
  ok: false,
package/dench.ts CHANGED
@@ -966,11 +966,12 @@ function backendSnapshot(config: ConfigFile) {
966
966
  : null;
967
967
  const envHost = process.env.DENCH_HOST?.trim() || null;
968
968
  const argBackend =
969
- option("--backend") ?? (hasFlag("--prod")
969
+ option("--backend") ??
970
+ (hasFlag("--prod")
970
971
  ? "production"
971
972
  : hasFlag("--staging")
972
973
  ? "staging"
973
- : option("--host") ?? null);
974
+ : (option("--host") ?? null));
974
975
 
975
976
  let source: "flag" | "env" | "config" | "default";
976
977
  if (argBackend) source = "flag";
@@ -1078,7 +1079,9 @@ async function backendSet() {
1078
1079
  "Local backend selected. dench will now also load .env / .env.local from the workspace so GATEWAY_URL etc. are picked up.",
1079
1080
  );
1080
1081
  }
1081
- print("Future `dench` commands without --prod/--staging/--host will target this backend.");
1082
+ print(
1083
+ "Future `dench` commands without --prod/--staging/--host will target this backend.",
1084
+ );
1082
1085
  }
1083
1086
 
1084
1087
  async function backendClear() {
@@ -1706,8 +1709,7 @@ async function signinViaBrowser(input: {
1706
1709
  agentName: input.agentName,
1707
1710
  agentKind: input.agentKind,
1708
1711
  intent: input.intent,
1709
- proposedOrganizationName:
1710
- input.proposedOrganizationName ?? undefined,
1712
+ proposedOrganizationName: input.proposedOrganizationName ?? undefined,
1711
1713
  requestedOrganizationSelector: input.orgSelector ?? undefined,
1712
1714
  userAgent: "@dench.com/cli",
1713
1715
  },
@@ -1841,7 +1843,8 @@ async function signinViaOtp(input: {
1841
1843
  // resolvable (single admin org or create_workspace intent) and the
1842
1844
  // server hands us a session, OR we get an auth token + list and
1843
1845
  // need a third call.
1844
- const code = option("--otp-code")?.trim() || (await promptLine("Paste OTP: "));
1846
+ const code =
1847
+ option("--otp-code")?.trim() || (await promptLine("Paste OTP: "));
1845
1848
  if (!/^\d{6}$/.test(code)) {
1846
1849
  throw new CliError("OTP code must be exactly 6 digits.", {
1847
1850
  code: "invalid_otp_code",
@@ -1857,8 +1860,7 @@ async function signinViaOtp(input: {
1857
1860
  agentKind: input.agentKind,
1858
1861
  sessionTokenHash: input.sessionTokenHash,
1859
1862
  intent: input.intent,
1860
- proposedOrganizationName:
1861
- input.proposedOrganizationName ?? undefined,
1863
+ proposedOrganizationName: input.proposedOrganizationName ?? undefined,
1862
1864
  organizationSelector: input.orgSelector ?? undefined,
1863
1865
  userAgent: "@dench.com/cli",
1864
1866
  }),
@@ -1878,8 +1880,15 @@ async function signinViaOtp(input: {
1878
1880
 
1879
1881
  // Branch A: server minted the session in one shot.
1880
1882
  if (result.kind === "completed") {
1881
- assertOtpCompletedOrgMatchesSelector(result.organization, input.orgSelector);
1882
- await finalizeOtpSession(input, result.organization, result.sessionExpiresAt);
1883
+ assertOtpCompletedOrgMatchesSelector(
1884
+ result.organization,
1885
+ input.orgSelector,
1886
+ );
1887
+ await finalizeOtpSession(
1888
+ input,
1889
+ result.organization,
1890
+ result.sessionExpiresAt,
1891
+ );
1883
1892
  return;
1884
1893
  }
1885
1894
 
@@ -2020,7 +2029,11 @@ async function resolveOrgChoiceForOtp(input: {
2020
2029
 
2021
2030
  const answer = await promptLine("Pick a number: ");
2022
2031
  const choice = Number(answer);
2023
- if (!Number.isFinite(choice) || choice < 1 || choice > input.organizations.length + 1) {
2032
+ if (
2033
+ !Number.isFinite(choice) ||
2034
+ choice < 1 ||
2035
+ choice > input.organizations.length + 1
2036
+ ) {
2024
2037
  throw new CliError(`Invalid choice: ${answer}`, {
2025
2038
  code: "invalid_org_choice",
2026
2039
  });
@@ -2234,9 +2247,7 @@ async function denchUpgrade(runtime: Runtime) {
2234
2247
  if (updatedInPlace) {
2235
2248
  const message = `Upgraded to Dench ${tierFlag === "pro" ? "Pro" : "Max"} in place — no Stripe Checkout needed.`;
2236
2249
  print(
2237
- json
2238
- ? { ok: true, updated: true, tier: tierFlag, message }
2239
- : message,
2250
+ json ? { ok: true, updated: true, tier: tierFlag, message } : message,
2240
2251
  );
2241
2252
  return;
2242
2253
  }
@@ -2487,6 +2498,103 @@ function requireAuthenticatedRuntime(
2487
2498
  throw loginRequiredError(command);
2488
2499
  }
2489
2500
 
2501
+ type GatewayCommandAuth = {
2502
+ bearerToken: string;
2503
+ gatewayBaseUrl?: string;
2504
+ };
2505
+
2506
+ function gatewayBaseUrlForCli(value: unknown): string | undefined {
2507
+ if (typeof value !== "string") return undefined;
2508
+ const trimmed = value.trim().replace(/\/+$/, "");
2509
+ if (!trimmed) return undefined;
2510
+ // The desktop endpoint returns a `/v1` base; CLI gateway modules append
2511
+ // `/v1/...` paths themselves, so normalize to the root origin here.
2512
+ return trimmed.replace(/\/v1$/, "");
2513
+ }
2514
+
2515
+ async function parseJsonResponseBody(response: Response): Promise<JsonRecord> {
2516
+ const text = await response.text().catch(() => "");
2517
+ if (!text.trim()) return {};
2518
+ try {
2519
+ const parsed = JSON.parse(text);
2520
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
2521
+ ? (parsed as JsonRecord)
2522
+ : {};
2523
+ } catch {
2524
+ return { error: text };
2525
+ }
2526
+ }
2527
+
2528
+ function gatewayAuthError(
2529
+ command: string,
2530
+ response: Response,
2531
+ body: JsonRecord,
2532
+ ) {
2533
+ const code =
2534
+ typeof body.error === "string" && body.error.trim()
2535
+ ? body.error.trim()
2536
+ : `gateway_key_${response.status}`;
2537
+ if (response.status === 403 && code === "no_active_subscription") {
2538
+ return new CliError(`${command} requires a paid Dench workspace`, {
2539
+ code: "paid_workspace_required",
2540
+ nextActions: [
2541
+ "Run dench upgrade --tier pro or dench upgrade --tier max, then retry.",
2542
+ ],
2543
+ });
2544
+ }
2545
+ if (response.status === 401) {
2546
+ return loginRequiredError(command);
2547
+ }
2548
+ return new CliError(`${command} could not get a gateway key (${code})`, {
2549
+ code,
2550
+ status: response.status,
2551
+ nextActions: [
2552
+ "Confirm this agent is signed in to the intended workspace.",
2553
+ "If the workspace is paid, retry after a minute in case gateway key provisioning is still finishing.",
2554
+ ],
2555
+ });
2556
+ }
2557
+
2558
+ async function resolveGatewayCommandAuth(
2559
+ runtime: Runtime,
2560
+ command: string,
2561
+ ): Promise<GatewayCommandAuth> {
2562
+ if (runtime.mode === "apiKey") {
2563
+ return { bearerToken: runtime.sessionToken };
2564
+ }
2565
+
2566
+ if (runtime.mode === "session") {
2567
+ const response = await fetch(
2568
+ `${runtime.host.replace(/\/+$/, "")}/api/desktop/organizations/gateway-key`,
2569
+ {
2570
+ headers: {
2571
+ accept: "application/json",
2572
+ authorization: `Bearer ${runtime.sessionToken}`,
2573
+ },
2574
+ },
2575
+ );
2576
+ const body = await parseJsonResponseBody(response);
2577
+ if (!response.ok) {
2578
+ throw gatewayAuthError(command, response, body);
2579
+ }
2580
+ const gatewayKey =
2581
+ typeof body.gatewayKey === "string" ? body.gatewayKey.trim() : "";
2582
+ if (!gatewayKey) {
2583
+ throw new CliError(`${command} could not get a gateway key`, {
2584
+ code: "missing_gateway_key",
2585
+ });
2586
+ }
2587
+ return {
2588
+ bearerToken: gatewayKey,
2589
+ gatewayBaseUrl: gatewayBaseUrlForCli(body.gatewayBaseUrl),
2590
+ };
2591
+ }
2592
+
2593
+ const envApiKey = process.env.DENCH_API_KEY?.trim();
2594
+ if (envApiKey) return { bearerToken: envApiKey };
2595
+ throw loginRequiredError(command);
2596
+ }
2597
+
2490
2598
  function encodeDevCrmSessionToken(args: {
2491
2599
  workspaceSlug: string;
2492
2600
  devKey: string;
@@ -2609,12 +2717,24 @@ function nextContextCommands() {
2609
2717
  ];
2610
2718
  }
2611
2719
 
2612
- async function connectedAppsContext(_runtime: Runtime) {
2613
- // Reach the Dench gateway directly with DENCH_API_KEY (mirrors the
2614
- // denchclaw pattern). This works for every Runtime mode — session,
2615
- // apiKey, dev — because the gateway only cares about the bearer key,
2616
- // not which Convex session is calling.
2617
- return fetchConnectedAppsSummary();
2720
+ async function connectedAppsContext(runtime: Runtime) {
2721
+ try {
2722
+ const gatewayAuth = await resolveGatewayCommandAuth(
2723
+ runtime,
2724
+ "dench context",
2725
+ );
2726
+ return fetchConnectedAppsSummary({
2727
+ bearerToken: gatewayAuth.bearerToken,
2728
+ gatewayBaseUrl: gatewayAuth.gatewayBaseUrl,
2729
+ });
2730
+ } catch (error) {
2731
+ return {
2732
+ available: false,
2733
+ reason: errorMessage(error),
2734
+ count: 0,
2735
+ connections: [],
2736
+ };
2737
+ }
2618
2738
  }
2619
2739
 
2620
2740
  async function buildContext(runtime: Runtime) {
@@ -3024,26 +3144,12 @@ function normalizeFilesPath(input: string): string {
3024
3144
  return cleaned;
3025
3145
  }
3026
3146
 
3027
- function filesApiKey(runtime: Runtime): string | undefined {
3028
- if (runtime.mode === "apiKey") return runtime.sessionToken;
3029
- return process.env.DENCH_API_KEY?.trim() || undefined;
3030
- }
3031
-
3032
- function requireFilesApiKey(runtime: Runtime, command: string): string {
3033
- const apiKey = filesApiKey(runtime);
3034
- if (!apiKey) {
3035
- throw new CliError(
3036
- `${command} needs DENCH_API_KEY (or a sandbox-baked api-key session). It bypasses the user-session auth path.`,
3037
- {
3038
- code: "files_api_key_required",
3039
- nextActions: [
3040
- "Run inside a Dench sandbox where DENCH_API_KEY is baked in.",
3041
- "Or export DENCH_API_KEY=<key> first (see dench signin output).",
3042
- ],
3043
- },
3044
- );
3045
- }
3046
- return apiKey;
3147
+ async function requireFilesApiKey(
3148
+ runtime: Runtime,
3149
+ command: string,
3150
+ ): Promise<string> {
3151
+ const auth = await resolveGatewayCommandAuth(runtime, command);
3152
+ return auth.bearerToken;
3047
3153
  }
3048
3154
 
3049
3155
  type FileTreeRow = {
@@ -3143,7 +3249,7 @@ async function uploadConvexFileBytes(
3143
3249
 
3144
3250
  async function runFilesLs() {
3145
3251
  const runtime = await getRuntime();
3146
- const apiKey = requireFilesApiKey(runtime, "dench files ls");
3252
+ const apiKey = await requireFilesApiKey(runtime, "dench files ls");
3147
3253
  const target = normalizeFilesPath(positional(2) ?? "/");
3148
3254
  const recursive = hasFlag("--recursive") || hasFlag("-r");
3149
3255
  const rows = recursive
@@ -3180,7 +3286,7 @@ async function runFilesLs() {
3180
3286
 
3181
3287
  async function runFilesMv() {
3182
3288
  const runtime = await getRuntime();
3183
- const apiKey = requireFilesApiKey(runtime, "dench files mv");
3289
+ const apiKey = await requireFilesApiKey(runtime, "dench files mv");
3184
3290
  const fromRaw = positional(2);
3185
3291
  const toRaw = positional(3);
3186
3292
  if (!fromRaw || !toRaw) {
@@ -3252,7 +3358,7 @@ async function runFilesMv() {
3252
3358
 
3253
3359
  async function runFilesRm() {
3254
3360
  const runtime = await getRuntime();
3255
- const apiKey = requireFilesApiKey(runtime, "dench files rm");
3361
+ const apiKey = await requireFilesApiKey(runtime, "dench files rm");
3256
3362
  const targetRaw = positional(2);
3257
3363
  if (!targetRaw) {
3258
3364
  throw new CliError("Usage: dench files rm <path> [--recursive]", {
@@ -3439,7 +3545,7 @@ async function* walkLocalFiles(
3439
3545
 
3440
3546
  async function runStage() {
3441
3547
  const runtime = await getRuntime();
3442
- const apiKey = requireFilesApiKey(runtime, "dench stage");
3548
+ const apiKey = await requireFilesApiKey(runtime, "dench stage");
3443
3549
  const localRaw = positional(1);
3444
3550
  const destRaw = positional(2);
3445
3551
  if (!localRaw) {
@@ -3694,15 +3800,13 @@ async function main() {
3694
3800
  // clean redirect to the new unified surface so old `npx` caches and
3695
3801
  // pre-existing agent prompts get a one-line nudge instead of an
3696
3802
  // opaque "Unknown command" error.
3697
- if (
3698
- isDeprecatedAuthCommand
3699
- ) {
3803
+ if (isDeprecatedAuthCommand) {
3700
3804
  throw new CliError(
3701
3805
  `\`dench ${command}\` was removed in @dench.com/cli v2. Run \`dench signin\` instead.`,
3702
3806
  {
3703
3807
  code: "deprecated_command",
3704
3808
  nextActions: [
3705
- 'Run `dench signin` to connect this agent to an existing workspace.',
3809
+ "Run `dench signin` to connect this agent to an existing workspace.",
3706
3810
  'Run `dench signin --new-workspace --org-name "<name>"` to create a free CRM-only workspace.',
3707
3811
  "Upgrade the CLI globally with `npm install -g @dench.com/cli@latest`.",
3708
3812
  ],
@@ -3746,10 +3850,7 @@ async function main() {
3746
3850
  return;
3747
3851
  }
3748
3852
 
3749
- if (
3750
- command === "backend" &&
3751
- (subcommand === "help" || hasFlag("--help"))
3752
- ) {
3853
+ if (command === "backend" && (subcommand === "help" || hasFlag("--help"))) {
3753
3854
  backendHelp();
3754
3855
  return;
3755
3856
  }
@@ -3776,18 +3877,38 @@ async function main() {
3776
3877
  // `requireCrmAccess` (convex/lib/crm/access.ts) dispatches on the
3777
3878
  // bearer prefix and resolves the org for both.
3778
3879
  if (runtime.mode === "dev") {
3880
+ const gatewayAuth =
3881
+ subArgs[0] === "enrich"
3882
+ ? await resolveGatewayCommandAuth(runtime, "dench crm enrich")
3883
+ : undefined;
3779
3884
  await runCrmCommand({
3780
3885
  convex: runtime.client,
3781
3886
  args: subArgs,
3782
3887
  sessionToken: encodeDevCrmSessionToken(runtime.workspaceArgs),
3888
+ enrichmentGateway: gatewayAuth
3889
+ ? {
3890
+ apiKey: gatewayAuth.bearerToken,
3891
+ baseUrl: gatewayAuth.gatewayBaseUrl,
3892
+ }
3893
+ : undefined,
3783
3894
  });
3784
3895
  return;
3785
3896
  }
3786
3897
  const authedRuntime = requireAuthenticatedRuntime(runtime, "dench crm");
3898
+ const gatewayAuth =
3899
+ subArgs[0] === "enrich"
3900
+ ? await resolveGatewayCommandAuth(authedRuntime, "dench crm enrich")
3901
+ : undefined;
3787
3902
  await runCrmCommand({
3788
3903
  convex: authedRuntime.client,
3789
3904
  args: subArgs,
3790
3905
  sessionToken: authedRuntime.sessionToken,
3906
+ enrichmentGateway: gatewayAuth
3907
+ ? {
3908
+ apiKey: gatewayAuth.bearerToken,
3909
+ baseUrl: gatewayAuth.gatewayBaseUrl,
3910
+ }
3911
+ : undefined,
3791
3912
  });
3792
3913
  return;
3793
3914
  }
@@ -3820,10 +3941,7 @@ async function main() {
3820
3941
  });
3821
3942
  return;
3822
3943
  }
3823
- const authedRuntime = requireAuthenticatedRuntime(
3824
- runtime,
3825
- "dench members",
3826
- );
3944
+ const authedRuntime = requireAuthenticatedRuntime(runtime, "dench members");
3827
3945
  await runMembersCommand({
3828
3946
  convex: authedRuntime.client,
3829
3947
  args: subArgs,
@@ -3848,44 +3966,76 @@ async function main() {
3848
3966
  }
3849
3967
 
3850
3968
  if (command === "search") {
3851
- // `dench search` talks straight to the gateway over HTTP using
3852
- // DENCH_API_KEY; it deliberately bypasses requireSessionRuntime so
3853
- // it works in any sandbox / CI environment that has the key.
3854
3969
  const subArgs = args.slice(args.indexOf("search") + 1);
3855
3970
  const filteredSubArgs = subArgs.filter((a) => a !== "--json");
3856
3971
  const { runSearchCommand } = await import("./search");
3857
- await runSearchCommand({ args: filteredSubArgs, jsonOutput: json });
3972
+ const sub = filteredSubArgs[0];
3973
+ if (!sub || sub === "help" || sub === "--help" || sub === "-h") {
3974
+ await runSearchCommand({ args: filteredSubArgs, jsonOutput: json });
3975
+ return;
3976
+ }
3977
+ const runtime = await getRuntime();
3978
+ const gatewayAuth = await resolveGatewayCommandAuth(
3979
+ runtime,
3980
+ "dench search",
3981
+ );
3982
+ await runSearchCommand({
3983
+ args: filteredSubArgs,
3984
+ jsonOutput: json,
3985
+ bearerToken: gatewayAuth.bearerToken,
3986
+ gatewayBaseUrl: gatewayAuth.gatewayBaseUrl,
3987
+ });
3858
3988
  return;
3859
3989
  }
3860
3990
 
3861
3991
  if (command === "image") {
3862
- // `dench image generate|edit` mirrors `dench search`: straight HTTP
3863
- // to the gateway with DENCH_API_KEY, no Convex session required.
3864
- // The gateway writes the bytes to Convex Storage on success; we
3865
- // also drop them on local disk so the next bash command sees them.
3866
3992
  const subArgs = args.slice(args.indexOf("image") + 1);
3867
3993
  const filteredSubArgs = subArgs.filter((a) => a !== "--json");
3868
3994
  const { runImageCommand } = await import("./image");
3869
- await runImageCommand({ args: filteredSubArgs, jsonOutput: json });
3995
+ const sub = filteredSubArgs[0];
3996
+ if (!sub || sub === "help" || sub === "--help" || sub === "-h") {
3997
+ await runImageCommand({ args: filteredSubArgs, jsonOutput: json });
3998
+ return;
3999
+ }
4000
+ const runtime = await getRuntime();
4001
+ const gatewayAuth = await resolveGatewayCommandAuth(runtime, "dench image");
4002
+ await runImageCommand({
4003
+ args: filteredSubArgs,
4004
+ jsonOutput: json,
4005
+ bearerToken: gatewayAuth.bearerToken,
4006
+ gatewayBaseUrl: gatewayAuth.gatewayBaseUrl,
4007
+ });
3870
4008
  return;
3871
4009
  }
3872
4010
 
3873
4011
  if (command === "apps") {
3874
- // `dench apps` is an alias for `dench tool status`. Both speak
3875
- // straight HTTP to the Dench Cloud Gateway with DENCH_API_KEY —
3876
- // mirrors the denchclaw pattern. No Convex session involved.
3877
- await runToolCommand({ args: ["status"], jsonOutput: json });
4012
+ const runtime = await getRuntime();
4013
+ const gatewayAuth = await resolveGatewayCommandAuth(runtime, "dench apps");
4014
+ await runToolCommand({
4015
+ args: ["status"],
4016
+ jsonOutput: json,
4017
+ bearerToken: gatewayAuth.bearerToken,
4018
+ gatewayBaseUrl: gatewayAuth.gatewayBaseUrl,
4019
+ });
3878
4020
  return;
3879
4021
  }
3880
4022
 
3881
4023
  if (command === "tool") {
3882
- // `dench tool {status,connect,search,run,disconnect}` is the model-facing
3883
- // composio surface and only needs DENCH_API_KEY (Bearer) on the
3884
- // gateway. Bypasses requireSessionRuntime entirely so it works in
3885
- // every sandbox / API-key context.
3886
4024
  const subArgs = stripRuntimeFlags(args.slice(args.indexOf("tool") + 1));
3887
4025
  const filteredSubArgs = subArgs.filter((a) => a !== "--json");
3888
- await runToolCommand({ args: filteredSubArgs, jsonOutput: json });
4026
+ const sub = filteredSubArgs[0];
4027
+ if (!sub || sub === "help" || sub === "--help" || sub === "-h") {
4028
+ await runToolCommand({ args: filteredSubArgs, jsonOutput: json });
4029
+ return;
4030
+ }
4031
+ const runtime = await getRuntime();
4032
+ const gatewayAuth = await resolveGatewayCommandAuth(runtime, "dench tool");
4033
+ await runToolCommand({
4034
+ args: filteredSubArgs,
4035
+ jsonOutput: json,
4036
+ bearerToken: gatewayAuth.bearerToken,
4037
+ gatewayBaseUrl: gatewayAuth.gatewayBaseUrl,
4038
+ });
3889
4039
  return;
3890
4040
  }
3891
4041
 
package/image.ts CHANGED
@@ -3,9 +3,9 @@
3
3
  * the Dench Cloud Gateway.
4
4
  *
5
5
  * Mirrors `dench search`: bypasses Convex entirely and talks straight HTTP
6
- * to the gateway, authenticating with the `DENCH_API_KEY` baked into every
7
- * sandbox at create time. Works anywhere that env var is exported (sandbox,
8
- * CI, local dev).
6
+ * to the gateway, authenticating with either `DENCH_API_KEY` or a
7
+ * paid-workspace gateway key resolved from the active agent session by the
8
+ * top-level dispatcher.
9
9
  *
10
10
  * Persistence model: the gateway already writes the bytes to Convex Storage
11
11
  * + the org's fileTree on success (so the workspace UI sees the file
@@ -27,38 +27,33 @@
27
27
 
28
28
  import { mkdir, readFile, writeFile } from "node:fs/promises";
29
29
  import { dirname, isAbsolute, resolve } from "node:path";
30
- import {
31
- CliArgError,
32
- getFlag,
33
- hasFlag,
34
- shift as shiftRaw,
35
- } from "./lib/cli-args";
30
+ import { getFlag, hasFlag } from "./lib/cli-args";
36
31
 
37
32
  type ImageCliContext = {
38
33
  args: string[];
39
34
  jsonOutput: boolean;
35
+ bearerToken?: string;
36
+ gatewayBaseUrl?: string;
40
37
  };
41
38
 
42
39
  class ImageCliError extends Error {}
43
40
 
44
- function shift(args: string[], expected: string): string {
45
- try {
46
- return shiftRaw(args, expected);
47
- } catch (error) {
48
- if (error instanceof CliArgError) {
49
- throw new ImageCliError(error.message);
50
- }
51
- throw error;
52
- }
53
- }
54
-
55
41
  const ALLOWED_QUALITIES = new Set(["auto", "low", "medium", "high"]);
56
42
  const ALLOWED_FORMATS = new Set(["png", "jpeg", "webp"]);
57
43
 
58
- function resolveGatewayBaseUrl(): string {
44
+ function normalizeGatewayBaseUrl(value: string): string {
45
+ return value.replace(/\/+$/, "").replace(/\/v1$/, "");
46
+ }
47
+
48
+ function resolveGatewayBaseUrl(
49
+ ctx?: Pick<ImageCliContext, "gatewayBaseUrl">,
50
+ ): string {
51
+ if (ctx?.gatewayBaseUrl?.trim()) {
52
+ return normalizeGatewayBaseUrl(ctx.gatewayBaseUrl.trim());
53
+ }
59
54
  const explicit =
60
55
  process.env.DENCH_GATEWAY_URL?.trim() || process.env.GATEWAY_URL?.trim();
61
- if (explicit) return explicit.replace(/\/+$/, "");
56
+ if (explicit) return normalizeGatewayBaseUrl(explicit);
62
57
  const host = process.env.DENCH_HOST?.trim();
63
58
  if (host && /localhost|127\.0\.0\.1/.test(host)) {
64
59
  return "http://localhost:8787";
@@ -66,16 +61,17 @@ function resolveGatewayBaseUrl(): string {
66
61
  return "https://gateway.merseoriginals.com";
67
62
  }
68
63
 
69
- function requireApiKey(): string {
70
- const apiKey = process.env.DENCH_API_KEY?.trim();
71
- if (!apiKey) {
64
+ function requireBearerToken(
65
+ ctx?: Pick<ImageCliContext, "bearerToken">,
66
+ ): string {
67
+ const bearerToken =
68
+ ctx?.bearerToken?.trim() || process.env.DENCH_API_KEY?.trim();
69
+ if (!bearerToken) {
72
70
  throw new ImageCliError(
73
- "DENCH_API_KEY is not set. Inside a Dench sandbox it is baked into " +
74
- "the env automatically; outside, export it manually or run " +
75
- "`dench login` first.",
71
+ "No Dench gateway credential found. Set DENCH_API_KEY or run `dench signin` for a paid workspace.",
76
72
  );
77
73
  }
78
- return apiKey;
74
+ return bearerToken;
79
75
  }
80
76
 
81
77
  function resolveDefaultVolumeRoot(): string {
@@ -125,16 +121,17 @@ type GatewayImageResponse = {
125
121
  };
126
122
 
127
123
  async function callGateway(
124
+ ctx: ImageCliContext,
128
125
  path: "/v1/images/generations" | "/v1/images/edits",
129
126
  body: Record<string, unknown>,
130
127
  ): Promise<GatewayImageResponse> {
131
- const apiKey = requireApiKey();
132
- const baseUrl = resolveGatewayBaseUrl();
128
+ const bearerToken = requireBearerToken(ctx);
129
+ const baseUrl = resolveGatewayBaseUrl(ctx);
133
130
  const response = await fetch(`${baseUrl}${path}`, {
134
131
  method: "POST",
135
132
  headers: {
136
133
  "content-type": "application/json",
137
- Authorization: `Bearer ${apiKey}`,
134
+ Authorization: `Bearer ${bearerToken}`,
138
135
  },
139
136
  body: JSON.stringify(body),
140
137
  });
@@ -246,7 +243,16 @@ function parseCommonOpts(args: string[]): CommonImageOpts {
246
243
  `Invalid --moderation: ${moderation}. Allowed: auto, low`,
247
244
  );
248
245
  }
249
- return { size, quality, format, savePath, output, noWrite, background, moderation };
246
+ return {
247
+ size,
248
+ quality,
249
+ format,
250
+ savePath,
251
+ output,
252
+ noWrite,
253
+ background,
254
+ moderation,
255
+ };
250
256
  }
251
257
 
252
258
  function buildGenerationBody(
@@ -283,7 +289,7 @@ async function runGenerateSubcommand(ctx: ImageCliContext): Promise<void> {
283
289
  );
284
290
  }
285
291
  const body = buildGenerationBody(prompt, opts);
286
- const result = await callGateway("/v1/images/generations", body);
292
+ const result = await callGateway(ctx, "/v1/images/generations", body);
287
293
  if (!result.b64_json) {
288
294
  throw new ImageCliError("Gateway response did not include image bytes.");
289
295
  }
@@ -341,14 +347,16 @@ async function runEditSubcommand(ctx: ImageCliContext): Promise<void> {
341
347
  `Too many --input paths (${inputs.length}); the gateway accepts at most 8.`,
342
348
  );
343
349
  }
344
- const inputBase64 = await Promise.all(inputs.map((p) => readBase64FromPath(p)));
350
+ const inputBase64 = await Promise.all(
351
+ inputs.map((p) => readBase64FromPath(p)),
352
+ );
345
353
  const maskBase64 = mask ? await readBase64FromPath(mask) : undefined;
346
354
  const body = {
347
355
  ...buildGenerationBody(prompt, opts),
348
356
  input_images_base64: inputBase64,
349
357
  ...(maskBase64 ? { mask_base64: maskBase64 } : {}),
350
358
  };
351
- const result = await callGateway("/v1/images/edits", body);
359
+ const result = await callGateway(ctx, "/v1/images/edits", body);
352
360
  if (!result.b64_json) {
353
361
  throw new ImageCliError("Gateway response did not include image bytes.");
354
362
  }
@@ -379,9 +387,10 @@ function imageHelp(): void {
379
387
  console.log(`Usage: dench image <subcommand>
380
388
 
381
389
  Generate or edit images via the Dench Cloud Gateway (gpt-image-2). Auths
382
- with DENCH_API_KEY (baked into every sandbox automatically). Each call
383
- writes the bytes to Convex Storage (workspace tree updates immediately)
384
- and, when running inside a sandbox, also writes the decoded bytes to
390
+ with DENCH_API_KEY or the active \`dench signin\` agent session. Agent
391
+ sessions require a paid workspace. Each call writes the bytes to Convex
392
+ Storage (workspace tree updates immediately) and, when running inside a sandbox,
393
+ also writes the decoded bytes to
385
394
  local disk so subsequent bash commands see the file without waiting on
386
395
  the daemon's downward sync.
387
396
 
@@ -397,7 +406,7 @@ Edit / remix one or more existing images (image-to-image):
397
406
  [--save-path ...] [--output ...] [--no-write] [--json]
398
407
 
399
408
  Environment variables:
400
- DENCH_API_KEY Required. Bearer token sent to the gateway.
409
+ DENCH_API_KEY Optional when a dench signin agent session is active.
401
410
  DENCH_GATEWAY_URL Override the gateway base URL.
402
411
  GATEWAY_URL Same, used when DENCH_GATEWAY_URL is unset.
403
412
  DENCH_HOST If it points at localhost, defaults to
@@ -81,7 +81,7 @@ export function resolveEnrichmentGatewayBaseUrl(
81
81
  env: EnrichmentGatewayEnv = process.env,
82
82
  ): string {
83
83
  const explicit = env.DENCH_GATEWAY_URL?.trim() || env.GATEWAY_URL?.trim();
84
- if (explicit) return explicit.replace(/\/+$/, "");
84
+ if (explicit) return explicit.replace(/\/+$/, "").replace(/\/v1$/, "");
85
85
 
86
86
  const host = env.DENCH_HOST?.trim();
87
87
  if (host && /localhost|127\.0\.0\.1/.test(host)) {
@@ -1248,7 +1248,7 @@ async function callGatewayJson(
1248
1248
 
1249
1249
  function resolveBaseUrl(options: EnrichmentGatewayOptions): string {
1250
1250
  return (
1251
- options.baseUrl?.trim().replace(/\/+$/, "") ??
1251
+ options.baseUrl?.trim().replace(/\/+$/, "").replace(/\/v1$/, "") ??
1252
1252
  resolveEnrichmentGatewayBaseUrl(options.env)
1253
1253
  );
1254
1254
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dench.com/cli",
3
- "version": "2.0.0",
3
+ "version": "2.0.1",
4
4
  "description": "Dench agent workspace CLI. v2 unifies auth behind `dench signin`; the legacy `dench login` / `dench onboard` / `dench setup` / `dench what-can-i-do` / `dench register` commands now error with a redirect.",
5
5
  "type": "module",
6
6
  "bin": {
package/search.ts CHANGED
@@ -2,11 +2,9 @@
2
2
  * `dench search` — live web access through the Dench Cloud Gateway.
3
3
  *
4
4
  * The gateway exposes Exa Search at three routes; we mirror them as
5
- * three subcommands and authenticate with the `DENCH_API_KEY` baked
6
- * into every sandbox at create time. Unlike most other CLI surfaces
7
- * this one bypasses Convex entirely — it speaks straight HTTP to the
8
- * gateway, so it works in any environment that has the env var
9
- * (sandboxes, CI, local dev with `DENCH_API_KEY` exported manually).
5
+ * three subcommands and authenticate with either `DENCH_API_KEY` or a
6
+ * paid-workspace gateway key resolved from the active agent session by
7
+ * the top-level dispatcher.
10
8
  *
11
9
  * Subcommands:
12
10
  * search "<query>" [--num-results N] [--type auto|fast|deep|deep-reasoning|neural]
@@ -17,32 +15,21 @@
17
15
  * search answer "<query>" [--json]
18
16
  */
19
17
 
20
- import {
21
- CliArgError,
22
- getFlag,
23
- hasFlag,
24
- shift as shiftRaw,
25
- } from "./lib/cli-args";
18
+ import { getFlag } from "./lib/cli-args";
26
19
 
27
20
  type SearchCliContext = {
28
21
  args: string[];
29
22
  jsonOutput: boolean;
23
+ bearerToken?: string;
24
+ gatewayBaseUrl?: string;
30
25
  };
31
26
 
32
27
  class SearchCliError extends Error {}
33
28
 
34
- function shift(args: string[], expected: string): string {
35
- try {
36
- return shiftRaw(args, expected);
37
- } catch (error) {
38
- if (error instanceof CliArgError) {
39
- throw new SearchCliError(error.message);
40
- }
41
- throw error;
42
- }
43
- }
44
-
45
- function parsePositiveInt(name: string, raw: string | undefined): number | undefined {
29
+ function parsePositiveInt(
30
+ name: string,
31
+ raw: string | undefined,
32
+ ): number | undefined {
46
33
  if (raw === undefined) return undefined;
47
34
  const parsed = Number(raw);
48
35
  if (!Number.isFinite(parsed) || parsed <= 0) {
@@ -78,10 +65,19 @@ const ALLOWED_CATEGORIES = new Set([
78
65
  "people",
79
66
  ]);
80
67
 
81
- function resolveGatewayBaseUrl(): string {
68
+ function normalizeGatewayBaseUrl(value: string): string {
69
+ return value.replace(/\/+$/, "").replace(/\/v1$/, "");
70
+ }
71
+
72
+ function resolveGatewayBaseUrl(
73
+ ctx?: Pick<SearchCliContext, "gatewayBaseUrl">,
74
+ ): string {
75
+ if (ctx?.gatewayBaseUrl?.trim()) {
76
+ return normalizeGatewayBaseUrl(ctx.gatewayBaseUrl.trim());
77
+ }
82
78
  const explicit =
83
79
  process.env.DENCH_GATEWAY_URL?.trim() || process.env.GATEWAY_URL?.trim();
84
- if (explicit) return explicit.replace(/\/+$/, "");
80
+ if (explicit) return normalizeGatewayBaseUrl(explicit);
85
81
  // Local dev: the gateway listens on :8787 (matches gateway/src/index.ts
86
82
  // and the dev-server skill). Anything that smells like localhost in
87
83
  // DENCH_HOST routes to the local gateway too so `dench --host
@@ -93,29 +89,31 @@ function resolveGatewayBaseUrl(): string {
93
89
  return "https://gateway.merseoriginals.com";
94
90
  }
95
91
 
96
- function requireApiKey(): string {
97
- const apiKey = process.env.DENCH_API_KEY?.trim();
98
- if (!apiKey) {
92
+ function requireBearerToken(
93
+ ctx?: Pick<SearchCliContext, "bearerToken">,
94
+ ): string {
95
+ const bearerToken =
96
+ ctx?.bearerToken?.trim() || process.env.DENCH_API_KEY?.trim();
97
+ if (!bearerToken) {
99
98
  throw new SearchCliError(
100
- "DENCH_API_KEY is not set. Inside a Dench sandbox it is baked into " +
101
- "the env automatically; outside, export it manually or run " +
102
- "`dench login` first.",
99
+ "No Dench gateway credential found. Set DENCH_API_KEY or run `dench signin` for a paid workspace.",
103
100
  );
104
101
  }
105
- return apiKey;
102
+ return bearerToken;
106
103
  }
107
104
 
108
105
  async function callGateway(
106
+ ctx: SearchCliContext,
109
107
  path: "/v1/search" | "/v1/search/contents" | "/v1/search/answer",
110
108
  body: Record<string, unknown>,
111
109
  ): Promise<unknown> {
112
- const apiKey = requireApiKey();
113
- const baseUrl = resolveGatewayBaseUrl();
110
+ const bearerToken = requireBearerToken(ctx);
111
+ const baseUrl = resolveGatewayBaseUrl(ctx);
114
112
  const response = await fetch(`${baseUrl}${path}`, {
115
113
  method: "POST",
116
114
  headers: {
117
115
  "content-type": "application/json",
118
- Authorization: `Bearer ${apiKey}`,
116
+ Authorization: `Bearer ${bearerToken}`,
119
117
  },
120
118
  body: JSON.stringify(body),
121
119
  });
@@ -143,7 +141,9 @@ function asString(value: unknown): string | undefined {
143
141
  }
144
142
 
145
143
  function asNumber(value: unknown): number | undefined {
146
- return typeof value === "number" && Number.isFinite(value) ? value : undefined;
144
+ return typeof value === "number" && Number.isFinite(value)
145
+ ? value
146
+ : undefined;
147
147
  }
148
148
 
149
149
  function asObject(value: unknown): Record<string, unknown> | undefined {
@@ -184,7 +184,8 @@ function formatSearchResults(payload: unknown, limit?: number): string {
184
184
  lines.push("(no results)");
185
185
  return lines.join("\n");
186
186
  }
187
- const cap = limit !== undefined ? Math.min(results.length, limit) : results.length;
187
+ const cap =
188
+ limit !== undefined ? Math.min(results.length, limit) : results.length;
188
189
  for (let i = 0; i < cap; i++) {
189
190
  const result = asObject(results[i]);
190
191
  if (!result) continue;
@@ -197,7 +198,9 @@ function formatSearchResults(payload: unknown, limit?: number): string {
197
198
  const snippet =
198
199
  asString(result.text) ??
199
200
  (Array.isArray(result.highlights)
200
- ? result.highlights.filter((h): h is string => typeof h === "string").join(" ")
201
+ ? result.highlights
202
+ .filter((h): h is string => typeof h === "string")
203
+ .join(" ")
201
204
  : undefined) ??
202
205
  asString(result.summary);
203
206
  lines.push(`${i + 1}. ${title}`);
@@ -284,7 +287,10 @@ function out(ctx: SearchCliContext, payload: unknown, formatted: string): void {
284
287
  // ────────────────────────────────────────────────────────────────────────────
285
288
 
286
289
  async function runSearchSubcommand(ctx: SearchCliContext): Promise<void> {
287
- const numResults = parsePositiveInt("--num-results", getFlag(ctx.args, "--num-results"));
290
+ const numResults = parsePositiveInt(
291
+ "--num-results",
292
+ getFlag(ctx.args, "--num-results"),
293
+ );
288
294
  const type = getFlag(ctx.args, "--type");
289
295
  if (type !== undefined && !ALLOWED_TYPES.has(type)) {
290
296
  throw new SearchCliError(
@@ -321,12 +327,15 @@ async function runSearchSubcommand(ctx: SearchCliContext): Promise<void> {
321
327
  if (category) body.category = category;
322
328
  if (includeDomains) body.includeDomains = includeDomains;
323
329
  if (excludeDomains) body.excludeDomains = excludeDomains;
324
- const payload = await callGateway("/v1/search", body);
330
+ const payload = await callGateway(ctx, "/v1/search", body);
325
331
  out(ctx, payload, formatSearchResults(payload, numResults));
326
332
  }
327
333
 
328
334
  async function runContentsSubcommand(ctx: SearchCliContext): Promise<void> {
329
- const maxChars = parsePositiveInt("--max-chars", getFlag(ctx.args, "--max-chars"));
335
+ const maxChars = parsePositiveInt(
336
+ "--max-chars",
337
+ getFlag(ctx.args, "--max-chars"),
338
+ );
330
339
  const summary = getFlag(ctx.args, "--summary");
331
340
  const urls = ctx.args.slice();
332
341
  ctx.args.length = 0;
@@ -346,7 +355,7 @@ async function runContentsSubcommand(ctx: SearchCliContext): Promise<void> {
346
355
  highlights: { maxCharacters: 200 },
347
356
  };
348
357
  if (summary) body.summary = { query: summary };
349
- const payload = await callGateway("/v1/search/contents", body);
358
+ const payload = await callGateway(ctx, "/v1/search/contents", body);
350
359
  out(ctx, payload, formatContentsResults(payload));
351
360
  }
352
361
 
@@ -355,11 +364,9 @@ async function runAnswerSubcommand(ctx: SearchCliContext): Promise<void> {
355
364
  ctx.args.length = 0;
356
365
  const query = queryParts.join(" ").trim();
357
366
  if (!query) {
358
- throw new SearchCliError(
359
- 'Usage: dench search answer "<query>" [--json]',
360
- );
367
+ throw new SearchCliError('Usage: dench search answer "<query>" [--json]');
361
368
  }
362
- const payload = await callGateway("/v1/search/answer", {
369
+ const payload = await callGateway(ctx, "/v1/search/answer", {
363
370
  query,
364
371
  text: true,
365
372
  stream: false,
@@ -371,7 +378,8 @@ function searchHelp(): void {
371
378
  console.log(`Usage: dench search <subcommand>
372
379
 
373
380
  Live web access through the Dench Cloud Gateway (Exa Search). Auths
374
- with DENCH_API_KEY (baked into every sandbox automatically).
381
+ with DENCH_API_KEY or the active \`dench signin\` agent session. Agent
382
+ sessions require a paid workspace.
375
383
 
376
384
  Search the web (default subcommand):
377
385
  dench search "<query>" [--num-results N] [--type auto|fast|deep|deep-reasoning|neural]
@@ -386,7 +394,7 @@ One-shot synthesised answer with citations (Exa /search/answer):
386
394
  dench search answer "<query>" [--json]
387
395
 
388
396
  Environment variables:
389
- DENCH_API_KEY Required. Bearer token sent to the gateway.
397
+ DENCH_API_KEY Optional when a dench signin agent session is active.
390
398
  DENCH_GATEWAY_URL Override the gateway base URL.
391
399
  GATEWAY_URL Same, used when DENCH_GATEWAY_URL is unset.
392
400
  DENCH_HOST If it points at localhost, defaults to
package/tools.ts CHANGED
@@ -3,13 +3,9 @@
3
3
  * Composio's get-apps / get-tools / execute-tool surface.
4
4
  *
5
5
  * Mirrors `denchclaw`'s pattern: this CLI is a thin wrapper over five
6
- * gateway endpoints, authenticated with the unified `DENCH_API_KEY`
7
- * baked into every Dench sandbox at create time. There is no Convex
8
- * session involved; that means these subcommands work in any
9
- * environment that has the API key (sandboxes, CI, local dev with the
10
- * key exported manually). The previous implementation forced
11
- * `requireSessionRuntime` and routed through a Convex action, which
12
- * broke `apiKey` runtime mode entirely.
6
+ * gateway endpoints, authenticated with either `DENCH_API_KEY` or a
7
+ * paid-workspace gateway key resolved from the active agent session by
8
+ * the top-level dispatcher.
13
9
  *
14
10
  * Subcommands:
15
11
  *
@@ -20,7 +16,7 @@
20
16
  * dench tool connect <toolkit> [--callback-url <url>] [--json] — print OAuth redirect URL
21
17
  * dench tool disconnect <connectionId> [--json]
22
18
  *
23
- * Endpoints used (all `Authorization: Bearer DENCH_API_KEY`):
19
+ * Endpoints used (all `Authorization: Bearer <gateway credential>`):
24
20
  *
25
21
  * GET /v1/composio/connections
26
22
  * GET /v1/composio/toolkits?search=<slug>
@@ -36,10 +32,7 @@
36
32
  * working.
37
33
  */
38
34
 
39
- import {
40
- PRODUCTION_API_URL,
41
- PRODUCTION_GATEWAY_URL,
42
- } from "./host";
35
+ import { PRODUCTION_API_URL, PRODUCTION_GATEWAY_URL } from "./host";
43
36
  import {
44
37
  CliArgError,
45
38
  getFlag,
@@ -55,8 +48,15 @@ import {
55
48
  export type ToolCliContext = {
56
49
  args: string[];
57
50
  jsonOutput: boolean;
51
+ bearerToken?: string;
52
+ gatewayBaseUrl?: string;
58
53
  };
59
54
 
55
+ type GatewayAuthContext = Pick<
56
+ ToolCliContext,
57
+ "bearerToken" | "gatewayBaseUrl"
58
+ >;
59
+
60
60
  export async function runToolCommand(ctx: ToolCliContext): Promise<void> {
61
61
  const sub = ctx.args[0];
62
62
  if (!sub || sub === "help" || sub === "--help" || sub === "-h") {
@@ -91,11 +91,14 @@ export async function runToolCommand(ctx: ToolCliContext): Promise<void> {
91
91
 
92
92
  /**
93
93
  * Summarise the user's connected apps for `dench context` and friends.
94
- * Bypasses any Convex session and speaks straight to the gateway.
94
+ * Speaks straight to the gateway with either DENCH_API_KEY or a gateway
95
+ * key resolved from the active agent session.
95
96
  * Returns the same legacy shape the previous Convex-routed path did so
96
97
  * existing parsers don't need to change.
97
98
  */
98
- export async function fetchConnectedAppsSummary(): Promise<{
99
+ export async function fetchConnectedAppsSummary(
100
+ ctx: GatewayAuthContext = {},
101
+ ): Promise<{
99
102
  available: boolean;
100
103
  count: number;
101
104
  connections: Array<{
@@ -109,7 +112,7 @@ export async function fetchConnectedAppsSummary(): Promise<{
109
112
  }> {
110
113
  let payload: unknown;
111
114
  try {
112
- payload = await callGateway("GET", "/v1/composio/connections");
115
+ payload = await callGateway(ctx, "GET", "/v1/composio/connections");
113
116
  } catch (error) {
114
117
  if (error instanceof MissingApiKeyError) {
115
118
  return {
@@ -139,6 +142,7 @@ async function runStatusSubcommand(ctx: ToolCliContext): Promise<void> {
139
142
  if (filterToolkit) ctx.args.shift();
140
143
 
141
144
  const connectionsPayload = await callGateway(
145
+ ctx,
142
146
  "GET",
143
147
  "/v1/composio/connections",
144
148
  );
@@ -146,6 +150,7 @@ async function runStatusSubcommand(ctx: ToolCliContext): Promise<void> {
146
150
  if (filterToolkit) {
147
151
  const search = encodeURIComponent(filterToolkit);
148
152
  toolkitsPayload = await callGateway(
153
+ ctx,
149
154
  "GET",
150
155
  `/v1/composio/toolkits?search=${search}`,
151
156
  );
@@ -194,7 +199,12 @@ async function runSearchSubcommand(ctx: ToolCliContext): Promise<void> {
194
199
  if (toolkit) body.toolkit_slug = toolkit;
195
200
  if (limit) body.limit = limit;
196
201
 
197
- const payload = await callGateway("POST", "/v1/composio/tools/search", body);
202
+ const payload = await callGateway(
203
+ ctx,
204
+ "POST",
205
+ "/v1/composio/tools/search",
206
+ body,
207
+ );
198
208
  if (ctx.jsonOutput) {
199
209
  console.log(JSON.stringify(payload, null, 2));
200
210
  return;
@@ -231,7 +241,12 @@ async function runRunSubcommand(ctx: ToolCliContext): Promise<void> {
231
241
 
232
242
  let payload: unknown;
233
243
  try {
234
- payload = await callGateway("POST", "/v1/composio/tools/execute", body);
244
+ payload = await callGateway(
245
+ ctx,
246
+ "POST",
247
+ "/v1/composio/tools/execute",
248
+ body,
249
+ );
235
250
  } catch (error) {
236
251
  if (error instanceof GatewayHttpError && isLikelyNoConnection(error)) {
237
252
  const noConnPayload = noConnectionPayload(toolSlug, error);
@@ -266,7 +281,7 @@ async function runConnectSubcommand(ctx: ToolCliContext): Promise<void> {
266
281
  process.env.DENCH_OAUTH_CALLBACK_URL?.trim() ??
267
282
  `${resolveAppPublicOrigin()}/api/composio/callback`;
268
283
 
269
- const payload = await callGateway("POST", "/v1/composio/connect", {
284
+ const payload = await callGateway(ctx, "POST", "/v1/composio/connect", {
270
285
  toolkit,
271
286
  callback_url: callbackUrl,
272
287
  });
@@ -313,7 +328,7 @@ async function runDisconnectSubcommand(ctx: ToolCliContext): Promise<void> {
313
328
  const path = `/v1/composio/connections/${encodeURIComponent(connectionId)}`;
314
329
  let payload: unknown = { ok: true, deleted: true };
315
330
  try {
316
- payload = await callGateway("DELETE", path);
331
+ payload = await callGateway(ctx, "DELETE", path);
317
332
  } catch (error) {
318
333
  // Composio returns 404 for connections that are already gone — surface
319
334
  // that as a soft success so callers can keep their local state in sync.
@@ -350,9 +365,8 @@ class ToolCliError extends Error {
350
365
  class MissingApiKeyError extends ToolCliError {
351
366
  constructor() {
352
367
  super(
353
- "DENCH_API_KEY is not set. Inside a Dench sandbox it is baked into " +
354
- "the env automatically; outside, export it manually or run " +
355
- "`dench login` and copy the api-key from the output.",
368
+ "No Dench gateway credential found. Set DENCH_API_KEY or run " +
369
+ "`dench signin` for a paid workspace.",
356
370
  );
357
371
  this.name = "MissingApiKeyError";
358
372
  }
@@ -376,10 +390,19 @@ class GatewayHttpError extends ToolCliError {
376
390
  }
377
391
  }
378
392
 
379
- function resolveGatewayBaseUrl(): string {
393
+ function normalizeGatewayBaseUrl(value: string): string {
394
+ return value.replace(/\/+$/, "").replace(/\/v1$/, "");
395
+ }
396
+
397
+ function resolveGatewayBaseUrl(
398
+ ctx?: Pick<ToolCliContext, "gatewayBaseUrl">,
399
+ ): string {
400
+ if (ctx?.gatewayBaseUrl?.trim()) {
401
+ return normalizeGatewayBaseUrl(ctx.gatewayBaseUrl.trim());
402
+ }
380
403
  const explicit =
381
404
  process.env.DENCH_GATEWAY_URL?.trim() || process.env.GATEWAY_URL?.trim();
382
- if (explicit) return explicit.replace(/\/+$/, "");
405
+ if (explicit) return normalizeGatewayBaseUrl(explicit);
383
406
  const host = process.env.DENCH_HOST?.trim();
384
407
  if (host && /localhost|127\.0\.0\.1/.test(host)) {
385
408
  return "http://localhost:8787";
@@ -410,24 +433,26 @@ function resolveAppPublicOrigin(): string {
410
433
  return PRODUCTION_API_URL;
411
434
  }
412
435
 
413
- function requireApiKey(): string {
414
- const apiKey = process.env.DENCH_API_KEY?.trim();
415
- if (!apiKey) throw new MissingApiKeyError();
416
- return apiKey;
436
+ function requireBearerToken(ctx?: Pick<ToolCliContext, "bearerToken">): string {
437
+ const bearerToken =
438
+ ctx?.bearerToken?.trim() || process.env.DENCH_API_KEY?.trim();
439
+ if (!bearerToken) throw new MissingApiKeyError();
440
+ return bearerToken;
417
441
  }
418
442
 
419
443
  async function callGateway(
444
+ ctx: GatewayAuthContext,
420
445
  method: "GET" | "POST" | "DELETE",
421
446
  path: string,
422
447
  body?: Record<string, unknown>,
423
448
  ): Promise<unknown> {
424
- const apiKey = requireApiKey();
425
- const baseUrl = resolveGatewayBaseUrl();
449
+ const bearerToken = requireBearerToken(ctx);
450
+ const baseUrl = resolveGatewayBaseUrl(ctx);
426
451
  const init: RequestInit = {
427
452
  method,
428
453
  headers: {
429
454
  "content-type": "application/json",
430
- authorization: `Bearer ${apiKey}`,
455
+ authorization: `Bearer ${bearerToken}`,
431
456
  },
432
457
  };
433
458
  if (body !== undefined) init.body = JSON.stringify(body);
@@ -730,8 +755,8 @@ Usage:
730
755
  dench tool disconnect <connectionId> [--json]
731
756
 
732
757
  Notes:
733
- All commands authenticate with DENCH_API_KEY (Bearer) directly to the
734
- Dench Cloud Gateway. No \`dench login\` agent session required.
758
+ All commands authenticate with DENCH_API_KEY or the active \`dench signin\`
759
+ agent session. Agent sessions require a paid workspace.
735
760
  dench tool connect prints the OAuth redirect URL for the human to open.
736
761
  dench tool run output is redacted for display; pass --json for raw output.
737
762
  Override the gateway base with DENCH_GATEWAY_URL or GATEWAY_URL.
@@ -804,7 +829,7 @@ function parsePositiveInt(name: string, raw: string): number {
804
829
  // them as unused when callers tree-shake.
805
830
  export { ToolCliError, GatewayHttpError, MissingApiKeyError };
806
831
  // Kept reachable so a future caller can build their own dispatcher.
807
- export { resolveGatewayBaseUrl, requireApiKey, callGateway };
832
+ export { resolveGatewayBaseUrl, requireBearerToken, callGateway };
808
833
  // shiftRaw / hasFlag from cli-args are not used in this module yet but
809
834
  // keep the import group consistent with cli/search.ts conventions.
810
835
  void shiftRaw;