Skip to content

Commit 7107f1f

Browse files
zertsclaude
andcommitted
WLT-1728: add browser (OAuth) login to CLI
Add a modern browser login flow (like `claude` / `gh auth login`): the CLI starts a loopback server, opens dashboard.zerion.io/oauth/authorize, and captures the API key from the redirect. Exposed as a standalone `zerion login` command and as an interactive picker in `zerion init` (browser login first, then paste-a-key, then pay-per-call info). - cli/utils/api/oauth.js: loopback authorize flow (client_id=zerion-cli, 127.0.0.1 bind, state CSRF guard, 302 to /oauth/success, 5m timeout) - cli/utils/api/interactive-auth.js: shared auth-method picker - cli/utils/common/select.js: arrow-key single-select extracted from policy-picker.js (now reused there) - cli/utils/common/browser.js: shared openBrowser helper - ZERION_DASHBOARD_URL override for staging/local QA - README + --help + router usage updated Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 325093a commit 7107f1f

11 files changed

Lines changed: 490 additions & 115 deletions

File tree

README.md

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -153,19 +153,23 @@ Three options. The CLI auto-detects which is active.
153153

154154
Get a key at **[dashboard.zerion.io](https://dashboard.zerion.io)** — it's free and takes a minute. Keys begin with `zk_`.
155155

156+
The fastest way is **browser login** — like `claude` or `gh auth login`, it opens the dashboard, you approve, and the key is captured over a local loopback redirect and saved to config. The key never leaves your machine.
157+
156158
```bash
157-
export ZERION_API_KEY="zk_..."
159+
zerion login # pick browser login, paste a key, or pay-per-call
160+
zerion login --browser # go straight to browser authentication
158161
```
159162

160-
- HTTP Basic Auth
161-
- Required for analysis and trading commands (analysis can also use x402 / MPP pay-per-call instead — see options B and C)
162-
163-
You can also persist it via config:
163+
Or set / persist a key manually:
164164

165165
```bash
166-
zerion config set apiKey zk_...
166+
export ZERION_API_KEY="zk_..." # per-session
167+
zerion config set apiKey zk_... # persisted to ~/.zerion/config.json
167168
```
168169

170+
- HTTP Basic Auth
171+
- Required for analysis and trading commands (analysis can also use x402 / MPP pay-per-call instead — see options B and C)
172+
169173
### B) x402 pay-per-call
170174

171175
**No API key needed.** Pay $0.01 USDC per request via the [x402 protocol](https://www.x402.org/). Supports EVM (Base) and Solana.
@@ -314,7 +318,9 @@ Track wallets by name without exposing addresses in commands.
314318

315319
| Command | Description | Example |
316320
|---------|-------------|---------|
317-
| `zerion init` | One-shot onboarding — install CLI globally, configure API key, install agent skills | `zerion init` |
321+
| `zerion login` | Authenticate — browser (dashboard) login, paste an API key, or pay-per-call | `zerion login` |
322+
| `zerion login --browser` | Browser auth: opens dashboard.zerion.io, captures the key via loopback | `zerion login --browser` |
323+
| `zerion init` | One-shot onboarding — install CLI globally, authenticate, install agent skills | `zerion init` |
318324
| `zerion init -y --browser` | Non-interactive init that opens dashboard.zerion.io for the API key | `npx -y zerion-cli init -y --browser` |
319325
| `zerion setup skills` | Install Zerion agent skills into detected coding agents | `zerion setup skills` |
320326
| `zerion setup skills --agent claude-code` | Install into a specific agent | `zerion setup skills --agent claude-code` |

cli/commands/init.js

Lines changed: 10 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,20 @@ import { existsSync } from "node:fs";
33
import { homedir } from "node:os";
44
import { join } from "node:path";
55
import { print, printError } from "../utils/common/output.js";
6-
import { readSecret } from "../utils/common/prompt.js";
7-
import { getApiKey, setConfigValue } from "../utils/config.js";
6+
import { openBrowser } from "../utils/common/browser.js";
7+
import { DASHBOARD_URL } from "../utils/common/constants.js";
8+
import { getApiKey } from "../utils/config.js";
9+
import { runInteractiveAuth } from "../utils/api/interactive-auth.js";
810

911
const ZERION_AGENT_REPO = "zeriontech/zerion-ai";
10-
const DASHBOARD_URL = "https://dashboard.zerion.io";
1112

1213
const HELP = {
1314
usage: "zerion init [options]",
1415
description:
15-
"One-shot onboarding: install the CLI globally, configure an API key, and install Zerion agent skills into detected coding agents. By default the skills step is interactive — pick which skills you want.",
16+
"One-shot onboarding: install the CLI globally, authenticate (browser login, paste an API key, or pay-per-call), and install Zerion agent skills into detected coding agents. By default the auth and skills steps are interactive.",
1617
flags: {
1718
"--yes, -y": "Non-interactive — skip prompts and install ALL skills (otherwise user picks)",
18-
"--browser": "Open dashboard.zerion.io in the default browser during auth",
19+
"--browser": "With --yes: open dashboard.zerion.io to grab an API key (interactive runs offer browser login directly)",
1920
"--no-install": "Skip the global `npm install -g zerion-cli` step",
2021
"--no-auth": "Skip the API key configuration step",
2122
"--no-skills": "Skip the agent skills install step",
@@ -60,17 +61,6 @@ function detectAgent() {
6061
return null;
6162
}
6263

63-
function openBrowser(url) {
64-
const cmd =
65-
process.platform === "darwin"
66-
? "open"
67-
: process.platform === "win32"
68-
? "start"
69-
: "xdg-open";
70-
const args = process.platform === "win32" ? ["", url] : [url];
71-
spawnSync(cmd, args, { stdio: "ignore", shell: process.platform === "win32" });
72-
}
73-
7464
function ensureGlobalInstall() {
7565
// Two skip conditions:
7666
// 1. Running from a global install (not npx temp dir).
@@ -98,6 +88,8 @@ async function ensureApiKey({ yes, browser }) {
9888
if (yes) {
9989
log(` ! No API key configured. Get one at ${DASHBOARD_URL} and run:`);
10090
log(` zerion config set apiKey <your-key>`);
91+
log(` (or run 'zerion login' for browser authentication)`);
92+
if (browser) openBrowser(DASHBOARD_URL);
10193
return { ok: true, skipped: true, reason: "non_interactive" };
10294
}
10395

@@ -107,23 +99,8 @@ async function ensureApiKey({ yes, browser }) {
10799
return { ok: true, skipped: true, reason: "non_tty" };
108100
}
109101

110-
log(` Get an API key at ${DASHBOARD_URL}`);
111-
if (browser) {
112-
log(` Opening browser...`);
113-
openBrowser(DASHBOARD_URL);
114-
}
115-
116-
const key = await readSecret(" Paste your API key (or press Enter to skip): ", { mask: true });
117-
if (!key) {
118-
log(" ! Skipped — set later with: zerion config set apiKey <your-key>");
119-
return { ok: true, skipped: true, reason: "user_skipped" };
120-
}
121-
if (!key.startsWith("zk_")) {
122-
log(` ! Warning: keys typically start with "zk_". Saving anyway.`);
123-
}
124-
setConfigValue("apiKey", key);
125-
log(" ✓ API key saved to config");
126-
return { ok: true, skipped: false };
102+
// Interactive: browser login (default), paste a key, or pay-per-call.
103+
return runInteractiveAuth({ log, open: true });
127104
}
128105

129106
function installSkills({ agent, yes }) {

cli/commands/login.js

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { print, printError } from "../utils/common/output.js";
2+
import { getApiKey, setConfigValue } from "../utils/config.js";
3+
import { DASHBOARD_URL } from "../utils/common/constants.js";
4+
import { authenticateWithBrowser } from "../utils/api/oauth.js";
5+
import { runInteractiveAuth } from "../utils/api/interactive-auth.js";
6+
7+
const HELP = {
8+
usage: "zerion login [options]",
9+
description:
10+
"Authenticate the CLI with your Zerion API key. Opens the Zerion dashboard in your browser and captures the key via a local loopback redirect (like `claude` / `gh auth login`), then saves it to config. Interactive runs also offer pasting an existing key or pay-per-call.",
11+
flags: {
12+
"--browser": "Skip the picker and go straight to browser authentication",
13+
"--no-open": "Print the authorize URL but don't auto-open the browser",
14+
},
15+
examples: {
16+
"zerion login": "Interactive — pick browser auth, paste a key, or pay-per-call",
17+
"zerion login --browser": "Go straight to browser authentication",
18+
"zerion login --browser --no-open": "Browser auth on a remote/headless host — copy the printed URL",
19+
},
20+
};
21+
22+
function log(line = "") {
23+
process.stderr.write(line + "\n");
24+
}
25+
26+
export default async function login(args, flags) {
27+
if (flags.help || flags.h) {
28+
print(HELP);
29+
return;
30+
}
31+
32+
// parseFlags maps `--no-open` to `flags.open = false`.
33+
const open = flags.open !== false;
34+
const dashboardUrl = DASHBOARD_URL;
35+
36+
if (getApiKey()) {
37+
log(" ! An API key is already configured — continuing will replace it.");
38+
}
39+
40+
// --browser: skip the picker. Works without a TTY (approval happens in the
41+
// browser, out-of-band), so it's the headless-friendly path.
42+
if (flags.browser) {
43+
try {
44+
const { apiKey } = await authenticateWithBrowser({ dashboardUrl, open, log });
45+
setConfigValue("apiKey", apiKey);
46+
log(" ✓ Authenticated — API key saved to config");
47+
print({ ok: true, action: "login", method: "oauth" });
48+
} catch (err) {
49+
printError(err.code || "login_failed", err.message);
50+
process.exit(1);
51+
}
52+
return;
53+
}
54+
55+
if (!process.stdin.isTTY) {
56+
printError(
57+
"not_interactive",
58+
"zerion login needs an interactive terminal. Use --browser for headless browser auth, " +
59+
"or set ZERION_API_KEY / run: zerion config set apiKey <your-key>"
60+
);
61+
process.exit(1);
62+
}
63+
64+
const res = await runInteractiveAuth({ log, open, dashboardUrl });
65+
if (!res.ok) {
66+
printError(res.reason || "login_failed", res.message || "Login failed");
67+
process.exit(1);
68+
}
69+
70+
print({
71+
ok: true,
72+
action: "login",
73+
method: res.method ?? null,
74+
skipped: Boolean(res.skipped),
75+
...(res.reason ? { reason: res.reason } : {}),
76+
});
77+
}

cli/router.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,8 @@ function printUsage() {
111111
"--allowlist <addresses>": "Only allow interaction with listed addresses",
112112
},
113113
env: {
114-
"ZERION_API_KEY": "API key (get at dashboard.zerion.io)",
114+
"ZERION_API_KEY": "API key (get at dashboard.zerion.io, or run `zerion login`)",
115+
"ZERION_DASHBOARD_URL": "Override the dashboard base URL for `zerion login` / `init` (default: https://dashboard.zerion.io)",
115116
"WALLET_PRIVATE_KEY": "Private key for pay-per-call: 0x-hex → x402 on Base; base58 → x402 on Solana; 0x-hex → also works for MPP",
116117
"EVM_PRIVATE_KEY": "EVM private key for x402 on Base (overrides WALLET_PRIVATE_KEY for EVM)",
117118
"SOLANA_PRIVATE_KEY": "Solana private key for x402 on Solana (overrides WALLET_PRIVATE_KEY for Solana)",
@@ -129,7 +130,9 @@ function printUsage() {
129130
"slippage": "Default slippage % for swaps (default: 2)",
130131
},
131132
setup: {
132-
"init": "One-shot onboarding: install CLI globally, configure API key, install agent skills",
133+
"login": "Authenticate — browser (dashboard) login, paste an API key, or pay-per-call",
134+
"login --browser": "Browser authentication: opens dashboard.zerion.io, captures the key via loopback",
135+
"init": "One-shot onboarding: install CLI globally, authenticate, install agent skills",
133136
"init -y --browser": "Non-interactive init that opens dashboard.zerion.io for the API key",
134137
"setup skills": "Install Zerion agent skills via `npx skills add zeriontech/zerion-ai` (45+ hosts)",
135138
},

cli/utils/api/interactive-auth.js

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/**
2+
* Interactive auth-method picker shared by `zerion init` and `zerion login`.
3+
*
4+
* Presents the currently available ways to authenticate — browser login first
5+
* (the modern default, like Claude Code), then pasting an existing API key,
6+
* then a pointer to pay-per-call (x402 / MPP) which needs no API key. Requires
7+
* a raw-mode TTY; callers guard on `process.stdin.isTTY` before invoking.
8+
*/
9+
10+
import { selectOne, BACK } from "../common/select.js";
11+
import { readSecret } from "../common/prompt.js";
12+
import { setConfigValue } from "../config.js";
13+
import { DASHBOARD_URL } from "../common/constants.js";
14+
import { authenticateWithBrowser } from "./oauth.js";
15+
16+
const METHODS = [
17+
{ id: "oauth", label: "Authenticate with the Zerion dashboard (opens browser)" },
18+
{ id: "paste", label: "Paste an existing API key" },
19+
{ id: "payg", label: "Use pay-per-call instead (x402 / MPP — no API key)" },
20+
];
21+
22+
/**
23+
* Show the auth-method menu. Returns the chosen method id, or null if the user
24+
* backed out (Esc).
25+
* @param {{ includePayg?: boolean }} [opts]
26+
* @returns {Promise<"oauth" | "paste" | "payg" | null>}
27+
*/
28+
export async function selectAuthMethod({ includePayg = true } = {}) {
29+
const methods = includePayg ? METHODS : METHODS.filter((m) => m.id !== "payg");
30+
const idx = await selectOne(
31+
"How would you like to authenticate?",
32+
methods.map((m) => m.label),
33+
{ defaultIndex: 0 }
34+
);
35+
if (idx === BACK) return null;
36+
return methods[idx].id;
37+
}
38+
39+
function printPaygGuidance(log) {
40+
log("");
41+
log(" Pay-per-call needs no API key — set a private key and pass --x402 or --mpp:");
42+
log(" export WALLET_PRIVATE_KEY=0x... # x402 on Base (EVM) or MPP on Tempo");
43+
log(" export WALLET_PRIVATE_KEY=<base58> # x402 on Solana");
44+
log(" zerion portfolio <address> --x402 # or --mpp");
45+
log(" Note: pay-per-call covers analytics only; trading needs an API key.");
46+
}
47+
48+
/**
49+
* Run the interactive auth setup: pick a method and execute it, persisting the
50+
* API key to config on success. Never throws for expected outcomes (denied /
51+
* timeout / cancel / skip) — returns a structured result so callers decide how
52+
* loud to be.
53+
*
54+
* @param {{
55+
* log?: (line?: string) => void,
56+
* open?: boolean,
57+
* dashboardUrl?: string,
58+
* includePayg?: boolean,
59+
* }} [opts]
60+
* @returns {Promise<{ ok: boolean, method?: string, skipped?: boolean, reason?: string, message?: string }>}
61+
*/
62+
export async function runInteractiveAuth({
63+
log = (line = "") => process.stderr.write(line + "\n"),
64+
open = true,
65+
dashboardUrl = DASHBOARD_URL,
66+
includePayg = true,
67+
} = {}) {
68+
const method = await selectAuthMethod({ includePayg });
69+
70+
if (method === null) {
71+
log(" ! Cancelled — no changes made.");
72+
return { ok: true, skipped: true, reason: "user_cancelled" };
73+
}
74+
75+
if (method === "oauth") {
76+
try {
77+
const { apiKey } = await authenticateWithBrowser({ dashboardUrl, open, log });
78+
setConfigValue("apiKey", apiKey);
79+
log(" ✓ Authenticated — API key saved to config");
80+
return { ok: true, method: "oauth" };
81+
} catch (err) {
82+
log(` ! Browser authorization failed: ${err.message}`);
83+
return { ok: false, method: "oauth", reason: err.code || "oauth_failed", message: err.message };
84+
}
85+
}
86+
87+
if (method === "paste") {
88+
const key = await readSecret(" Paste your API key (or press Enter to skip): ", { mask: true });
89+
if (!key) {
90+
log(" ! Skipped — set later with: zerion config set apiKey <your-key>");
91+
return { ok: true, skipped: true, method: "paste", reason: "user_skipped" };
92+
}
93+
if (!key.startsWith("zk_")) {
94+
log(` ! Warning: keys typically start with "zk_". Saving anyway.`);
95+
}
96+
setConfigValue("apiKey", key);
97+
log(" ✓ API key saved to config");
98+
return { ok: true, method: "paste" };
99+
}
100+
101+
// payg — informational only; nothing is persisted.
102+
printPaygGuidance(log);
103+
return { ok: true, skipped: true, method: "payg", reason: "pay_per_call" };
104+
}

0 commit comments

Comments
 (0)