@bankr/cli 0.3.35 → 0.3.38

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
@@ -58,6 +58,8 @@ bankr whoami
58
58
  bankr logout
59
59
  ```
60
60
 
61
+ Accounts with two-factor authentication enabled get a browser step during `bankr login email`: the CLI prints (and, when interactive, opens) an approval link on bankr.bot, you verify with your passkey there, and the CLI continues once it sees the approval. The link is valid for five minutes; in a headless run, pass it along to whoever holds the passkey. The approval also covers the rest of that login for five minutes, so if you linger on the key-name prompts past that you'll get a second link. If it expires, or you'd rather skip the hop, generate a key at [bankr.bot/api-keys](https://bankr.bot/api-keys) and use `bankr login --api-key`.
62
+
61
63
  ### Prompts
62
64
 
63
65
  ```bash
@@ -94,9 +96,11 @@ bankr launch --name "TESTCOIN" --image "https://example.com/img.png" \
94
96
  # Quick launch (name only, skip confirmation)
95
97
  bankr launch --name "TESTCOIN" -y
96
98
 
97
- # Pair the pool with a tokenized stock instead of WETH (Robinhood Chain or Base)
99
+ # Pair the pool with a tokenized stock or another listed quote token instead of WETH (Robinhood Chain or Base)
98
100
  bankr launch quotes --chain robinhood # list the quote tokens you can pair with
99
101
  bankr launch --name "TESTCOIN" --chain robinhood --quote TSLA -y
102
+ bankr launch --name "TESTCOIN" --chain robinhood --quote musebook -y
103
+ bankr launch --name "TESTCOIN" --chain robinhood --quote BNKR -y # the Robinhood Chain BNKR contract
100
104
 
101
105
  # Fee recipient types: x, farcaster, ens, wallet
102
106
  bankr launch --name "TESTCOIN" --fee "vitalik.eth" --fee-type ens -y
@@ -178,12 +182,13 @@ Credentials are stored in `~/.bankr/config.json`.
178
182
 
179
183
  Environment variables override stored values:
180
184
 
181
- | Variable | Description |
182
- | --------------- | ---------------------------------------------------------- |
183
- | `BANKR_API_KEY` | API key (overrides stored key) |
184
- | `BANKR_API_URL` | API URL (default: `https://api.bankr.bot`) |
185
- | `BANKR_LLM_KEY` | LLM gateway key (falls back to `BANKR_API_KEY` if not set) |
186
- | `BANKR_LLM_URL` | LLM gateway URL (default: `https://llm.bankr.bot`) |
185
+ | Variable | Description |
186
+ | --------------- | -------------------------------------------------------------------------- |
187
+ | `BANKR_API_KEY` | API key (overrides stored key) |
188
+ | `BANKR_API_URL` | API URL (default: `https://api.bankr.bot`) |
189
+ | `BANKR_WEB_URL` | Web app URL for browser approval links (default: derived from the API URL) |
190
+ | `BANKR_LLM_KEY` | LLM gateway key (falls back to `BANKR_API_KEY` if not set) |
191
+ | `BANKR_LLM_URL` | LLM gateway URL (default: `https://llm.bankr.bot`) |
187
192
 
188
193
  ## API Key
189
194
 
package/dist/cli.js CHANGED
@@ -1,5 +1,4 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync } from "node:fs";
3
2
  import { Command } from "commander";
4
3
  import { input } from "@inquirer/prompts";
5
4
  import { balancesCommand } from "./commands/balances.js";
@@ -9,7 +8,7 @@ import { swapCommand } from "./commands/swap.js";
9
8
  import { skillsCommand } from "./commands/capabilities.js";
10
9
  import { cancelCommand } from "./commands/cancel.js";
11
10
  import { configGetCommand, configSetCommand } from "./commands/config.js";
12
- import { setConfigPath } from "./lib/config.js";
11
+ import { CLI_VERSION, setConfigPath } from "./lib/config.js";
13
12
  import { getErrorMessage } from "./lib/errors.js";
14
13
  import { passthroughArgs } from "./lib/passthrough.js";
15
14
  import { claudeCommand, creditsCommand, creditsAddCommand, creditsAutoCommand, modelsCommand, opencodeCommand, setupClaudeCodeCommand, setupCursorCommand, setupOpenclawCommand, setupOpenCodeCommand, } from "./commands/llm.js";
@@ -33,7 +32,6 @@ import { filesListCommand, filesUploadCommand, filesDownloadCommand, filesMkdirC
33
32
  import { clubStatusCommand, clubSignupCommand, clubCancelCommand, } from "./commands/club.js";
34
33
  import * as output from "./lib/output.js";
35
34
  import { checkForUpdate } from "./lib/updateCheck.js";
36
- const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
37
35
  /**
38
36
  * Read prompt text from stdin (piped) or interactively.
39
37
  * Bypasses shell expansion so dollar signs and special characters are preserved.
@@ -81,7 +79,7 @@ function colorizeHelp(text) {
81
79
  program
82
80
  .name("bankr")
83
81
  .description("Bankr AI agent CLI\n\n Docs: https://docs.bankr.bot\n Skills: https://docs.bankr.bot/skills/overview")
84
- .version(pkg.version, "-v, --version", "Show CLI version")
82
+ .version(CLI_VERSION, "-v, --version", "Show CLI version")
85
83
  .helpOption("-h, --help", "Display help")
86
84
  .helpCommand(false)
87
85
  .option("--config <path>", "Path to config file (default: ~/.bankr/config.json)")
@@ -126,8 +124,10 @@ loginCmd
126
124
  .option("--accept-terms", "Accept Terms of Service (skips prompt)")
127
125
  .option("--key-name <name>", "API key name (default: CLI)")
128
126
  .optionsGroup("API Permissions")
129
- .option("--read-write", "Disable read-only mode (allow transactions)")
127
+ .option("--read-only", "Restrict the API key to read operations")
128
+ .option("--read-write", "Allow transactions (the default for new keys)")
130
129
  .option("--no-wallet-api", "Disable Wallet API (enabled by default)")
130
+ .option("--no-agent-api", "Disable Agent API (enabled by default)")
131
131
  .option("--no-token-launch", "Disable Token Launch API (enabled by default)")
132
132
  .option("--llm", "Enable LLM gateway on the API key")
133
133
  .optionsGroup("Security")
@@ -142,8 +142,10 @@ loginCmd
142
142
  code: opts.code,
143
143
  acceptTerms: opts.acceptTerms,
144
144
  keyName: opts.keyName,
145
+ readOnly: opts.readOnly,
145
146
  readWrite: opts.readWrite,
146
147
  walletApi: opts.walletApi,
148
+ agentApi: opts.agentApi,
147
149
  tokenLaunch: opts.tokenLaunch,
148
150
  llm: opts.llm,
149
151
  allowedIps: opts.allowedIps,
@@ -158,6 +160,7 @@ loginCmd
158
160
  .optionsGroup("API Permissions")
159
161
  .option("--read-write", "Disable read-only mode (allow transactions)")
160
162
  .option("--no-wallet-api", "Disable Wallet API (enabled by default)")
163
+ .option("--no-agent-api", "Disable Agent API (enabled by default)")
161
164
  .option("--no-token-launch", "Disable Token Launch API (enabled by default)")
162
165
  .optionsGroup("Security")
163
166
  .option("--allowed-ips <ips>", "Comma-separated IP/CIDR allowlist, e.g. 1.2.3.4,10.0.0.0/24")
@@ -171,6 +174,7 @@ loginCmd
171
174
  keyName: opts.keyName,
172
175
  readWrite: opts.readWrite,
173
176
  walletApi: opts.walletApi,
177
+ agentApi: opts.agentApi,
174
178
  tokenLaunch: opts.tokenLaunch,
175
179
  allowedIps: opts.allowedIps,
176
180
  allowedRecipients: opts.allowedRecipients,
@@ -1078,7 +1082,7 @@ async function main() {
1078
1082
  }
1079
1083
  output.fatal(getErrorMessage(err));
1080
1084
  }
1081
- await checkForUpdate(pkg.version);
1085
+ await checkForUpdate(CLI_VERSION);
1082
1086
  }
1083
1087
  main();
1084
1088
  //# sourceMappingURL=cli.js.map
@@ -869,10 +869,10 @@ export async function modelsCommand(opts = {}) {
869
869
  output.dim(` Gateway: ${getLlmUrl()}`);
870
870
  output.dim(` ${shown.length} model${shown.length === 1 ? "" : "s"}${view?.countSuffix ?? " available"}${live ? "" : " (cached)"}`);
871
871
  if (!view && shown.some((m) => m.private)) {
872
- output.dim(" Private (TEE) — append :private to the model id (e.g. glm-5.2:private). List: bankr llm models --private");
872
+ output.dim(" Private (TEE) — append :private to the model id (e.g. glm-5.3-flash:private). List: bankr llm models --private");
873
873
  }
874
874
  if (!view && shown.some((m) => m.zdr)) {
875
- output.dim(" Zero data retention — append :zdr to the model id (e.g. glm-5.2:zdr), or turn it on account-wide under Settings in the web terminal. List: bankr llm models --zdr");
875
+ output.dim(" Zero data retention — append :zdr to the model id (e.g. glm-5.3-flash:zdr), or turn it on account-wide under Settings in the web terminal. List: bankr llm models --zdr");
876
876
  }
877
877
  }
878
878
  /* ─────────────────────── bankr llm credits ─────────────────────────────────── */
@@ -1,3 +1,20 @@
1
+ /**
2
+ * Thrown when a login-gated call answers with the MFA step-up envelope. A
3
+ * passkey ceremony can't run in a terminal, so the email flow can't recover;
4
+ * the caller turns this into the browser-key guidance instead of the server's
5
+ * fallback text.
6
+ */
7
+ export declare class MfaStepUpError extends Error {
8
+ readonly context: string;
9
+ constructor(context: string);
10
+ }
11
+ /** Keyed on `code`, never on message text, so a server wording change can't
12
+ * silently turn this back into the opaque failure. */
13
+ export declare function mfaStepUpErrorFrom(body: unknown): MfaStepUpError | null;
14
+ /** The web app that fronts an API URL: `BANKR_WEB_URL` when set (local dev,
15
+ * staging), bankr.bot for the official API, else the API host with its
16
+ * `api.`/`api-` prefix dropped. */
17
+ export declare function deriveWebOrigin(apiUrl: string): string;
1
18
  /**
2
19
  * Both Privy credentials minted by the OTP exchange. The identity token alone
3
20
  * authenticates, but only the ACCESS token carries the session `sid` claim —
@@ -11,6 +28,39 @@ interface CliAuthTokens {
11
28
  /** The auth headers every authenticated CLI call sends — one place, so a new
12
29
  * endpoint can't silently omit the access token and go unrevocable again. */
13
30
  export declare function authHeaders(tokens: CliAuthTokens): Record<string, string>;
31
+ /**
32
+ * Maps the permission opt-out flags to the create-key body. Omitted fields
33
+ * are dropped by JSON.stringify so the server's API_KEY_CREATE_DEFAULTS
34
+ * decide them; only an explicit flag sends a value.
35
+ */
36
+ export declare function resolveKeyPermissionOverrides(opts: {
37
+ readOnly?: boolean;
38
+ readWrite?: boolean;
39
+ agentApi?: boolean;
40
+ }): {
41
+ readOnly?: boolean;
42
+ agentApiEnabled?: boolean;
43
+ };
44
+ declare const CHALLENGE_STATUSES: readonly ["pending", "verified", "expired", "abandoned", "failed"];
45
+ export type ChallengeStatus = (typeof CHALLENGE_STATUSES)[number];
46
+ /**
47
+ * One poll's outcome from its HTTP status. Only a definitive refusal ends the
48
+ * wait: the gate (401, an API without this route yet), another wallet's token
49
+ * (403) or a gone challenge (404). Anything else — 429, 5xx, a dropped
50
+ * connection — is a blip, so the wait keeps polling as if still pending.
51
+ */
52
+ export declare function pollOutcome(httpStatus: number, body: unknown): ChallengeStatus;
53
+ /**
54
+ * Poll until the challenge leaves `pending` or the deadline passes: once at
55
+ * once, then every 2s for the first 30s, then every 5s. Pure over its
56
+ * collaborators so the loop is unit-testable without a network.
57
+ */
58
+ export declare function waitForChallengeApproval(deps: {
59
+ poll: () => Promise<ChallengeStatus>;
60
+ sleep: (ms: number) => Promise<void>;
61
+ deadlineMs: number;
62
+ now?: () => number;
63
+ }): Promise<ChallengeStatus>;
14
64
  export declare function loginCommand(opts: {
15
65
  apiUrl?: string;
16
66
  apiKey?: string;
@@ -18,8 +68,10 @@ export declare function loginCommand(opts: {
18
68
  code?: string;
19
69
  acceptTerms?: boolean;
20
70
  keyName?: string;
71
+ readOnly?: boolean;
21
72
  readWrite?: boolean;
22
73
  walletApi?: boolean;
74
+ agentApi?: boolean;
23
75
  tokenLaunch?: boolean;
24
76
  llm?: boolean;
25
77
  llmKey?: string;
@@ -8,12 +8,50 @@ import { getErrorMessage } from "../lib/errors.js";
8
8
  const DEFAULT_DASHBOARD_URL = "https://bankr.bot/api-keys";
9
9
  const MAX_OTP_ATTEMPTS = 3;
10
10
  const INVALID_CODE_STATUSES = [400, 401, 422];
11
+ /** The `code` the API's MFA gates send on every step-up 401 envelope
12
+ * (`sendStepUpRequired` in packages/api/src/middleware/mfaGate.ts). Duplicated
13
+ * here because the CLI can't import `@bankr/*` packages. */
14
+ const MFA_STEP_UP_CODE = "MFA_STEP_UP_REQUIRED";
15
+ /**
16
+ * Thrown when a login-gated call answers with the MFA step-up envelope. A
17
+ * passkey ceremony can't run in a terminal, so the email flow can't recover;
18
+ * the caller turns this into the browser-key guidance instead of the server's
19
+ * fallback text.
20
+ */
21
+ export class MfaStepUpError extends Error {
22
+ constructor(context) {
23
+ super("MFA step-up required");
24
+ this.context = context;
25
+ this.name = "MfaStepUpError";
26
+ }
27
+ }
28
+ /** Keyed on `code`, never on message text, so a server wording change can't
29
+ * silently turn this back into the opaque failure. */
30
+ export function mfaStepUpErrorFrom(body) {
31
+ if (!body || typeof body !== "object")
32
+ return null;
33
+ const { code, context } = body;
34
+ if (code !== MFA_STEP_UP_CODE)
35
+ return null;
36
+ return new MfaStepUpError(typeof context === "string" ? context : "unknown");
37
+ }
38
+ /** The web app that fronts an API URL: `BANKR_WEB_URL` when set (local dev,
39
+ * staging), bankr.bot for the official API, else the API host with its
40
+ * `api.`/`api-` prefix dropped. */
41
+ export function deriveWebOrigin(apiUrl) {
42
+ const override = process.env.BANKR_WEB_URL?.trim();
43
+ if (override)
44
+ return override.replace(/\/+$/, "");
45
+ if (apiUrl === DEFAULT_API_URL)
46
+ return "https://bankr.bot";
47
+ // Anchored to the host's start so a host merely containing "api." is intact.
48
+ return apiUrl.replace(/\/+$/, "").replace(/^(https?:\/\/)api[.-]/, "$1");
49
+ }
11
50
  function deriveDashboardUrl(apiUrl) {
12
51
  if (apiUrl === DEFAULT_API_URL) {
13
52
  return DEFAULT_DASHBOARD_URL;
14
53
  }
15
- return (apiUrl.replace(/\/+$/, "").replace("api.", "").replace("api-", "") +
16
- "/api-keys");
54
+ return `${deriveWebOrigin(apiUrl)}/api-keys`;
17
55
  }
18
56
  async function fetchPrivyConfig(apiUrl) {
19
57
  const res = await fetch(`${apiUrl}/cli/config`, {
@@ -52,6 +90,26 @@ export function authHeaders(tokens) {
52
90
  "x-access-token": tokens.accessToken,
53
91
  };
54
92
  }
93
+ /**
94
+ * Maps the permission opt-out flags to the create-key body. Omitted fields
95
+ * are dropped by JSON.stringify so the server's API_KEY_CREATE_DEFAULTS
96
+ * decide them; only an explicit flag sends a value.
97
+ */
98
+ export function resolveKeyPermissionOverrides(opts) {
99
+ if (opts.readOnly && opts.readWrite) {
100
+ throw new Error("--read-only and --read-write are mutually exclusive");
101
+ }
102
+ // Build conditionally: an own `undefined` key would clobber a value the
103
+ // caller set before spreading this in.
104
+ const overrides = {};
105
+ if (opts.readOnly)
106
+ overrides.readOnly = true;
107
+ else if (opts.readWrite)
108
+ overrides.readOnly = false;
109
+ if (opts.agentApi === false)
110
+ overrides.agentApiEnabled = false;
111
+ return overrides;
112
+ }
55
113
  async function verifyPrivyOtp(privyConfig, email, code) {
56
114
  const res = await fetch("https://auth.privy.io/api/v1/passwordless/authenticate", {
57
115
  method: "POST",
@@ -80,7 +138,10 @@ async function callGenerateWallet(apiUrl, tokens) {
80
138
  });
81
139
  const body = (await res.json());
82
140
  if (!res.ok) {
83
- throw new Error(body.message || body.error || `Wallet generation failed (${res.status})`);
141
+ throw (mfaStepUpErrorFrom(body) ??
142
+ new Error(body.message ||
143
+ body.error ||
144
+ `Wallet generation failed (${res.status})`));
84
145
  }
85
146
  return body;
86
147
  }
@@ -91,7 +152,8 @@ async function callAcceptTerms(apiUrl, tokens) {
91
152
  });
92
153
  if (!res.ok) {
93
154
  const body = (await res.json().catch(() => ({})));
94
- throw new Error(body.message || body.error || `Accept terms failed (${res.status})`);
155
+ throw (mfaStepUpErrorFrom(body) ??
156
+ new Error(body.message || body.error || `Accept terms failed (${res.status})`));
95
157
  }
96
158
  }
97
159
  async function callGenerateApiKey(apiUrl, tokens, opts) {
@@ -102,10 +164,115 @@ async function callGenerateApiKey(apiUrl, tokens, opts) {
102
164
  });
103
165
  const body = (await res.json());
104
166
  if (!res.ok) {
105
- throw new Error(body.message || body.error || `API key generation failed (${res.status})`);
167
+ throw (mfaStepUpErrorFrom(body) ??
168
+ new Error(body.message ||
169
+ body.error ||
170
+ `API key generation failed (${res.status})`));
106
171
  }
107
172
  return body;
108
173
  }
174
+ // ── Out-of-band MFA approval ─────────────────────────────────────────
175
+ // Mirrors `MFA_CHALLENGE_STATUSES` in @bankr/shared (the CLI can't import it).
176
+ const CHALLENGE_STATUSES = [
177
+ "pending",
178
+ "verified",
179
+ "expired",
180
+ "abandoned",
181
+ "failed",
182
+ ];
183
+ /** Narrow the status poll body; anything unrecognised reads as `failed`. */
184
+ function parseChallengeStatus(body) {
185
+ const status = body?.status;
186
+ return CHALLENGE_STATUSES.includes(status)
187
+ ? status
188
+ : "failed";
189
+ }
190
+ /**
191
+ * One poll's outcome from its HTTP status. Only a definitive refusal ends the
192
+ * wait: the gate (401, an API without this route yet), another wallet's token
193
+ * (403) or a gone challenge (404). Anything else — 429, 5xx, a dropped
194
+ * connection — is a blip, so the wait keeps polling as if still pending.
195
+ */
196
+ export function pollOutcome(httpStatus, body) {
197
+ if (httpStatus >= 200 && httpStatus < 300)
198
+ return parseChallengeStatus(body);
199
+ return [401, 403, 404].includes(httpStatus) ? "failed" : "pending";
200
+ }
201
+ /** Poll cadence: 2s while an approval is likely imminent, then 5s. */
202
+ const POLL_FAST_MS = 2000;
203
+ const POLL_SLOW_MS = 5000;
204
+ const POLL_FAST_WINDOW_MS = 30000;
205
+ /**
206
+ * Poll until the challenge leaves `pending` or the deadline passes: once at
207
+ * once, then every 2s for the first 30s, then every 5s. Pure over its
208
+ * collaborators so the loop is unit-testable without a network.
209
+ */
210
+ export async function waitForChallengeApproval(deps) {
211
+ const now = deps.now ?? Date.now;
212
+ const startedAt = now();
213
+ for (;;) {
214
+ const status = await deps.poll();
215
+ if (status !== "pending")
216
+ return status;
217
+ if (now() >= deps.deadlineMs)
218
+ return "expired";
219
+ await deps.sleep(now() - startedAt < POLL_FAST_WINDOW_MS ? POLL_FAST_MS : POLL_SLOW_MS);
220
+ }
221
+ }
222
+ async function createMfaChallenge(apiUrl, tokens, context) {
223
+ const res = await fetch(`${apiUrl}/user/mfa/challenge`, {
224
+ method: "POST",
225
+ headers: authHeaders(tokens),
226
+ body: JSON.stringify({ context }),
227
+ });
228
+ if (!res.ok) {
229
+ throw new Error(`Couldn't start passkey approval (${res.status})`);
230
+ }
231
+ return (await res.json());
232
+ }
233
+ async function readMfaChallengeStatus(apiUrl, tokens, token) {
234
+ try {
235
+ const res = await fetch(`${apiUrl}/user/mfa/challenge/${token}`, {
236
+ headers: authHeaders(tokens),
237
+ });
238
+ return pollOutcome(res.status, await res.json().catch(() => null));
239
+ }
240
+ catch {
241
+ return "pending";
242
+ }
243
+ }
244
+ /** Mirrors the server's challenge TTL; the deadline is kept on the local clock
245
+ * so a skewed machine can't expire the wait early (the poll reports a real
246
+ * server-side expiry as `expired` regardless). */
247
+ const CHALLENGE_TTL_MS = 5 * 60 * 1000;
248
+ /**
249
+ * The browser hop for an MFA-enabled account: create a challenge for THIS
250
+ * session, hand the user the confirm link, and wait for their passkey on the
251
+ * web to mint the grants. Resolves true once approved.
252
+ */
253
+ async function approveInBrowser(apiUrl, tokens) {
254
+ const { token } = await createMfaChallenge(apiUrl, tokens, "api-key:generate");
255
+ const url = `${deriveWebOrigin(apiUrl)}/mfa/confirm/${token}`;
256
+ output.blank();
257
+ output.warn("Two-factor authentication is enabled on this account.");
258
+ output.info("Approve this login with your passkey:");
259
+ output.brandBold(` ${url}`);
260
+ if (!output.isNonInteractive()) {
261
+ await open(url).catch(() => undefined);
262
+ }
263
+ const spin = output.spinner("Waiting for approval in your browser...");
264
+ const status = await waitForChallengeApproval({
265
+ poll: () => readMfaChallengeStatus(apiUrl, tokens, token),
266
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
267
+ deadlineMs: Date.now() + CHALLENGE_TTL_MS,
268
+ });
269
+ if (status === "verified") {
270
+ spin.succeed("Approved");
271
+ return true;
272
+ }
273
+ spin.fail(`Approval ${status}`);
274
+ return false;
275
+ }
109
276
  // ── OTP verification helpers ─────────────────────────────────────────
110
277
  async function fetchPrivyConfigOrFail(apiUrl) {
111
278
  const config = await fetchPrivyConfig(apiUrl).catch(() => null);
@@ -200,20 +367,57 @@ async function authenticateWithEmail(apiUrl, providedEmail, providedCode) {
200
367
  }
201
368
  output.fatal("Verification failed. Please try again.");
202
369
  }
370
+ /**
371
+ * Run a login-gated call; on the MFA step-up envelope, take the browser
372
+ * approval hop once and retry. A second refusal, or an approval that expires
373
+ * or fails, exits with the browser-key guidance.
374
+ */
375
+ async function runGatedStep(apiUrl, tokens, label, run) {
376
+ for (const retry of [false, true]) {
377
+ const spin = output.spinner(label);
378
+ try {
379
+ const result = await run();
380
+ spin.stop();
381
+ return result;
382
+ }
383
+ catch (err) {
384
+ spin.stop();
385
+ if (!(err instanceof MfaStepUpError) || retry) {
386
+ return failLoginStep(`${label} failed`, err, apiUrl);
387
+ }
388
+ }
389
+ const approved = await approveInBrowser(apiUrl, tokens).catch((err) => {
390
+ output.error(getErrorMessage(err));
391
+ return false;
392
+ });
393
+ if (!approved)
394
+ return exitWithApiKeyGuidance(apiUrl);
395
+ }
396
+ throw new Error("unreachable");
397
+ }
398
+ /** Exit with the generic epilogue, or the browser-key path on a step-up
399
+ * refusal the approval hop didn't clear. */
400
+ function failLoginStep(label, err, apiUrl) {
401
+ if (!(err instanceof MfaStepUpError)) {
402
+ return output.fatal(`${label}: ${getErrorMessage(err)}`);
403
+ }
404
+ return exitWithApiKeyGuidance(apiUrl);
405
+ }
406
+ /** The remediation is the point of this message, so every line stays on
407
+ * stderr with the headline: a scripted caller capturing stderr sees all of it. */
408
+ function exitWithApiKeyGuidance(apiUrl) {
409
+ output.error("Two-factor authentication is enabled on this account.");
410
+ output.errorDetail(" The passkey approval didn't complete.");
411
+ output.errorDetail(` Generate an API key at ${deriveDashboardUrl(apiUrl)} instead (verify with your passkey there), then run:`);
412
+ output.errorDetail(" bankr login --api-key bk_YOUR_KEY");
413
+ process.exit(1);
414
+ }
203
415
  // ── Email login flow ────────────────────────────────────────────────
204
416
  async function emailLoginFlow(apiUrl, opts) {
205
417
  // Step 1: Authenticate via email OTP
206
418
  const { tokens } = await authenticateWithEmail(apiUrl, opts.email, opts.code);
207
419
  // Step 2: Generate/resolve wallet
208
- const walletSpin = output.spinner("Setting up wallet...");
209
- let wallet;
210
- try {
211
- wallet = await callGenerateWallet(apiUrl, tokens);
212
- walletSpin.stop();
213
- }
214
- catch (err) {
215
- output.failWith(walletSpin, "Wallet setup failed", err);
216
- }
420
+ const wallet = await runGatedStep(apiUrl, tokens, "Setting up wallet...", () => callGenerateWallet(apiUrl, tokens));
217
421
  // Show wallet info
218
422
  output.blank();
219
423
  if (wallet.isNewUser) {
@@ -244,14 +448,8 @@ async function emailLoginFlow(apiUrl, opts) {
244
448
  if (!accepted) {
245
449
  output.fatal("Terms must be accepted to continue.");
246
450
  }
247
- const termsSpin = output.spinner("Accepting terms...");
248
- try {
249
- await callAcceptTerms(apiUrl, tokens);
250
- termsSpin.succeed("Terms accepted");
251
- }
252
- catch (err) {
253
- output.failWith(termsSpin, "Failed to accept terms", err);
254
- }
451
+ await runGatedStep(apiUrl, tokens, "Accepting terms...", () => callAcceptTerms(apiUrl, tokens));
452
+ output.success("Terms accepted");
255
453
  }
256
454
  // Step 4: Generate API key
257
455
  output.blank();
@@ -266,10 +464,12 @@ async function emailLoginFlow(apiUrl, opts) {
266
464
  validate: (v) => v.trim().length >= 3 ? true : "Name must be at least 3 characters.",
267
465
  }));
268
466
  // Fine-grained API flags. walletApi and tokenLaunch default to enabled.
269
- // --read-write only controls readOnly, not which APIs are enabled.
467
+ // --read-only/--read-write only control readOnly, not which APIs are enabled.
270
468
  const enableWallet = opts.walletApi ?? true;
271
- // Agent API must be enabled from the website (bankr.bot/api-keys), not the CLI.
272
- const enableAgent = false;
469
+ // readOnly and agentApiEnabled are only sent when a flag asks for it, so the
470
+ // server's API_KEY_CREATE_DEFAULTS (the bankr.bot/api-keys form) decide them.
471
+ // The --read-only/--read-write conflict was already rejected in loginCommand.
472
+ const overrides = resolveKeyPermissionOverrides(opts);
273
473
  const enableTokenLaunch = opts.tokenLaunch ?? true;
274
474
  const enableLlm = opts.llm ??
275
475
  (headless
@@ -279,32 +479,20 @@ async function emailLoginFlow(apiUrl, opts) {
279
479
  default: false,
280
480
  theme: output.bankrTheme,
281
481
  }));
282
- if (opts.readWrite && !enableWallet) {
283
- output.warn("--read-write has no effect when Wallet API is disabled");
284
- }
285
- const keySpin = output.spinner("Generating API key...");
286
- let apiKeyResult;
287
- try {
288
- apiKeyResult = await callGenerateApiKey(apiUrl, tokens, {
289
- name: keyName.trim(),
290
- walletApiEnabled: enableWallet,
291
- agentApiEnabled: enableAgent,
292
- readOnly: opts.readWrite ? false : undefined,
293
- tokenLaunchApiEnabled: enableTokenLaunch,
294
- llmGatewayEnabled: enableLlm,
295
- allowedIps: opts.allowedIps,
296
- allowedRecipients: opts.allowedRecipients,
297
- });
298
- keySpin.stop();
299
- }
300
- catch (err) {
301
- output.failWith(keySpin, "Failed to generate API key", err);
302
- }
482
+ const apiKeyResult = await runGatedStep(apiUrl, tokens, "Generating API key...", () => callGenerateApiKey(apiUrl, tokens, {
483
+ name: keyName.trim(),
484
+ walletApiEnabled: enableWallet,
485
+ ...overrides,
486
+ tokenLaunchApiEnabled: enableTokenLaunch,
487
+ llmGatewayEnabled: enableLlm,
488
+ allowedIps: opts.allowedIps,
489
+ allowedRecipients: opts.allowedRecipients,
490
+ }));
303
491
  // Show result
304
492
  output.blank();
305
493
  const features = [
306
494
  enableWallet && "Wallet",
307
- enableAgent && "Agent",
495
+ apiKeyResult.agentApiEnabled && "Agent",
308
496
  enableTokenLaunch && "Token Launch",
309
497
  enableLlm && "LLM",
310
498
  ].filter(Boolean);
@@ -321,7 +509,6 @@ async function emailLoginFlow(apiUrl, opts) {
321
509
  ];
322
510
  output.dim(` Recipients: ${parts.join(", ")}`);
323
511
  }
324
- output.dim(" Agent API: enable at bankr.bot/api-keys");
325
512
  output.dim(" Manage keys at bankr.bot/api-keys");
326
513
  return apiKeyResult.apiKey;
327
514
  }
@@ -372,7 +559,7 @@ async function siweLoginFlow(apiUrl, opts) {
372
559
  keyName: opts.keyName ?? `SIWE-${new Date().toISOString().slice(0, 10)}`,
373
560
  readOnly: opts.readOnly ?? false,
374
561
  walletApiEnabled: opts.walletApi ?? true,
375
- agentApiEnabled: false,
562
+ ...resolveKeyPermissionOverrides({ agentApi: opts.agentApi }),
376
563
  tokenLaunchApiEnabled: opts.tokenLaunch ?? true,
377
564
  allowedIps: opts.allowedIps,
378
565
  allowedRecipients: opts.allowedRecipients,
@@ -492,6 +679,11 @@ export async function loginCommand(opts) {
492
679
  if (opts.apiKey !== undefined && !opts.apiKey.trim()) {
493
680
  output.fatal("--api-key requires a non-empty value");
494
681
  }
682
+ // Reject conflicting permission flags before any network call, so a
683
+ // headless step-2 login doesn't burn its one-time code on a usage error.
684
+ if (opts.readOnly && opts.readWrite) {
685
+ output.fatal("--read-only and --read-write are mutually exclusive");
686
+ }
495
687
  // Non-interactive mode requires a headless flag combo. Without one, the
496
688
  // command would hang on a prompt — fail fast with the available paths.
497
689
  // Email step 1 (`login email <addr>` without --code) is also headless:
@@ -569,6 +761,7 @@ export async function loginCommand(opts) {
569
761
  keyName: opts.keyName,
570
762
  readOnly: !opts.readWrite,
571
763
  walletApi: opts.walletApi,
764
+ agentApi: opts.agentApi,
572
765
  tokenLaunch: opts.tokenLaunch,
573
766
  ...getParsedRestrictions(),
574
767
  });
@@ -1,7 +1,7 @@
1
- import { readFileSync } from "node:fs";
2
1
  import { execSync } from "node:child_process";
3
2
  import * as output from "../lib/output.js";
4
3
  import { getErrorMessage } from "../lib/errors.js";
4
+ import { CLI_VERSION } from "../lib/config.js";
5
5
  const PACKAGE_NAME = "@bankr/cli";
6
6
  /**
7
7
  * Compare two semver version strings.
@@ -31,8 +31,7 @@ async function fetchLatestVersion() {
31
31
  return data.version;
32
32
  }
33
33
  function getCurrentVersion() {
34
- const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf-8"));
35
- return pkg.version;
34
+ return CLI_VERSION;
36
35
  }
37
36
  /**
38
37
  * Detect which package manager installed the CLI globally.
package/dist/lib/api.d.ts CHANGED
@@ -224,7 +224,8 @@ export interface DeployTokenRequest {
224
224
  provider?: LaunchProvider;
225
225
  /** Quote the pool in a tokenized stock (Robinhood Chain / Base, Doppler). */
226
226
  pairedStockAddress?: string;
227
- /** Quote the pool in one of the fixed Base allowlist tokens (Doppler). */
227
+ /** Quote the pool in one of the chain's fixed Doppler allowlist tokens
228
+ * (Base or Robinhood Chain). */
228
229
  pairedTokenAddress?: string;
229
230
  /** Bankr Launch v3 options; `quoteAddress` picks the pool's quote token. */
230
231
  launchV3?: {
@@ -243,7 +244,7 @@ export interface LaunchQuoteTokenOption {
243
244
  deployField: LaunchQuoteTokenDeployField | null;
244
245
  /** Stocks only: advisory thin-pool verdict, null when unmeasured. */
245
246
  illiquid?: boolean | null;
246
- /** Base allowlist only: reviewed-liquidity gate. */
247
+ /** Doppler allowlist tokens only (pairedTokenAddress): reviewed-liquidity gate. */
247
248
  readiness?: "live" | "pending" | "unknown";
248
249
  }
249
250
  export interface LaunchQuoteTokensResponse {
@@ -11,7 +11,8 @@ export declare function setConfigPath(path: string): void;
11
11
  export declare function getConfigPath(): string;
12
12
  export declare const DEFAULT_API_URL = "https://api.bankr.bot";
13
13
  export declare const DEFAULT_LLM_URL = "https://llm.bankr.bot";
14
- export declare const CLI_USER_AGENT = "bankr-cli/0.1";
14
+ export declare const CLI_VERSION: string;
15
+ export declare const CLI_USER_AGENT: string;
15
16
  export declare function readConfig(): BankrConfig;
16
17
  export declare function writeConfig(config: BankrConfig): void;
17
18
  export declare function clearConfig(): void;
@@ -1,5 +1,5 @@
1
1
  import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync, } from "fs";
2
- import { homedir } from "os";
2
+ import { homedir, platform, release } from "os";
3
3
  import { dirname, join } from "path";
4
4
  let configPathOverride;
5
5
  const DEFAULT_CONFIG_FILE = join(homedir(), ".bankr", "config.json");
@@ -11,7 +11,23 @@ export function getConfigPath() {
11
11
  }
12
12
  export const DEFAULT_API_URL = "https://api.bankr.bot";
13
13
  export const DEFAULT_LLM_URL = "https://llm.bankr.bot";
14
- export const CLI_USER_AGENT = "bankr-cli/0.1";
14
+ export const CLI_VERSION = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf-8")).version;
15
+ /** Browser-style platform token so the API's shared user-agent parser (which
16
+ * clamps to known families) names the OS on an out-of-band approval screen
17
+ * as "Bankr CLI · macOS". Never the hostname or username. */
18
+ function platformToken() {
19
+ switch (platform()) {
20
+ case "darwin":
21
+ return "Macintosh";
22
+ case "win32":
23
+ return "Windows NT";
24
+ case "linux":
25
+ return "Linux";
26
+ default:
27
+ return platform();
28
+ }
29
+ }
30
+ export const CLI_USER_AGENT = `bankr-cli/${CLI_VERSION} (${platformToken()}; ${platform()} ${release()})`;
15
31
  function ensureConfigDir() {
16
32
  const configDir = dirname(getConfigPath());
17
33
  if (!existsSync(configDir)) {
@@ -22,6 +22,8 @@ export declare function brand(msg: string): void;
22
22
  export declare function brandBold(msg: string): void;
23
23
  export declare function success(msg: string): void;
24
24
  export declare function error(msg: string): void;
25
+ /** Dim follow-up line for an error, on stderr so it stays with the headline. */
26
+ export declare function errorDetail(msg: string): void;
25
27
  /** Collapses the error-then-exit epilogue every command repeats. */
26
28
  export declare function fatal(message: string): never;
27
29
  /** Same epilogue, for the spinner-driven variant that reports a caught error. */
@@ -40,6 +40,10 @@ export function success(msg) {
40
40
  export function error(msg) {
41
41
  console.error(chalk.red(`✗ ${msg}`));
42
42
  }
43
+ /** Dim follow-up line for an error, on stderr so it stays with the headline. */
44
+ export function errorDetail(msg) {
45
+ console.error(chalk.dim(msg));
46
+ }
43
47
  /** Collapses the error-then-exit epilogue every command repeats. */
44
48
  export function fatal(message) {
45
49
  error(message);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bankr/cli",
3
- "version": "0.3.35",
3
+ "version": "0.3.38",
4
4
  "description": "Official CLI for the Bankr AI agent platform",
5
5
  "type": "module",
6
6
  "bin": {