@dench.com/cli 0.3.1 → 0.3.2

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/README.md CHANGED
@@ -149,3 +149,36 @@ For staging:
149
149
  dench login --staging
150
150
  dench login --host https://workspace-staging.dench.com
151
151
  ```
152
+
153
+ ## Sandbox / API key auth (no `dench login` required)
154
+
155
+ Inside a Daytona sandbox the unified Dench API key is baked into envVars
156
+ at create time (see `src/lib/secrets/sandbox-tokens.ts`). When
157
+ `DENCH_API_KEY` and `CONVEX_URL` are both present, the CLI skips the
158
+ `dench login` flow entirely and uses the API key as a bearer for every
159
+ org-scoped command — `dench crm`, `dench files`, `dench fs sync`, etc.
160
+
161
+ ```bash
162
+ export DENCH_API_KEY=dench_pk_live_…
163
+ export CONVEX_URL=https://your-convex-deployment.convex.cloud
164
+ # Optional but enables nicer URLs in CLI output:
165
+ export DENCH_API_URL=https://app.dench.com
166
+ export DENCH_ORG_ID=…
167
+ export DENCH_ORG_SLUG=…
168
+
169
+ dench crm entries list people
170
+ ```
171
+
172
+ Auth precedence:
173
+
174
+ 1. `--dev` flag → use the dev workspace env (`NEXT_PUBLIC_CONVEX_URL`,
175
+ `DENCH_WORKSPACE`, `DENCH_DEV_AGENT_KEY`).
176
+ 2. A locally-stored `dench login` session for the current host.
177
+ 3. `DENCH_API_KEY` + `CONVEX_URL` (sandbox / automation path).
178
+ 4. `--dev`-style env vars (without the flag).
179
+
180
+ Commands that need to impersonate a specific agent (`dench memory`,
181
+ `dench task`, `dench claim`, `dench log`, `dench approval request`,
182
+ etc.) still require an agent session (`dench login`) — the unified API
183
+ key is org-scoped and cannot speak as a single agent. Those commands
184
+ return a clear "agent session required" error in API-key mode.
package/crm.ts CHANGED
@@ -2,8 +2,14 @@
2
2
  * `dench crm <subcommand>` — CLI surface for the Convex CRM.
3
3
  *
4
4
  * All operations resolve via the unified Dench API key baked into the
5
- * sandbox (DENCH_API_KEY) or the locally-stored session token. Talks to
6
- * the Convex deployment URL stored in the active session.
5
+ * sandbox (DENCH_API_KEY) or a locally-stored agent session token from
6
+ * `dench login`. The dispatcher does not care which one — the parent
7
+ * (`cli/dench.ts → getRuntime`) picks whichever auth is available and
8
+ * forwards a single bearer through `sessionToken`. The server-side
9
+ * `requireCrmAccess` (convex/lib/crm/access.ts) accepts either format.
10
+ *
11
+ * Talks to the Convex deployment URL provided by the active runtime
12
+ * (the session's `convexUrl` or the sandbox's `CONVEX_URL` envvar).
7
13
  *
8
14
  * This is a thin command dispatcher; the heavy lifting lives in
9
15
  * convex/functions/crm/* and convex/lib/crm/*.
@@ -37,9 +43,14 @@ type CrmCliContext = {
37
43
  args: string[];
38
44
  jsonOutput: boolean;
39
45
  /**
40
- * Agent session token (`dch_agent_*`) minted by `dench login`. Sent
41
- * with every Convex call so the server-side `requireCrmAccess` helper
42
- * can resolve the caller's organization without a Convex Auth cookie.
46
+ * Bearer token sent with every Convex call so the server-side
47
+ * `requireCrmAccess` helper can resolve the caller's organization
48
+ * without a Convex Auth cookie. Two formats are accepted:
49
+ * • `dch_agent_*` agent session minted by `dench login`.
50
+ * • Unified Dench API key (`dench_pk_*`, `dench_live_*`,
51
+ * `gw_sk_*`, etc.) baked into sandbox envVars as `DENCH_API_KEY`.
52
+ * The server dispatches on prefix; both resolve to the same
53
+ * organization context.
43
54
  */
44
55
  sessionToken?: string;
45
56
  };
package/dench.ts CHANGED
@@ -1620,11 +1620,27 @@ function hasDevEnv() {
1620
1620
  );
1621
1621
  }
1622
1622
 
1623
+ /**
1624
+ * Resolve the Convex URL the API-key runtime should talk to. Sandboxes
1625
+ * bake `CONVEX_URL` (see `src/lib/secrets/sandbox-tokens.ts`); local
1626
+ * dev typically only has `NEXT_PUBLIC_CONVEX_URL` exported. We accept
1627
+ * either so the same code path works in both shells without callers
1628
+ * needing to know which env var is set.
1629
+ */
1630
+ function resolveApiKeyConvexUrl(): string | undefined {
1631
+ return (
1632
+ process.env.CONVEX_URL?.trim() ||
1633
+ process.env.NEXT_PUBLIC_CONVEX_URL?.trim() ||
1634
+ undefined
1635
+ );
1636
+ }
1637
+
1623
1638
  function loginRequiredError(command = "this command") {
1624
1639
  return new CliError(`${command} requires dench login`, {
1625
1640
  code: "login_required",
1626
1641
  nextActions: [
1627
- 'Run dench onboard --kind <kind> --name "AI Agent - Project".',
1642
+ "Set DENCH_API_KEY (and CONVEX_URL) in your env if running inside a sandbox or CI.",
1643
+ 'Or run dench onboard --kind <kind> --name "AI Agent - Project" to mint an agent session.',
1628
1644
  "If already approved, run dench sessions --json, then dench use <session-key-or-workspace-slug>.",
1629
1645
  ],
1630
1646
  });
@@ -1670,6 +1686,33 @@ async function getRuntime() {
1670
1686
  ],
1671
1687
  });
1672
1688
  }
1689
+
1690
+ // Sandbox / automation path: the unified Dench API key + Convex URL
1691
+ // baked into sandbox envVars (see
1692
+ // src/lib/secrets/sandbox-tokens.ts) is enough to drive every
1693
+ // command that goes through `requireCrmAccess` server-side. We only
1694
+ // fall into this branch when no stored session is present, so
1695
+ // explicit `dench login` setups keep their existing behavior.
1696
+ const apiKey = process.env.DENCH_API_KEY?.trim();
1697
+ const convexUrl = resolveApiKeyConvexUrl();
1698
+ if (apiKey && convexUrl) {
1699
+ const orgId = process.env.DENCH_ORG_ID?.trim();
1700
+ const orgSlug = process.env.DENCH_ORG_SLUG?.trim();
1701
+ const apiHost =
1702
+ process.env.DENCH_API_URL?.trim() ||
1703
+ process.env.DENCH_INTERNAL_BASE?.trim() ||
1704
+ host;
1705
+ return {
1706
+ mode: "apiKey" as const,
1707
+ host: apiHost,
1708
+ client: new ConvexHttpClient(convexUrl),
1709
+ sessionToken: apiKey,
1710
+ organization:
1711
+ orgId && orgSlug
1712
+ ? { id: orgId, name: orgSlug, slug: orgSlug }
1713
+ : undefined,
1714
+ };
1715
+ }
1673
1716
  }
1674
1717
 
1675
1718
  if (!hasDevEnv()) {
@@ -1688,14 +1731,73 @@ async function getRuntime() {
1688
1731
 
1689
1732
  type Runtime = Awaited<ReturnType<typeof getRuntime>>;
1690
1733
 
1734
+ /**
1735
+ * `session` mode is required for `agentWorkspace.*` calls because they
1736
+ * gate on `validateAgentSession` server-side, which only accepts the
1737
+ * `dch_agent_*` token format. Sandboxes that only have a unified
1738
+ * `DENCH_API_KEY` (apiKey mode) cannot impersonate a specific agent
1739
+ * and will fail at the server with `Invalid agent session` — surface
1740
+ * that here as a clear error instead.
1741
+ */
1691
1742
  function requireSessionRuntime(
1692
1743
  runtime: Runtime,
1693
1744
  command: string,
1694
1745
  ): Extract<Runtime, { mode: "session" }> {
1695
- if (runtime.mode !== "session") {
1696
- throw loginRequiredError(command);
1746
+ if (runtime.mode === "session") return runtime;
1747
+ if (runtime.mode === "apiKey") {
1748
+ throw agentSessionRequiredError(command);
1749
+ }
1750
+ throw loginRequiredError(command);
1751
+ }
1752
+
1753
+ /**
1754
+ * Org-scoped commands (`dench crm`, `dench files`, etc.) accept either
1755
+ * a `dch_agent_*` agent session token OR a unified Dench API key
1756
+ * (`dench_*` / `gw_sk_*` / etc.) baked into sandbox envVars. The
1757
+ * server-side `requireCrmAccess` helper dispatches on the prefix.
1758
+ */
1759
+ type AuthenticatedRuntime = Extract<
1760
+ Runtime,
1761
+ { mode: "session" | "apiKey" }
1762
+ >;
1763
+
1764
+ function requireAuthenticatedRuntime(
1765
+ runtime: Runtime,
1766
+ command: string,
1767
+ ): AuthenticatedRuntime {
1768
+ if (runtime.mode === "session" || runtime.mode === "apiKey") return runtime;
1769
+ throw loginRequiredError(command);
1770
+ }
1771
+
1772
+ function agentSessionRequiredError(command: string) {
1773
+ return new CliError(
1774
+ `${command} requires an agent session — DENCH_API_KEY is org-scoped and cannot impersonate a specific agent`,
1775
+ {
1776
+ code: "agent_session_required",
1777
+ nextActions: [
1778
+ 'Run dench onboard --kind <kind> --name "AI Agent - Project" to mint an agent session for this command.',
1779
+ "Or run the equivalent action via the workspace UI / dench crm if it is org-level (e.g. CRM data, files).",
1780
+ ],
1781
+ },
1782
+ );
1783
+ }
1784
+
1785
+ /**
1786
+ * Some legacy `agentWorkspace.*` commands (task create / claim / log /
1787
+ * approval request) have separate session vs dev branches. With apiKey
1788
+ * mode added, the dev branch would silently receive partial args. Use
1789
+ * this to short-circuit those branches with a clear error.
1790
+ *
1791
+ * Asserts away the `apiKey` mode so the caller's downstream type
1792
+ * narrowing (e.g. `runtime.workspaceArgs`) keeps working.
1793
+ */
1794
+ function rejectApiKeyForAgentCommand(
1795
+ runtime: Runtime,
1796
+ command: string,
1797
+ ): asserts runtime is Exclude<Runtime, { mode: "apiKey" }> {
1798
+ if (runtime.mode === "apiKey") {
1799
+ throw agentSessionRequiredError(command);
1697
1800
  }
1698
- return runtime;
1699
1801
  }
1700
1802
 
1701
1803
  async function printToolStatus(runtime: Runtime, toolkit?: string) {
@@ -1927,23 +2029,36 @@ async function connectedAppsContext(runtime: Runtime) {
1927
2029
  }
1928
2030
 
1929
2031
  async function buildContext(runtime: Runtime) {
1930
- const mine =
1931
- runtime.mode === "session"
1932
- ? ((await runtime.client.query(
1933
- api.functions.agentWorkspace.whatCanIDoHere,
1934
- {
1935
- sessionToken: runtime.sessionToken,
1936
- },
1937
- )) as JsonRecord)
1938
- : summarizeDevMine(
1939
- runtime.workspaceArgs.workspaceSlug,
1940
- await devOverview(runtime),
1941
- );
1942
-
1943
- const urls =
1944
- runtime.mode === "session"
1945
- ? workspaceUrls(runtime.host, workspaceSlugFromContext(mine))
2032
+ let mine: JsonRecord;
2033
+ let urls: JsonRecord;
2034
+ if (runtime.mode === "session") {
2035
+ mine = (await runtime.client.query(
2036
+ api.functions.agentWorkspace.whatCanIDoHere,
2037
+ { sessionToken: runtime.sessionToken },
2038
+ )) as JsonRecord;
2039
+ urls = workspaceUrls(runtime.host, workspaceSlugFromContext(mine));
2040
+ } else if (runtime.mode === "dev") {
2041
+ mine = summarizeDevMine(
2042
+ runtime.workspaceArgs.workspaceSlug,
2043
+ await devOverview(runtime),
2044
+ );
2045
+ urls = {};
2046
+ } else {
2047
+ // apiKey mode: no agent session is available, so we can't call
2048
+ // `whatCanIDoHere` (it gates on `validateAgentSession`). Return a
2049
+ // minimal context derived from sandbox envVars so dench-cli help
2050
+ // surfaces still work without a full login.
2051
+ mine = {
2052
+ authMode: "apiKey",
2053
+ organization: runtime.organization ?? null,
2054
+ runId: process.env.DENCH_RUN_ID?.trim() ?? null,
2055
+ parentRunId: process.env.DENCH_PARENT_RUN_ID?.trim() ?? null,
2056
+ rootRunId: process.env.DENCH_ROOT_RUN_ID?.trim() ?? null,
2057
+ };
2058
+ urls = runtime.host
2059
+ ? workspaceUrls(runtime.host, runtime.organization?.slug)
1946
2060
  : {};
2061
+ }
1947
2062
 
1948
2063
  return {
1949
2064
  ...mine,
@@ -2341,7 +2456,7 @@ function withToolRunGuidance(runtime: Runtime, result: unknown) {
2341
2456
  if (!record || record.requiresApproval !== true) return result;
2342
2457
  const approvalId = stringField(record, "approvalId");
2343
2458
  const statusContext =
2344
- runtime.mode === "session"
2459
+ runtime.mode === "session" || runtime.mode === "apiKey"
2345
2460
  ? workspaceUrls(runtime.host, runtime.organization?.slug)
2346
2461
  : {};
2347
2462
  const approvalCommand = approvalId
@@ -2501,6 +2616,12 @@ async function listArtifacts(runtime: Runtime, limit?: number) {
2501
2616
  );
2502
2617
  }
2503
2618
  }
2619
+ if (runtime.mode === "apiKey") {
2620
+ // No agent identity → no artifact list. Return empty so callers
2621
+ // (e.g. `dench artifacts`, `dench suggested-work`) degrade gracefully
2622
+ // instead of throwing for sandbox callers.
2623
+ return [] as JsonRecord[];
2624
+ }
2504
2625
  const data = await devOverview(runtime);
2505
2626
  return asRecordArray(data.recentArtifacts);
2506
2627
  }
@@ -2675,14 +2796,16 @@ async function main() {
2675
2796
  }
2676
2797
  const { runCrmCommand } = await import("./crm");
2677
2798
  const runtime = await getRuntime();
2678
- const sessionRuntime = requireSessionRuntime(runtime, "dench crm");
2799
+ // CRM is org-scoped accept either an agent session token
2800
+ // (`dch_agent_*` from `dench login`) or a unified Dench API key
2801
+ // (`DENCH_API_KEY` baked into sandbox envVars). Server-side
2802
+ // `requireCrmAccess` (convex/lib/crm/access.ts) dispatches on the
2803
+ // bearer prefix and resolves the org for both.
2804
+ const authedRuntime = requireAuthenticatedRuntime(runtime, "dench crm");
2679
2805
  await runCrmCommand({
2680
- convex: sessionRuntime.client,
2806
+ convex: authedRuntime.client,
2681
2807
  args: subArgs,
2682
- // Server-side `requireCrmAccess` (convex/lib/crm/access.ts) accepts
2683
- // this token and resolves the agent's organization without a Convex
2684
- // Auth cookie. Mirrors the pattern used by every agentWorkspace fn.
2685
- sessionToken: sessionRuntime.sessionToken,
2808
+ sessionToken: authedRuntime.sessionToken,
2686
2809
  });
2687
2810
  return;
2688
2811
  }
@@ -2881,6 +3004,13 @@ async function main() {
2881
3004
  print(status);
2882
3005
  return;
2883
3006
  }
3007
+ if (runtime.mode === "apiKey") {
3008
+ // No agent session → no agent-status query. Fall back to a thin
3009
+ // env-derived snapshot so sandbox callers still get something
3010
+ // useful from `dench status`.
3011
+ print(await buildContext(runtime));
3012
+ return;
3013
+ }
2884
3014
  const data = await devOverview(runtime);
2885
3015
  const output = scopedStatus
2886
3016
  ? summarizeDevMine(runtime.workspaceArgs.workspaceSlug, data)
@@ -2903,6 +3033,11 @@ async function main() {
2903
3033
  );
2904
3034
  return;
2905
3035
  }
3036
+ if (runtime.mode === "apiKey") {
3037
+ throw loginRequiredError(
3038
+ "dench tasks (agent-scoped task list)",
3039
+ );
3040
+ }
2906
3041
  const data = await devOverview(runtime);
2907
3042
  print(data.tasks);
2908
3043
  return;
@@ -2919,6 +3054,9 @@ async function main() {
2919
3054
  print(status.agents);
2920
3055
  return;
2921
3056
  }
3057
+ if (runtime.mode === "apiKey") {
3058
+ throw loginRequiredError("dench agents");
3059
+ }
2922
3060
  const data = await devOverview(runtime);
2923
3061
  print(data.agents);
2924
3062
  return;
@@ -2935,6 +3073,9 @@ async function main() {
2935
3073
  print(status.approvals);
2936
3074
  return;
2937
3075
  }
3076
+ if (runtime.mode === "apiKey") {
3077
+ throw loginRequiredError("dench approvals");
3078
+ }
2938
3079
  const data = await devOverview(runtime);
2939
3080
  print(data.approvals);
2940
3081
  return;
@@ -3000,6 +3141,7 @@ async function main() {
3000
3141
  );
3001
3142
  return;
3002
3143
  }
3144
+ rejectApiKeyForAgentCommand(runtime, "dench task create");
3003
3145
  const taskId = await runtime.client.mutation(
3004
3146
  api.functions.agentWorkspace.devCreateTask,
3005
3147
  {
@@ -3108,6 +3250,7 @@ async function main() {
3108
3250
  );
3109
3251
  return;
3110
3252
  }
3253
+ rejectApiKeyForAgentCommand(runtime, "dench claim");
3111
3254
  const agentId = option("--agent") ?? (await defaultDevAgentId(runtime));
3112
3255
  print(
3113
3256
  await runtime.client.mutation(api.functions.agentWorkspace.devClaimTask, {
@@ -3135,6 +3278,7 @@ async function main() {
3135
3278
  );
3136
3279
  return;
3137
3280
  }
3281
+ rejectApiKeyForAgentCommand(runtime, "dench log");
3138
3282
  print(
3139
3283
  await runtime.client.mutation(api.functions.agentWorkspace.devAppendLog, {
3140
3284
  ...runtime.workspaceArgs,
@@ -3164,6 +3308,7 @@ async function main() {
3164
3308
  );
3165
3309
  return;
3166
3310
  }
3311
+ rejectApiKeyForAgentCommand(runtime, "dench approval request");
3167
3312
  const approvalId = await runtime.client.mutation(
3168
3313
  api.functions.agentWorkspace.devRequestApproval,
3169
3314
  {
package/fs-daemon.ts CHANGED
@@ -359,15 +359,23 @@ async function pullDownRemoteFile(
359
359
  );
360
360
  }
361
361
 
362
+ /**
363
+ * sysexits.h-style exit codes the supervisor (in
364
+ * src/lib/daytona/sandbox-bootstrap.ts) recognizes as **permanent** —
365
+ * keep restarting won't fix a missing env var or a missing dependency,
366
+ * so the supervisor gives up instead of crash-looping. Anything else
367
+ * (network blip, transient Convex error) exits 1 and gets retried.
368
+ */
369
+ const EXIT_CONFIG_ERROR = 78;
370
+
362
371
  async function runDaemon(daemonArgs: Args): Promise<void> {
363
372
  const convexUrl = process.env.CONVEX_URL?.trim();
364
373
  const apiKey = process.env.DENCH_API_KEY?.trim();
365
374
  if (!convexUrl || !apiKey) {
366
375
  console.error(
367
- "[dench-fs-daemon] CONVEX_URL or DENCH_API_KEY missing in env; nothing to sync",
376
+ "[dench-fs-daemon] CONVEX_URL or DENCH_API_KEY missing in env; refusing to start",
368
377
  );
369
- setInterval(() => undefined, 60_000);
370
- return;
378
+ process.exit(EXIT_CONFIG_ERROR);
371
379
  }
372
380
 
373
381
  const client = new DenchFileClient(convexUrl, apiKey);
@@ -386,12 +394,11 @@ async function runDaemon(daemonArgs: Args): Promise<void> {
386
394
  chokidar = (await import("chokidar")) as never;
387
395
  } catch (error) {
388
396
  console.error(
389
- `[dench-fs-daemon] chokidar not available; running in idle mode (${
397
+ `[dench-fs-daemon] chokidar not available; refusing to start (${
390
398
  error instanceof Error ? error.message : String(error)
391
399
  })`,
392
400
  );
393
- setInterval(() => undefined, 60_000);
394
- return;
401
+ process.exit(EXIT_CONFIG_ERROR);
395
402
  }
396
403
 
397
404
  const watcher = chokidar.watch(daemonArgs.workspace, {
@@ -705,10 +712,10 @@ main().catch((error) => {
705
712
  error instanceof Error ? (error.stack ?? error.message) : String(error)
706
713
  }`,
707
714
  );
708
- // Long-running daemon: don't exit on transient errors. One-shot
709
- // subcommands use a non-zero exit so callers see failure.
710
- if (process.argv.slice(2)[0]?.startsWith("stream-")) {
711
- process.exit(1);
712
- }
713
- setInterval(() => undefined, 60_000);
715
+ // Always exit with a non-zero code so the supervisor (in
716
+ // src/lib/daytona/sandbox-bootstrap.ts) can decide whether to
717
+ // restart. Previously we parked the process on a noop interval,
718
+ // which made `pgrep dench-fs-daemon` report "alive" for a daemon
719
+ // that had silently given up syncing.
720
+ process.exit(1);
714
721
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dench.com/cli",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Dench agent workspace CLI.",
5
5
  "type": "module",
6
6
  "bin": {