@dench.com/cli 0.4.9 → 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/dench.ts CHANGED
@@ -73,7 +73,7 @@ type ConfigFile = {
73
73
  * Explicit user choice of which Dench backend to talk to by default,
74
74
  * set via `dench backend set <local|staging|prod|<url>>`. Takes
75
75
  * precedence over the legacy `currentHost` field (which used to be
76
- * silently updated by every `dench login` and caused the CLI to
76
+ * silently updated by every `dench signin` and caused the CLI to
77
77
  * silently follow the last-logged-in environment).
78
78
  *
79
79
  * Resolution order in `resolveHost`:
@@ -164,8 +164,8 @@ const api = {
164
164
  agentUpdateTaskStatus: makeFunctionReference<"mutation">(
165
165
  "functions/agentWorkspace:agentUpdateTaskStatus",
166
166
  ),
167
- createAgentLoginRequest: makeFunctionReference<"mutation">(
168
- "functions/agentWorkspace:createAgentLoginRequest",
167
+ createSignInRequest: makeFunctionReference<"mutation">(
168
+ "functions/agentWorkspace:createSignInRequest",
169
169
  ),
170
170
  devAppendLog: makeFunctionReference<"mutation">(
171
171
  "functions/agentWorkspace:devAppendLog",
@@ -182,14 +182,11 @@ const api = {
182
182
  devListWorkspaceOverview: makeFunctionReference<"query">(
183
183
  "functions/agentWorkspace:devListWorkspaceOverview",
184
184
  ),
185
- devRegisterAgent: makeFunctionReference<"mutation">(
186
- "functions/agentWorkspace:devRegisterAgent",
187
- ),
188
185
  devRequestApproval: makeFunctionReference<"mutation">(
189
186
  "functions/agentWorkspace:devRequestApproval",
190
187
  ),
191
- pollAgentLoginRequest: makeFunctionReference<"mutation">(
192
- "functions/agentWorkspace:pollAgentLoginRequest",
188
+ pollSignInRequest: makeFunctionReference<"mutation">(
189
+ "functions/agentWorkspace:pollSignInRequest",
193
190
  ),
194
191
  whatCanIDoHere: makeFunctionReference<"query">(
195
192
  "functions/agentWorkspace:whatCanIDoHere",
@@ -336,7 +333,7 @@ function normalizeCliError(error: unknown): CliError {
336
333
  nextActions: [
337
334
  "Check that DENCH_DEV_AGENT_KEY in your shell matches the Convex deployment env.",
338
335
  "Use --dev only for a local/dev Convex deployment configured with that key.",
339
- "For normal terminal usage, run dench login or set DENCH_API_KEY + CONVEX_URL.",
336
+ "For normal terminal usage, run dench signin or set DENCH_API_KEY + CONVEX_URL.",
340
337
  ],
341
338
  });
342
339
  }
@@ -344,7 +341,7 @@ function normalizeCliError(error: unknown): CliError {
344
341
  return new CliError("Dev CRM access is disabled in production", {
345
342
  code: "dev_access_disabled",
346
343
  nextActions: [
347
- "Remove --dev and run dench login for terminal access.",
344
+ "Remove --dev and run dench signin for terminal access.",
348
345
  "Or use DENCH_API_KEY + CONVEX_URL for sandbox/automation access.",
349
346
  ],
350
347
  });
@@ -354,8 +351,8 @@ function normalizeCliError(error: unknown): CliError {
354
351
  code: "invalid_api_key",
355
352
  nextActions: [
356
353
  "Check DENCH_API_KEY and CONVEX_URL point to the same organization/deployment.",
357
- "Unset DENCH_API_KEY to use a saved dench login session instead.",
358
- "Or run dench login to create a new terminal session.",
354
+ "Unset DENCH_API_KEY to use a saved dench signin session instead.",
355
+ "Or run dench signin to create a new terminal session.",
359
356
  ],
360
357
  });
361
358
  }
@@ -423,10 +420,11 @@ function help() {
423
420
  console.log(`Dench CLI
424
421
 
425
422
  Usage:
426
- dench onboard [--kind <kind>] [--name "AI Agent - Project"] [--no-open] [--json]
427
- dench setup [--kind <kind>] [--name "AI Agent - Project"] [--no-open] [--json]
428
- dench what-can-i-do [--kind <kind>] [--name "AI Agent - Project"] [--no-open] [--json]
429
- dench login [--host ${DEFAULT_HOST}] [--name "Claude Code Agent"] [--kind <kind>] [--no-open]
423
+ dench signin [--kind <kind>] [--name "AI Agent - Project"] [--no-open] [--json]
424
+ dench signin --new-workspace --org-name "Workspace" [--kind <kind>] [--name "AI Agent - Project"]
425
+ dench signin --email user@example.com [--new-workspace --org-name "Workspace"] [--kind <kind>] [--name "AI Agent - Project"]
426
+ dench signin --org <workspace-slug> [--kind <kind>] [--name "AI Agent - Project"]
427
+ dench upgrade [--tier pro|max] [--cycle monthly|yearly] [--no-open] [--json]
430
428
  dench sessions [--host <host>] [--json]
431
429
  dench use <session-key-or-workspace-slug> [--host <host>] [--json]
432
430
  dench logout [session-key-or-workspace-slug] [--session <key-or-scope>] [--host <host>] [--all] [--json]
@@ -570,14 +568,18 @@ Billing:
570
568
  dench billing topup --amount 5 creates a Stripe Checkout link for the human to pay.
571
569
 
572
570
  Agent setup:
573
- dench onboard wraps login (when needed), workspace orientation, tasks, tools,
574
- suggested work, memory, and next recommended actions.
571
+ dench signin is the only auth surface. Two transports (browser link
572
+ by default, OTP paste-back with --email <addr>) cover both intents:
573
+ signing in to an existing workspace or creating a new free CRM-only
574
+ workspace (--new-workspace --org-name "<name>"). New workspaces have
575
+ full CRM access; run \`dench upgrade\` to unlock chat, file sync,
576
+ enrichment, and image generation.
575
577
  --kind accepts any string. Suggested values: claude_code, codex, cursor,
576
578
  hermes, openclaw, or any custom kind (e.g. aider, goose, internal_orchestrator).
577
579
  Defaults to "other" when omitted. Values are normalized to lowercase
578
580
  snake_case before send (e.g. "Claude Code" -> claude_code).
579
- After login approval, run dench status --mine --json and dench tasks --json.
580
- Use dench memory and dench artifacts to continue from durable context.
581
+ After sign-in, run dench context --json and dench crm objects list --json
582
+ to verify the workspace + CRM are wired up.
581
583
  Do not create, claim, or log a setup task by default.
582
584
 
583
585
  Backends and hosts:
@@ -602,7 +604,7 @@ Dev fallback (run against a local Convex deployment with a dev key):
602
604
 
603
605
  Advanced:
604
606
  For long-lived agents or when the human asks:
605
- DENCH_SESSION_KEY=stable-agent-id dench login
607
+ DENCH_SESSION_KEY=stable-agent-id dench signin
606
608
  DENCH_SESSION_KEY=stable-agent-id dench logout
607
609
  DENCH_SESSION_KEY=stable-agent-id dench status
608
610
  `);
@@ -621,7 +623,7 @@ Usage:
621
623
 
622
624
  Auth:
623
625
  All commands authenticate with DENCH_API_KEY (Bearer) directly to the
624
- Dench Cloud Gateway. No \`dench login\` agent session is required —
626
+ Dench Cloud Gateway. No \`dench signin\` agent session is required —
625
627
  inside a Dench sandbox the key is baked into the env automatically;
626
628
  outside, export DENCH_API_KEY=<key> first.
627
629
 
@@ -669,39 +671,46 @@ be converted into a workspace task.
669
671
  `);
670
672
  }
671
673
 
672
- function onboardHelp() {
673
- console.log(`Dench onboard
674
-
675
- Usage:
676
- dench onboard [--kind <kind>] [--name "AI Agent - Project"] [--no-open] [--json]
677
- dench setup [--kind <kind>] [--name "AI Agent - Project"] [--no-open] [--json]
678
- dench what-can-i-do [--kind <kind>] [--name "AI Agent - Project"] [--no-open] [--json]
679
-
680
- Logs in if needed, then shows what this agent can do here: workspace rules,
681
- open work, suggested work, connected-tool commands, memory, approvals, and
682
- next actions.
683
- `);
684
- }
685
-
686
- function loginHelp() {
687
- console.log(`Dench login
674
+ function signinHelp() {
675
+ console.log(`Dench signin
688
676
 
689
677
  Usage:
690
- dench login [--name "AI Agent - Billing Repo"] [--kind <kind>] [--host <host>] [--no-open]
691
-
692
- Agent kind:
693
- --kind accepts any string. Defaults to "other" when omitted.
694
- Suggested values: claude_code, codex, cursor, hermes, openclaw, or any
695
- custom kind such as aider, goose, or some_custom_agent. Values are
696
- normalized to lowercase snake_case before send (e.g. "Claude Code"
697
- becomes claude_code, "my agent" becomes my_agent).
698
-
699
- Agent setup:
700
- Claude Code: dench login --kind claude_code --name "Claude Code Agent - Project"
701
- Codex: dench login --kind codex --name "Codex Agent - Project"
702
- Cursor: dench login --kind cursor --name "Cursor Agent - Project"
703
- Custom: dench login --kind some_custom_agent --name "My Agent - Project"
704
- After approval, run dench status --mine --json and dench tasks --json.
678
+ dench signin [--kind <kind>] [--name "AI Agent - Project"] [--no-open] [--json]
679
+ dench signin --new-workspace --org-name "Workspace Name" [--kind <kind>] [--name "AI Agent - Project"]
680
+ dench signin --email user@example.com [--new-workspace --org-name "Workspace Name"] [--kind <kind>] [--name "AI Agent - Project"]
681
+ dench signin --org <workspace-slug> [--kind <kind>] [--name "AI Agent - Project"]
682
+
683
+ Signs in a CLI agent to a Dench workspace. Two transport modes:
684
+
685
+ Browser (default): opens a one-click approval URL. The human picks
686
+ or creates a workspace in the browser, then the CLI saves the
687
+ session token. Use --no-open to print the link instead.
688
+
689
+ OTP (with --email): prompts for the 6-digit code the user receives
690
+ by email; never opens a browser. Useful for headless / remote
691
+ shells.
692
+
693
+ Intents:
694
+
695
+ Sign in to existing workspace (default): the user picks one of
696
+ their admin workspaces in the browser. With OTP, the CLI prompts
697
+ when more than one admin workspace exists.
698
+
699
+ --new-workspace --org-name "<name>": creates a new FREE CRM-only
700
+ workspace with that name. Free workspaces have full CRM access;
701
+ chat, file sync, enrichment, and image-gen are paywalled — run
702
+ \`dench upgrade\` to unlock those.
703
+
704
+ Agent identity:
705
+ --kind accepts any string. Suggested values: claude_code, codex,
706
+ cursor, hermes, openclaw, or any custom kind. Values are
707
+ normalized to lowercase snake_case (e.g. "Claude Code" \u2192
708
+ claude_code). Defaults to "other".
709
+ --name is a human-friendly label, e.g. "Cursor Agent - Billing Repo".
710
+
711
+ After sign-in, run \`dench context --json\` to confirm the active
712
+ workspace and agent, then \`dench crm objects list --json\` to verify
713
+ CRM access.
705
714
  `);
706
715
  }
707
716
 
@@ -753,7 +762,7 @@ Usage:
753
762
 
754
763
  Lists all external apps connected to this organization (Slack, Gmail,
755
764
  GitHub, etc.). Authenticates with DENCH_API_KEY (Bearer) directly to the
756
- Dench Cloud Gateway — no \`dench login\` agent session required. Alias
765
+ Dench Cloud Gateway — no \`dench signin\` agent session required. Alias
757
766
  for \`dench tool status\`.
758
767
  `);
759
768
  }
@@ -786,7 +795,7 @@ What this does:
786
795
  The default Dench backend is production (${PRODUCTION_HOST}). "dench backend set"
787
796
  stores an explicit override in ~/.dench/config.json so every future
788
797
  "dench" invocation talks to that backend by default. Use it instead of
789
- relying on a stale currentHost from a past "dench login".
798
+ relying on a stale currentHost from a past "dench signin".
790
799
 
791
800
  Setting the backend to "local" also opts the CLI into auto-loading
792
801
  .env and .env.local from the dench.com workspace at startup, which is
@@ -810,7 +819,7 @@ function resolveHost(config: ConfigFile | undefined, _scope: SessionScope) {
810
819
  // represents an explicit user choice. Production wins on fresh
811
820
  // installs (no flags, no env, no config) — which is the safe default
812
821
  // for an agent CLI. The per-scope `currentHosts[scope.key]` map is
813
- // intentionally NOT consulted here so that a past `dench login
822
+ // intentionally NOT consulted here so that a past `dench signin
814
823
  // --host http://localhost:3000` doesn't silently keep routing every
815
824
  // future command at local dev.
816
825
  return resolveHostFromArgs(filteredArgs, config?.defaultBackend);
@@ -854,19 +863,6 @@ function workspaceLabel(session: StoredSession) {
854
863
  : session.organization.name;
855
864
  }
856
865
 
857
- function loginNextCommandsMessage(
858
- session: StoredSession,
859
- stableSessionKey: string | undefined,
860
- ) {
861
- const workspaceName = session.organization?.name ?? "unknown workspace";
862
- const workspaceSlug = session.organization?.slug ?? "unknown";
863
- const agentName = session.agent?.name ?? "unknown agent";
864
- const stableKeyText = stableSessionKey
865
- ? ` Stable session key: ${stableSessionKey}.`
866
- : "";
867
- return `Next commands will use workspace ${workspaceName} (${workspaceSlug}) as ${agentName}.${stableKeyText}`;
868
- }
869
-
870
866
  function ambiguousSessionMessage(
871
867
  config: ConfigFile,
872
868
  scope: SessionScope,
@@ -970,11 +966,12 @@ function backendSnapshot(config: ConfigFile) {
970
966
  : null;
971
967
  const envHost = process.env.DENCH_HOST?.trim() || null;
972
968
  const argBackend =
973
- option("--backend") ?? (hasFlag("--prod")
969
+ option("--backend") ??
970
+ (hasFlag("--prod")
974
971
  ? "production"
975
972
  : hasFlag("--staging")
976
973
  ? "staging"
977
- : option("--host") ?? null);
974
+ : (option("--host") ?? null));
978
975
 
979
976
  let source: "flag" | "env" | "config" | "default";
980
977
  if (argBackend) source = "flag";
@@ -1082,7 +1079,9 @@ async function backendSet() {
1082
1079
  "Local backend selected. dench will now also load .env / .env.local from the workspace so GATEWAY_URL etc. are picked up.",
1083
1080
  );
1084
1081
  }
1085
- 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
+ );
1086
1085
  }
1087
1086
 
1088
1087
  async function backendClear() {
@@ -1539,29 +1538,183 @@ async function discoverConvexUrl(host: string) {
1539
1538
  return payload.convexUrl;
1540
1539
  }
1541
1540
 
1542
- async function login() {
1541
+ /**
1542
+ * Read a single line from stdin. Used by `dench signin --email` for
1543
+ * the OTP code and (when the user has multiple admin workspaces) the
1544
+ * org-picker prompt. Returns the trimmed line or throws if stdin
1545
+ * isn't a TTY — callers can pre-supply the value via flags to avoid
1546
+ * the prompt in CI / non-interactive shells.
1547
+ */
1548
+ async function promptLine(question: string): Promise<string> {
1549
+ if (!process.stdin.isTTY) {
1550
+ throw new CliError(
1551
+ `Non-interactive shell: pass the answer to "${question}" via a CLI flag instead of prompting.`,
1552
+ { code: "stdin_not_tty" },
1553
+ );
1554
+ }
1555
+ process.stdout.write(question);
1556
+ return await new Promise<string>((resolve, reject) => {
1557
+ let data = "";
1558
+ const onData = (chunk: Buffer | string) => {
1559
+ data += chunk.toString();
1560
+ if (data.includes("\n")) {
1561
+ cleanup();
1562
+ resolve(data.trim());
1563
+ }
1564
+ };
1565
+ const onErr = (err: Error) => {
1566
+ cleanup();
1567
+ reject(err);
1568
+ };
1569
+ const cleanup = () => {
1570
+ process.stdin.removeListener("data", onData);
1571
+ process.stdin.removeListener("error", onErr);
1572
+ process.stdin.pause();
1573
+ };
1574
+ process.stdin.resume();
1575
+ process.stdin.on("data", onData);
1576
+ process.stdin.on("error", onErr);
1577
+ });
1578
+ }
1579
+
1580
+ type SignInIntent = "join_existing" | "create_workspace";
1581
+
1582
+ function signinNextCommandsMessage(
1583
+ session: StoredSession,
1584
+ stableSessionKey: string | undefined,
1585
+ ) {
1586
+ const workspaceName = session.organization?.name ?? "unknown workspace";
1587
+ const workspaceSlug = session.organization?.slug ?? "unknown";
1588
+ const agentName = session.agent?.name ?? "unknown agent";
1589
+ const stableKeyText = stableSessionKey
1590
+ ? ` Stable session key: ${stableSessionKey}.`
1591
+ : "";
1592
+ return `Signed in. Next commands will use workspace ${workspaceName} (${workspaceSlug}) as ${agentName}.${stableKeyText}`;
1593
+ }
1594
+
1595
+ /**
1596
+ * `dench signin` — the single unified CLI auth entry point.
1597
+ *
1598
+ * Two transports (browser link or OTP) × two intents (join existing
1599
+ * workspace or create a new free CRM-only workspace). Replaces the
1600
+ * deprecated `dench login`, `dench onboard`, `dench setup`,
1601
+ * `dench what-can-i-do`, and `dench register` commands.
1602
+ *
1603
+ * Flag matrix:
1604
+ *
1605
+ * (no flags) Browser link, join existing.
1606
+ * --new-workspace --org-name "X" Browser link, create new free workspace.
1607
+ * --email <addr> OTP flow, join existing.
1608
+ * --email + --new-workspace ... OTP flow, create new free workspace.
1609
+ * --org <slug-or-id> Skip the org picker (existing workspaces).
1610
+ *
1611
+ * Both transports save the resulting `dch_agent_*` session to
1612
+ * `~/.dench/config.json` via `saveSession()` — identical shape to the
1613
+ * previous login flow, so `dench logout` / `dench use` keep working.
1614
+ */
1615
+ async function signin() {
1543
1616
  const scope = resolveSessionScope({ args: filteredArgs });
1544
1617
  const explicitKey = explicitSessionKeyInput({ args: filteredArgs });
1545
1618
  const host = resolveHostFromArgs(filteredArgs, undefined);
1546
1619
  const convexUrl = await discoverConvexUrl(host);
1547
1620
  const client = new ConvexHttpClient(convexUrl);
1548
- const code = randomUrlSafe(18);
1621
+
1549
1622
  const sessionToken = `dch_agent_${randomUrlSafe(32)}`;
1550
- const codeHash = await sha256Hex(code);
1551
1623
  const sessionTokenHash = await sha256Hex(sessionToken);
1624
+
1552
1625
  const agentKind = normalizeAgentKind(option("--kind"));
1553
1626
  const agentName = resolveAgentName(agentKind);
1554
- const request = await client.mutation(
1555
- api.functions.agentWorkspace.createAgentLoginRequest,
1556
- {
1557
- codeHash,
1627
+ const email = option("--email")?.trim().toLowerCase() || null;
1628
+ const newWorkspace = hasFlag("--new-workspace");
1629
+ const orgNameFlag = option("--org-name")?.trim() || null;
1630
+ const orgFlag = option("--org")?.trim() || null;
1631
+
1632
+ if (newWorkspace && !orgNameFlag) {
1633
+ throw new CliError(
1634
+ "--new-workspace requires --org-name '<workspace name>'.",
1635
+ { code: "missing_org_name" },
1636
+ );
1637
+ }
1638
+ if (newWorkspace && orgFlag) {
1639
+ throw new CliError(
1640
+ "Pass either --new-workspace --org-name '<name>' OR --org <slug>, not both.",
1641
+ { code: "conflicting_intent" },
1642
+ );
1643
+ }
1644
+
1645
+ const intent: SignInIntent = newWorkspace
1646
+ ? "create_workspace"
1647
+ : "join_existing";
1648
+
1649
+ if (email) {
1650
+ await signinViaOtp({
1651
+ host,
1652
+ scope,
1653
+ explicitKey,
1654
+ sessionToken,
1558
1655
  sessionTokenHash,
1559
1656
  agentName,
1560
1657
  agentKind,
1658
+ email,
1659
+ intent,
1660
+ proposedOrganizationName: orgNameFlag,
1661
+ orgSelector: orgFlag,
1662
+ });
1663
+ return;
1664
+ }
1665
+
1666
+ await signinViaBrowser({
1667
+ client,
1668
+ host,
1669
+ convexUrl,
1670
+ scope,
1671
+ explicitKey,
1672
+ sessionToken,
1673
+ sessionTokenHash,
1674
+ agentName,
1675
+ agentKind,
1676
+ intent,
1677
+ proposedOrganizationName: orgNameFlag,
1678
+ orgSelector: orgFlag,
1679
+ });
1680
+ }
1681
+
1682
+ /**
1683
+ * Browser-link variant of `dench signin`. Creates a sign-in request
1684
+ * row in Convex, opens the approval URL in the user's browser, then
1685
+ * polls until the human approves the request (and picks/creates the
1686
+ * target workspace on the approval page).
1687
+ */
1688
+ async function signinViaBrowser(input: {
1689
+ client: ConvexHttpClient;
1690
+ host: string;
1691
+ convexUrl: string;
1692
+ scope: SessionScope;
1693
+ explicitKey: ReturnType<typeof explicitSessionKeyInput>;
1694
+ sessionToken: string;
1695
+ sessionTokenHash: string;
1696
+ agentName: string;
1697
+ agentKind: string;
1698
+ intent: SignInIntent;
1699
+ proposedOrganizationName: string | null;
1700
+ orgSelector: string | null;
1701
+ }) {
1702
+ const code = randomUrlSafe(18);
1703
+ const codeHash = await sha256Hex(code);
1704
+ const request = await input.client.mutation(
1705
+ api.functions.agentWorkspace.createSignInRequest,
1706
+ {
1707
+ codeHash,
1708
+ sessionTokenHash: input.sessionTokenHash,
1709
+ agentName: input.agentName,
1710
+ agentKind: input.agentKind,
1711
+ intent: input.intent,
1712
+ proposedOrganizationName: input.proposedOrganizationName ?? undefined,
1713
+ requestedOrganizationSelector: input.orgSelector ?? undefined,
1561
1714
  userAgent: "@dench.com/cli",
1562
1715
  },
1563
1716
  );
1564
- const approvalUrl = `${host}/agent-login/${encodeURIComponent(code)}`;
1717
+ const approvalUrl = `${input.host}/agent-signin/${encodeURIComponent(code)}`;
1565
1718
 
1566
1719
  const openResult = await openUrl(approvalUrl, {
1567
1720
  noOpen: hasFlag("--no-open"),
@@ -1569,9 +1722,19 @@ async function login() {
1569
1722
  env: process.env,
1570
1723
  });
1571
1724
  logHuman(formatApprovalOpenMessage(approvalUrl, openResult));
1572
- logHuman(
1573
- "Before approving, switch Dench in your browser to the intended workspace.",
1574
- );
1725
+ if (input.intent === "create_workspace") {
1726
+ logHuman(
1727
+ `On the approval page, confirm the workspace name and connect ${input.agentName}.`,
1728
+ );
1729
+ } else if (input.orgSelector) {
1730
+ logHuman(
1731
+ `On the approval page, confirm connecting ${input.agentName} to ${input.orgSelector}.`,
1732
+ );
1733
+ } else {
1734
+ logHuman(
1735
+ `On the approval page, pick which workspace to connect ${input.agentName} to (or create a new one).`,
1736
+ );
1737
+ }
1575
1738
  logHuman(
1576
1739
  `This request expires at ${new Date(request.expiresAt).toLocaleString()}.`,
1577
1740
  );
@@ -1579,33 +1742,33 @@ async function login() {
1579
1742
  const timeoutMs = Number(option("--timeout-ms") ?? 15 * 60 * 1000);
1580
1743
  const deadline = Date.now() + timeoutMs;
1581
1744
  while (Date.now() < deadline) {
1582
- const poll = await client.mutation(
1583
- api.functions.agentWorkspace.pollAgentLoginRequest,
1584
- { codeHash, sessionTokenHash },
1745
+ const poll = await input.client.mutation(
1746
+ api.functions.agentWorkspace.pollSignInRequest,
1747
+ { codeHash, sessionTokenHash: input.sessionTokenHash },
1585
1748
  );
1586
1749
 
1587
1750
  if (poll.status === "approved") {
1588
1751
  const storedSession: StoredSession = {
1589
- host,
1590
- convexUrl,
1591
- sessionToken,
1752
+ host: input.host,
1753
+ convexUrl: input.convexUrl,
1754
+ sessionToken: input.sessionToken,
1592
1755
  organization: poll.organization,
1593
1756
  agent: poll.agent,
1594
1757
  sessionExpiresAt: poll.sessionExpiresAt,
1595
1758
  savedAt: Date.now(),
1596
1759
  };
1597
- await saveSession(storedSession, scope);
1598
- const message = loginNextCommandsMessage(
1760
+ await saveSession(storedSession, input.scope);
1761
+ const message = signinNextCommandsMessage(
1599
1762
  storedSession,
1600
- explicitKey?.source === "DENCH_SESSION_KEY"
1601
- ? explicitKey.value
1763
+ input.explicitKey?.source === "DENCH_SESSION_KEY"
1764
+ ? input.explicitKey.value
1602
1765
  : undefined,
1603
1766
  );
1604
1767
  print(
1605
1768
  json
1606
1769
  ? {
1607
1770
  ok: true,
1608
- host,
1771
+ host: input.host,
1609
1772
  organization: poll.organization,
1610
1773
  agent: poll.agent,
1611
1774
  sessionExpiresAt: poll.sessionExpiresAt,
@@ -1616,13 +1779,275 @@ async function login() {
1616
1779
  return;
1617
1780
  }
1618
1781
  if (poll.status === "rejected" || poll.status === "expired") {
1619
- throw new Error(`Login ${poll.status}`);
1782
+ throw new Error(`Sign-in ${poll.status}`);
1620
1783
  }
1621
1784
 
1622
1785
  await sleep(2000);
1623
1786
  }
1624
1787
 
1625
- throw new Error(`Login timed out. Open ${approvalUrl} and run login again.`);
1788
+ throw new Error(
1789
+ `Sign-in timed out. Open ${approvalUrl} and run \`dench signin\` again.`,
1790
+ );
1791
+ }
1792
+
1793
+ type OtpVerifyResult =
1794
+ | {
1795
+ kind: "completed";
1796
+ organization?: { id: string; name: string; slug: string };
1797
+ sessionExpiresAt?: number;
1798
+ }
1799
+ | {
1800
+ kind: "requiresOrgChoice";
1801
+ authToken: string;
1802
+ organizations: Array<{ id: string; name: string; slug: string }>;
1803
+ expiresAt: number;
1804
+ };
1805
+
1806
+ /**
1807
+ * OTP variant of `dench signin`. Sends the OTP via the Next.js
1808
+ * agent-signin routes, prompts the human for the 6-digit code, and
1809
+ * mints a session against the org they pick (or create) in one or
1810
+ * two HTTP round-trips.
1811
+ */
1812
+ async function signinViaOtp(input: {
1813
+ host: string;
1814
+ scope: SessionScope;
1815
+ explicitKey: ReturnType<typeof explicitSessionKeyInput>;
1816
+ sessionToken: string;
1817
+ sessionTokenHash: string;
1818
+ agentName: string;
1819
+ agentKind: string;
1820
+ email: string;
1821
+ intent: SignInIntent;
1822
+ proposedOrganizationName: string | null;
1823
+ orgSelector: string | null;
1824
+ }) {
1825
+ const base = input.host.replace(/\/+$/, "");
1826
+
1827
+ // Step 1 — send the OTP.
1828
+ const startResponse = await fetch(`${base}/api/agent-signin/otp-start`, {
1829
+ method: "POST",
1830
+ headers: { "content-type": "application/json", accept: "application/json" },
1831
+ body: JSON.stringify({ email: input.email }),
1832
+ });
1833
+ if (!startResponse.ok) {
1834
+ const body = await startResponse.text();
1835
+ throw new CliError(
1836
+ `OTP send failed (${startResponse.status}): ${body || "unknown error"}`,
1837
+ { code: "otp_send_failed", status: startResponse.status },
1838
+ );
1839
+ }
1840
+ logHuman(`We sent a 6-digit sign-in code to ${input.email}.`);
1841
+
1842
+ // Step 2 — verify the OTP. Either the user is unambiguously
1843
+ // resolvable (single admin org or create_workspace intent) and the
1844
+ // server hands us a session, OR we get an auth token + list and
1845
+ // need a third call.
1846
+ const code =
1847
+ option("--otp-code")?.trim() || (await promptLine("Paste OTP: "));
1848
+ if (!/^\d{6}$/.test(code)) {
1849
+ throw new CliError("OTP code must be exactly 6 digits.", {
1850
+ code: "invalid_otp_code",
1851
+ });
1852
+ }
1853
+ const verifyResponse = await fetch(`${base}/api/agent-signin/otp-verify`, {
1854
+ method: "POST",
1855
+ headers: { "content-type": "application/json", accept: "application/json" },
1856
+ body: JSON.stringify({
1857
+ email: input.email,
1858
+ code,
1859
+ agentName: input.agentName,
1860
+ agentKind: input.agentKind,
1861
+ sessionTokenHash: input.sessionTokenHash,
1862
+ intent: input.intent,
1863
+ proposedOrganizationName: input.proposedOrganizationName ?? undefined,
1864
+ organizationSelector: input.orgSelector ?? undefined,
1865
+ userAgent: "@dench.com/cli",
1866
+ }),
1867
+ });
1868
+ if (!verifyResponse.ok) {
1869
+ const body = await verifyResponse.text();
1870
+ throw new CliError(
1871
+ `OTP verify failed (${verifyResponse.status}): ${body || "unknown error"}`,
1872
+ { code: "otp_verify_failed", status: verifyResponse.status },
1873
+ );
1874
+ }
1875
+ const verifyPayload = (await verifyResponse.json()) as {
1876
+ ok: boolean;
1877
+ result: OtpVerifyResult;
1878
+ };
1879
+ const result = verifyPayload.result;
1880
+
1881
+ // Branch A: server minted the session in one shot.
1882
+ if (result.kind === "completed") {
1883
+ assertOtpCompletedOrgMatchesSelector(
1884
+ result.organization,
1885
+ input.orgSelector,
1886
+ );
1887
+ await finalizeOtpSession(
1888
+ input,
1889
+ result.organization,
1890
+ result.sessionExpiresAt,
1891
+ );
1892
+ return;
1893
+ }
1894
+
1895
+ // Branch B: human has multiple admin orgs — pick one (or create new).
1896
+ const finalizeChoice = await resolveOrgChoiceForOtp({
1897
+ organizations: result.organizations,
1898
+ orgSelector: input.orgSelector,
1899
+ });
1900
+
1901
+ const finalizeResponse = await fetch(
1902
+ `${base}/api/agent-signin/otp-finalize`,
1903
+ {
1904
+ method: "POST",
1905
+ headers: {
1906
+ "content-type": "application/json",
1907
+ accept: "application/json",
1908
+ },
1909
+ body: JSON.stringify({
1910
+ authToken: result.authToken,
1911
+ organizationId: finalizeChoice.organizationId,
1912
+ newOrganizationName: finalizeChoice.newOrganizationName,
1913
+ }),
1914
+ },
1915
+ );
1916
+ if (!finalizeResponse.ok) {
1917
+ const body = await finalizeResponse.text();
1918
+ throw new CliError(
1919
+ `OTP finalize failed (${finalizeResponse.status}): ${body || "unknown error"}`,
1920
+ { code: "otp_finalize_failed", status: finalizeResponse.status },
1921
+ );
1922
+ }
1923
+ const finalizePayload = (await finalizeResponse.json()) as {
1924
+ ok: boolean;
1925
+ result: {
1926
+ organization?: { id: string; name: string; slug: string };
1927
+ sessionExpiresAt?: number;
1928
+ };
1929
+ };
1930
+ await finalizeOtpSession(
1931
+ input,
1932
+ finalizePayload.result.organization,
1933
+ finalizePayload.result.sessionExpiresAt,
1934
+ );
1935
+ }
1936
+
1937
+ function assertOtpCompletedOrgMatchesSelector(
1938
+ organization: { id: string; name: string; slug: string } | undefined,
1939
+ orgSelector: string | null,
1940
+ ) {
1941
+ if (!orgSelector) return;
1942
+ const normalized = orgSelector.trim().toLowerCase();
1943
+ if (!organization) {
1944
+ throw new CliError(
1945
+ `The OTP flow completed without an organization, but --org ${orgSelector} was requested.`,
1946
+ { code: "org_not_found" },
1947
+ );
1948
+ }
1949
+ const matches =
1950
+ organization.id.toLowerCase() === normalized ||
1951
+ organization.slug.toLowerCase() === normalized ||
1952
+ organization.name.trim().toLowerCase() === normalized;
1953
+ if (!matches) {
1954
+ throw new CliError(
1955
+ `The OTP flow resolved ${organization.name} (${organization.slug}), but --org ${orgSelector} was requested.`,
1956
+ { code: "org_mismatch" },
1957
+ );
1958
+ }
1959
+ }
1960
+
1961
+ async function finalizeOtpSession(
1962
+ input: {
1963
+ host: string;
1964
+ scope: SessionScope;
1965
+ explicitKey: ReturnType<typeof explicitSessionKeyInput>;
1966
+ sessionToken: string;
1967
+ },
1968
+ organization: { id: string; name: string; slug: string } | undefined,
1969
+ sessionExpiresAt: number | undefined,
1970
+ ) {
1971
+ const convexUrl = await discoverConvexUrl(input.host);
1972
+ const storedSession: StoredSession = {
1973
+ host: input.host,
1974
+ convexUrl,
1975
+ sessionToken: input.sessionToken,
1976
+ organization,
1977
+ sessionExpiresAt,
1978
+ savedAt: Date.now(),
1979
+ };
1980
+ await saveSession(storedSession, input.scope);
1981
+ const message = signinNextCommandsMessage(
1982
+ storedSession,
1983
+ input.explicitKey?.source === "DENCH_SESSION_KEY"
1984
+ ? input.explicitKey.value
1985
+ : undefined,
1986
+ );
1987
+ print(
1988
+ json
1989
+ ? {
1990
+ ok: true,
1991
+ host: input.host,
1992
+ organization,
1993
+ sessionExpiresAt,
1994
+ message,
1995
+ }
1996
+ : message,
1997
+ );
1998
+ }
1999
+
2000
+ async function resolveOrgChoiceForOtp(input: {
2001
+ organizations: Array<{ id: string; name: string; slug: string }>;
2002
+ orgSelector: string | null;
2003
+ }): Promise<{ organizationId?: string; newOrganizationName?: string }> {
2004
+ if (input.orgSelector) {
2005
+ const match = input.organizations.find(
2006
+ (org) =>
2007
+ org.id === input.orgSelector ||
2008
+ org.slug === input.orgSelector ||
2009
+ org.name === input.orgSelector,
2010
+ );
2011
+ if (!match) {
2012
+ throw new CliError(
2013
+ `--org "${input.orgSelector}" did not match any of your admin workspaces.`,
2014
+ { code: "org_not_found" },
2015
+ );
2016
+ }
2017
+ return { organizationId: match.id };
2018
+ }
2019
+
2020
+ // Interactive picker.
2021
+ logHuman("");
2022
+ logHuman("Which workspace should this agent connect to?");
2023
+ for (let i = 0; i < input.organizations.length; i++) {
2024
+ const org = input.organizations[i];
2025
+ logHuman(` ${i + 1}) ${org.name} (${org.slug})`);
2026
+ }
2027
+ logHuman(` ${input.organizations.length + 1}) Create a new free workspace`);
2028
+ logHuman("");
2029
+
2030
+ const answer = await promptLine("Pick a number: ");
2031
+ const choice = Number(answer);
2032
+ if (
2033
+ !Number.isFinite(choice) ||
2034
+ choice < 1 ||
2035
+ choice > input.organizations.length + 1
2036
+ ) {
2037
+ throw new CliError(`Invalid choice: ${answer}`, {
2038
+ code: "invalid_org_choice",
2039
+ });
2040
+ }
2041
+ if (choice <= input.organizations.length) {
2042
+ return { organizationId: input.organizations[choice - 1].id };
2043
+ }
2044
+ const newName = await promptLine("New workspace name: ");
2045
+ if (!newName.trim()) {
2046
+ throw new CliError("Workspace name cannot be empty.", {
2047
+ code: "missing_org_name",
2048
+ });
2049
+ }
2050
+ return { newOrganizationName: newName.trim() };
1626
2051
  }
1627
2052
 
1628
2053
  function normalizeApiBase(input: string) {
@@ -1745,6 +2170,114 @@ async function billingTopup(runtime: Runtime) {
1745
2170
  print(json ? output : output.message);
1746
2171
  }
1747
2172
 
2173
+ /**
2174
+ * `dench upgrade` — turns a free CRM-only workspace into a paid Pro or
2175
+ * Max plan by handing the human a Stripe Checkout link. Mirrors the
2176
+ * `billing topup` flow (POST with the agent session as a Bearer
2177
+ * token, surface the returned URL) but hits the existing
2178
+ * `/api/stripe/create-checkout` endpoint that the marketing pricing
2179
+ * page already uses.
2180
+ *
2181
+ * Two flag-driven paths:
2182
+ *
2183
+ * --tier pro | max Which paid tier to target. Defaults to
2184
+ * "pro" (the most common upgrade from
2185
+ * free).
2186
+ * --cycle monthly | yearly Billing cycle. Defaults to "monthly".
2187
+ *
2188
+ * The endpoint can also return `{ updated: true }` for in-place
2189
+ * Pro \u2192 Max upgrades on an already-subscribed org (no Checkout URL
2190
+ * needed in that case). We surface either outcome cleanly.
2191
+ */
2192
+ async function denchUpgrade(runtime: Runtime) {
2193
+ const sessionRuntime = requireSessionRuntime(runtime, "dench upgrade");
2194
+ const tierFlag = option("--tier")?.trim().toLowerCase() || "pro";
2195
+ const cycleFlag =
2196
+ option("--cycle")?.trim().toLowerCase() ||
2197
+ option("--billing-cycle")?.trim().toLowerCase() ||
2198
+ "monthly";
2199
+ if (tierFlag !== "pro" && tierFlag !== "max") {
2200
+ throw new CliError("--tier must be 'pro' or 'max'.", {
2201
+ code: "invalid_tier",
2202
+ });
2203
+ }
2204
+ if (cycleFlag !== "monthly" && cycleFlag !== "yearly") {
2205
+ throw new CliError("--cycle must be 'monthly' or 'yearly'.", {
2206
+ code: "invalid_cycle",
2207
+ });
2208
+ }
2209
+ // Internal tier strings still use the legacy "desktop" / "cloud"
2210
+ // names from before the Daytona rewrite. Map the CLI's
2211
+ // customer-facing flag onto them so the existing checkout endpoint
2212
+ // doesn't need to learn a new vocabulary.
2213
+ const internalTier = tierFlag === "pro" ? "desktop" : "cloud";
2214
+
2215
+ const response = await fetch(
2216
+ `${sessionRuntime.host.replace(/\/+$/, "")}/api/stripe/create-checkout`,
2217
+ {
2218
+ method: "POST",
2219
+ headers: {
2220
+ accept: "application/json",
2221
+ "content-type": "application/json",
2222
+ authorization: `Bearer ${sessionRuntime.sessionToken}`,
2223
+ },
2224
+ body: JSON.stringify({
2225
+ tier: internalTier,
2226
+ billingCycle: cycleFlag,
2227
+ requestId: crypto.randomUUID(),
2228
+ }),
2229
+ },
2230
+ );
2231
+ const payload = await parseResponseBody(response);
2232
+ if (!response.ok) {
2233
+ const errorRecord = asRecord(payload);
2234
+ throw new CliError(
2235
+ stringField(errorRecord, "error", "message") ??
2236
+ `Could not create upgrade checkout (${response.status})`,
2237
+ {
2238
+ code: stringField(errorRecord, "code") ?? "upgrade_failed",
2239
+ status: response.status,
2240
+ },
2241
+ );
2242
+ }
2243
+ const record = asRecord(payload);
2244
+ const url = stringField(record, "url");
2245
+ const updatedInPlace = record?.updated === true;
2246
+
2247
+ if (updatedInPlace) {
2248
+ const message = `Upgraded to Dench ${tierFlag === "pro" ? "Pro" : "Max"} in place — no Stripe Checkout needed.`;
2249
+ print(
2250
+ json ? { ok: true, updated: true, tier: tierFlag, message } : message,
2251
+ );
2252
+ return;
2253
+ }
2254
+
2255
+ const openResult = url
2256
+ ? await openUrl(url, {
2257
+ noOpen: hasFlag("--no-open"),
2258
+ json,
2259
+ env: process.env,
2260
+ })
2261
+ : ({ status: "skipped", reason: "missing-url" } as const);
2262
+ const openMessage =
2263
+ url && openResult.status === "opened"
2264
+ ? `Attempted to open Stripe Checkout in your browser. If it did not open, use this link: ${url}`
2265
+ : url && openResult.status === "failed"
2266
+ ? `Could not open browser automatically. Open this Stripe Checkout link: ${url}`
2267
+ : url
2268
+ ? `Open this Stripe Checkout link: ${url}`
2269
+ : "Stripe Checkout link created.";
2270
+ const output = {
2271
+ ok: true,
2272
+ tier: tierFlag,
2273
+ billingCycle: cycleFlag,
2274
+ url,
2275
+ openResult,
2276
+ message: openMessage,
2277
+ };
2278
+ print(json ? output : output.message);
2279
+ }
2280
+
1748
2281
  function hasDevEnv() {
1749
2282
  return Boolean(
1750
2283
  process.env.NEXT_PUBLIC_CONVEX_URL &&
@@ -1794,11 +2327,11 @@ function resolveApiHost(fallbackHost: string): string {
1794
2327
  }
1795
2328
 
1796
2329
  function loginRequiredError(command = "this command") {
1797
- return new CliError(`${command} requires dench login`, {
2330
+ return new CliError(`${command} requires dench signin`, {
1798
2331
  code: "login_required",
1799
2332
  nextActions: [
1800
2333
  "Set DENCH_API_KEY (and CONVEX_URL) in your env if running inside a sandbox or CI.",
1801
- 'Or run dench onboard --kind <kind> --name "AI Agent - Project" to mint an agent session.',
2334
+ 'Or run dench signin --kind <kind> --name "AI Agent - Project" to mint an agent session.',
1802
2335
  "If already approved, run dench sessions --json, then dench use <session-key-or-workspace-slug>.",
1803
2336
  ],
1804
2337
  });
@@ -1815,7 +2348,7 @@ function missingDevEnvError() {
1815
2348
  missingEnv: required.filter((name) => !process.env[name]?.trim()),
1816
2349
  nextActions: [
1817
2350
  "Set NEXT_PUBLIC_CONVEX_URL, DENCH_WORKSPACE, and DENCH_DEV_AGENT_KEY.",
1818
- "Or remove --dev and run dench onboard to use a real agent session.",
2351
+ "Or remove --dev and run dench signin to use a real agent session.",
1819
2352
  ],
1820
2353
  });
1821
2354
  }
@@ -1871,7 +2404,7 @@ async function getRuntime() {
1871
2404
  // src/lib/secrets/sandbox-tokens.ts) is enough to drive every
1872
2405
  // command that goes through `requireCrmAccess` server-side. We only
1873
2406
  // fall into this branch when no stored session is present, so
1874
- // explicit `dench login` setups keep their existing behavior.
2407
+ // explicit `dench signin` setups keep their existing behavior.
1875
2408
  const apiKey = process.env.DENCH_API_KEY?.trim();
1876
2409
  const convexUrl = resolveApiKeyConvexUrl();
1877
2410
  if (apiKey && convexUrl) {
@@ -1965,6 +2498,103 @@ function requireAuthenticatedRuntime(
1965
2498
  throw loginRequiredError(command);
1966
2499
  }
1967
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
+
1968
2598
  function encodeDevCrmSessionToken(args: {
1969
2599
  workspaceSlug: string;
1970
2600
  devKey: string;
@@ -1978,7 +2608,7 @@ function agentSessionRequiredError(command: string) {
1978
2608
  {
1979
2609
  code: "agent_session_required",
1980
2610
  nextActions: [
1981
- 'Run dench onboard --kind <kind> --name "AI Agent - Project" to mint an agent session for this command.',
2611
+ 'Run dench signin --kind <kind> --name "AI Agent - Project" to mint an agent session for this command.',
1982
2612
  "Or run the equivalent action via the workspace UI / dench crm if it is org-level (e.g. CRM data, files).",
1983
2613
  ],
1984
2614
  },
@@ -2079,7 +2709,7 @@ function workspaceUrls(host: string, workspaceSlug: string | undefined) {
2079
2709
 
2080
2710
  function nextContextCommands() {
2081
2711
  return [
2082
- "dench onboard --json",
2712
+ "dench context --json",
2083
2713
  "dench tasks --json",
2084
2714
  'dench log "Brief progress update"',
2085
2715
  'dench tool search "what you need" --toolkit <app>',
@@ -2087,12 +2717,24 @@ function nextContextCommands() {
2087
2717
  ];
2088
2718
  }
2089
2719
 
2090
- async function connectedAppsContext(_runtime: Runtime) {
2091
- // Reach the Dench gateway directly with DENCH_API_KEY (mirrors the
2092
- // denchclaw pattern). This works for every Runtime mode — session,
2093
- // apiKey, dev — because the gateway only cares about the bearer key,
2094
- // not which Convex session is calling.
2095
- 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
+ }
2096
2738
  }
2097
2739
 
2098
2740
  async function buildContext(runtime: Runtime) {
@@ -2135,36 +2777,6 @@ async function buildContext(runtime: Runtime) {
2135
2777
  };
2136
2778
  }
2137
2779
 
2138
- async function runOnboarding() {
2139
- let runtime: Runtime;
2140
- try {
2141
- runtime = await getRuntime();
2142
- } catch (error) {
2143
- const message = error instanceof Error ? error.message : String(error);
2144
- const isLoginRequired =
2145
- error instanceof CliError && error.payload.code === "login_required";
2146
- if (!isLoginRequired && !message.includes("Not logged in")) {
2147
- throw error;
2148
- }
2149
- logHuman("No Dench session found. Starting login first.");
2150
- await login();
2151
- runtime = await getRuntime();
2152
- }
2153
-
2154
- const context = await buildContext(runtime);
2155
- print(
2156
- json
2157
- ? { ok: true, context }
2158
- : [
2159
- "Dench onboarding complete.",
2160
- "",
2161
- formatContext(context),
2162
- "",
2163
- "Setup is complete when the workspace and agent above are correct.",
2164
- ].join("\n"),
2165
- );
2166
- }
2167
-
2168
2780
  function formatCount(value: unknown) {
2169
2781
  return typeof value === "number" ? String(value) : "0";
2170
2782
  }
@@ -2441,7 +3053,7 @@ async function defaultDevAgentId(
2441
3053
  const agent = data.agents[0];
2442
3054
  if (!agent) {
2443
3055
  throw new Error(
2444
- 'No agent registered yet. Run: dench register --name "AI Agent" --kind other --dev',
3056
+ 'No agent registered yet. Run: dench signin --kind <kind> --name "AI Agent - Project"',
2445
3057
  );
2446
3058
  }
2447
3059
  return agent._id;
@@ -2532,26 +3144,12 @@ function normalizeFilesPath(input: string): string {
2532
3144
  return cleaned;
2533
3145
  }
2534
3146
 
2535
- function filesApiKey(runtime: Runtime): string | undefined {
2536
- if (runtime.mode === "apiKey") return runtime.sessionToken;
2537
- return process.env.DENCH_API_KEY?.trim() || undefined;
2538
- }
2539
-
2540
- function requireFilesApiKey(runtime: Runtime, command: string): string {
2541
- const apiKey = filesApiKey(runtime);
2542
- if (!apiKey) {
2543
- throw new CliError(
2544
- `${command} needs DENCH_API_KEY (or a sandbox-baked api-key session). It bypasses the user-session auth path.`,
2545
- {
2546
- code: "files_api_key_required",
2547
- nextActions: [
2548
- "Run inside a Dench sandbox where DENCH_API_KEY is baked in.",
2549
- "Or export DENCH_API_KEY=<key> first (see dench login output).",
2550
- ],
2551
- },
2552
- );
2553
- }
2554
- 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;
2555
3153
  }
2556
3154
 
2557
3155
  type FileTreeRow = {
@@ -2651,7 +3249,7 @@ async function uploadConvexFileBytes(
2651
3249
 
2652
3250
  async function runFilesLs() {
2653
3251
  const runtime = await getRuntime();
2654
- const apiKey = requireFilesApiKey(runtime, "dench files ls");
3252
+ const apiKey = await requireFilesApiKey(runtime, "dench files ls");
2655
3253
  const target = normalizeFilesPath(positional(2) ?? "/");
2656
3254
  const recursive = hasFlag("--recursive") || hasFlag("-r");
2657
3255
  const rows = recursive
@@ -2688,7 +3286,7 @@ async function runFilesLs() {
2688
3286
 
2689
3287
  async function runFilesMv() {
2690
3288
  const runtime = await getRuntime();
2691
- const apiKey = requireFilesApiKey(runtime, "dench files mv");
3289
+ const apiKey = await requireFilesApiKey(runtime, "dench files mv");
2692
3290
  const fromRaw = positional(2);
2693
3291
  const toRaw = positional(3);
2694
3292
  if (!fromRaw || !toRaw) {
@@ -2760,7 +3358,7 @@ async function runFilesMv() {
2760
3358
 
2761
3359
  async function runFilesRm() {
2762
3360
  const runtime = await getRuntime();
2763
- const apiKey = requireFilesApiKey(runtime, "dench files rm");
3361
+ const apiKey = await requireFilesApiKey(runtime, "dench files rm");
2764
3362
  const targetRaw = positional(2);
2765
3363
  if (!targetRaw) {
2766
3364
  throw new CliError("Usage: dench files rm <path> [--recursive]", {
@@ -2947,7 +3545,7 @@ async function* walkLocalFiles(
2947
3545
 
2948
3546
  async function runStage() {
2949
3547
  const runtime = await getRuntime();
2950
- const apiKey = requireFilesApiKey(runtime, "dench stage");
3548
+ const apiKey = await requireFilesApiKey(runtime, "dench stage");
2951
3549
  const localRaw = positional(1);
2952
3550
  const destRaw = positional(2);
2953
3551
  if (!localRaw) {
@@ -3182,21 +3780,40 @@ async function main() {
3182
3780
  return;
3183
3781
  }
3184
3782
 
3185
- if (command === "login" && hasFlag("--help")) {
3186
- loginHelp();
3187
- return;
3188
- }
3783
+ const isDeprecatedAuthCommand =
3784
+ command === "login" ||
3785
+ command === "onboard" ||
3786
+ command === "setup" ||
3787
+ command === "what-can-i-do" ||
3788
+ command === "register";
3189
3789
 
3190
3790
  if (
3191
- (command === "onboard" ||
3192
- command === "setup" ||
3193
- command === "what-can-i-do") &&
3791
+ (command === "signin" || isDeprecatedAuthCommand) &&
3194
3792
  (subcommand === "help" || hasFlag("--help"))
3195
3793
  ) {
3196
- onboardHelp();
3794
+ signinHelp();
3197
3795
  return;
3198
3796
  }
3199
3797
 
3798
+ // `dench login`, `dench onboard`, `dench setup`, `dench what-can-i-do`,
3799
+ // and `dench register` were removed in @dench.com/cli v2. Surface a
3800
+ // clean redirect to the new unified surface so old `npx` caches and
3801
+ // pre-existing agent prompts get a one-line nudge instead of an
3802
+ // opaque "Unknown command" error.
3803
+ if (isDeprecatedAuthCommand) {
3804
+ throw new CliError(
3805
+ `\`dench ${command}\` was removed in @dench.com/cli v2. Run \`dench signin\` instead.`,
3806
+ {
3807
+ code: "deprecated_command",
3808
+ nextActions: [
3809
+ "Run `dench signin` to connect this agent to an existing workspace.",
3810
+ 'Run `dench signin --new-workspace --org-name "<name>"` to create a free CRM-only workspace.',
3811
+ "Upgrade the CLI globally with `npm install -g @dench.com/cli@latest`.",
3812
+ ],
3813
+ },
3814
+ );
3815
+ }
3816
+
3200
3817
  if (command === "context" && hasFlag("--help")) {
3201
3818
  contextHelp();
3202
3819
  return;
@@ -3233,10 +3850,7 @@ async function main() {
3233
3850
  return;
3234
3851
  }
3235
3852
 
3236
- if (
3237
- command === "backend" &&
3238
- (subcommand === "help" || hasFlag("--help"))
3239
- ) {
3853
+ if (command === "backend" && (subcommand === "help" || hasFlag("--help"))) {
3240
3854
  backendHelp();
3241
3855
  return;
3242
3856
  }
@@ -3258,23 +3872,43 @@ async function main() {
3258
3872
  const { runCrmCommand } = await import("./crm");
3259
3873
  const runtime = await getRuntime();
3260
3874
  // CRM is org-scoped — accept either an agent session token
3261
- // (`dch_agent_*` from `dench login`) or a unified Dench API key
3875
+ // (`dch_agent_*` from `dench signin`) or a unified Dench API key
3262
3876
  // (`DENCH_API_KEY` baked into sandbox envVars). Server-side
3263
3877
  // `requireCrmAccess` (convex/lib/crm/access.ts) dispatches on the
3264
3878
  // bearer prefix and resolves the org for both.
3265
3879
  if (runtime.mode === "dev") {
3880
+ const gatewayAuth =
3881
+ subArgs[0] === "enrich"
3882
+ ? await resolveGatewayCommandAuth(runtime, "dench crm enrich")
3883
+ : undefined;
3266
3884
  await runCrmCommand({
3267
3885
  convex: runtime.client,
3268
3886
  args: subArgs,
3269
3887
  sessionToken: encodeDevCrmSessionToken(runtime.workspaceArgs),
3888
+ enrichmentGateway: gatewayAuth
3889
+ ? {
3890
+ apiKey: gatewayAuth.bearerToken,
3891
+ baseUrl: gatewayAuth.gatewayBaseUrl,
3892
+ }
3893
+ : undefined,
3270
3894
  });
3271
3895
  return;
3272
3896
  }
3273
3897
  const authedRuntime = requireAuthenticatedRuntime(runtime, "dench crm");
3898
+ const gatewayAuth =
3899
+ subArgs[0] === "enrich"
3900
+ ? await resolveGatewayCommandAuth(authedRuntime, "dench crm enrich")
3901
+ : undefined;
3274
3902
  await runCrmCommand({
3275
3903
  convex: authedRuntime.client,
3276
3904
  args: subArgs,
3277
3905
  sessionToken: authedRuntime.sessionToken,
3906
+ enrichmentGateway: gatewayAuth
3907
+ ? {
3908
+ apiKey: gatewayAuth.bearerToken,
3909
+ baseUrl: gatewayAuth.gatewayBaseUrl,
3910
+ }
3911
+ : undefined,
3278
3912
  });
3279
3913
  return;
3280
3914
  }
@@ -3307,10 +3941,7 @@ async function main() {
3307
3941
  });
3308
3942
  return;
3309
3943
  }
3310
- const authedRuntime = requireAuthenticatedRuntime(
3311
- runtime,
3312
- "dench members",
3313
- );
3944
+ const authedRuntime = requireAuthenticatedRuntime(runtime, "dench members");
3314
3945
  await runMembersCommand({
3315
3946
  convex: authedRuntime.client,
3316
3947
  args: subArgs,
@@ -3335,44 +3966,76 @@ async function main() {
3335
3966
  }
3336
3967
 
3337
3968
  if (command === "search") {
3338
- // `dench search` talks straight to the gateway over HTTP using
3339
- // DENCH_API_KEY; it deliberately bypasses requireSessionRuntime so
3340
- // it works in any sandbox / CI environment that has the key.
3341
3969
  const subArgs = args.slice(args.indexOf("search") + 1);
3342
3970
  const filteredSubArgs = subArgs.filter((a) => a !== "--json");
3343
3971
  const { runSearchCommand } = await import("./search");
3344
- 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
+ });
3345
3988
  return;
3346
3989
  }
3347
3990
 
3348
3991
  if (command === "image") {
3349
- // `dench image generate|edit` mirrors `dench search`: straight HTTP
3350
- // to the gateway with DENCH_API_KEY, no Convex session required.
3351
- // The gateway writes the bytes to Convex Storage on success; we
3352
- // also drop them on local disk so the next bash command sees them.
3353
3992
  const subArgs = args.slice(args.indexOf("image") + 1);
3354
3993
  const filteredSubArgs = subArgs.filter((a) => a !== "--json");
3355
3994
  const { runImageCommand } = await import("./image");
3356
- 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
+ });
3357
4008
  return;
3358
4009
  }
3359
4010
 
3360
4011
  if (command === "apps") {
3361
- // `dench apps` is an alias for `dench tool status`. Both speak
3362
- // straight HTTP to the Dench Cloud Gateway with DENCH_API_KEY —
3363
- // mirrors the denchclaw pattern. No Convex session involved.
3364
- 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
+ });
3365
4020
  return;
3366
4021
  }
3367
4022
 
3368
4023
  if (command === "tool") {
3369
- // `dench tool {status,connect,search,run,disconnect}` is the model-facing
3370
- // composio surface and only needs DENCH_API_KEY (Bearer) on the
3371
- // gateway. Bypasses requireSessionRuntime entirely so it works in
3372
- // every sandbox / API-key context.
3373
4024
  const subArgs = stripRuntimeFlags(args.slice(args.indexOf("tool") + 1));
3374
4025
  const filteredSubArgs = subArgs.filter((a) => a !== "--json");
3375
- 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
+ });
3376
4039
  return;
3377
4040
  }
3378
4041
 
@@ -3485,7 +4148,7 @@ async function main() {
3485
4148
  const { runCronCommand } = await import("./cron");
3486
4149
  const runtime = await getRuntime();
3487
4150
  // Cron CRUD is org-scoped — accept either an agent session token
3488
- // (`dch_agent_*` from `dench login`) or a unified Dench API key
4151
+ // (`dch_agent_*` from `dench signin`) or a unified Dench API key
3489
4152
  // (`DENCH_API_KEY` baked into sandbox envVars). Server-side
3490
4153
  // `requireCronAccess` (convex/lib/cronAccess.ts) dispatches on the
3491
4154
  // bearer prefix and resolves the org for both. The Bearer token
@@ -3508,7 +4171,7 @@ async function main() {
3508
4171
  // Dispatch all `dench identity / organisation / user / tools / mem /
3509
4172
  // heartbeat / bootstrap / model / daily` traffic through the shared
3510
4173
  // agent-config CLI module. Each command requires the same auth shape
3511
- // as `dench cron` (Bearer DENCH_API_KEY or `dench login` session
4174
+ // as `dench cron` (Bearer DENCH_API_KEY or `dench signin` session
3512
4175
  // token) so server-side `requireCronAccess` accepts both.
3513
4176
  const AGENT_CONFIG_COMMANDS = new Set([
3514
4177
  "identity",
@@ -3582,17 +4245,8 @@ async function main() {
3582
4245
  }
3583
4246
  }
3584
4247
 
3585
- if (command === "login") {
3586
- await login();
3587
- return;
3588
- }
3589
-
3590
- if (
3591
- command === "onboard" ||
3592
- command === "setup" ||
3593
- command === "what-can-i-do"
3594
- ) {
3595
- await runOnboarding();
4248
+ if (command === "signin") {
4249
+ await signin();
3596
4250
  return;
3597
4251
  }
3598
4252
 
@@ -3631,6 +4285,11 @@ async function main() {
3631
4285
  return;
3632
4286
  }
3633
4287
 
4288
+ if (command === "upgrade") {
4289
+ await denchUpgrade(runtime);
4290
+ return;
4291
+ }
4292
+
3634
4293
  if (command === "context") {
3635
4294
  const context = await buildContext(runtime);
3636
4295
  print(json ? context : formatContext(context));
@@ -3828,24 +4487,6 @@ async function main() {
3828
4487
  return;
3829
4488
  }
3830
4489
 
3831
- if (command === "register") {
3832
- if (runtime.mode !== "dev") {
3833
- throw new Error(
3834
- "register is only needed in dev mode. Real sessions use dench login.",
3835
- );
3836
- }
3837
- const agentId = await runtime.client.mutation(
3838
- api.functions.agentWorkspace.devRegisterAgent,
3839
- {
3840
- ...runtime.workspaceArgs,
3841
- name: option("--name") ?? "AI Agent",
3842
- kind: normalizeAgentKind(option("--kind")),
3843
- },
3844
- );
3845
- print({ ok: true, agentId });
3846
- return;
3847
- }
3848
-
3849
4490
  if (command === "project" && subcommand === "create") {
3850
4491
  if (runtime.mode !== "dev") {
3851
4492
  throw new Error(