Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 13 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,19 +153,23 @@ Three options. The CLI auto-detects which is active.

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

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.

```bash
export ZERION_API_KEY="zk_..."
zerion login # pick browser login, paste a key, or pay-per-call
zerion login --browser # go straight to browser authentication
```

- HTTP Basic Auth
- Required for analysis and trading commands (analysis can also use x402 / MPP pay-per-call instead — see options B and C)

You can also persist it via config:
Or set / persist a key manually:

```bash
zerion config set apiKey zk_...
export ZERION_API_KEY="zk_..." # per-session
zerion config set apiKey zk_... # persisted to ~/.zerion/config.json
```

- HTTP Basic Auth
- Required for analysis and trading commands (analysis can also use x402 / MPP pay-per-call instead — see options B and C)

### B) x402 pay-per-call

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

| Command | Description | Example |
|---------|-------------|---------|
| `zerion init` | One-shot onboarding — install CLI globally, configure API key, install agent skills | `zerion init` |
| `zerion login` | Authenticate — browser (dashboard) login, paste an API key, or pay-per-call | `zerion login` |
| `zerion login --browser` | Browser auth: opens dashboard.zerion.io, captures the key via loopback | `zerion login --browser` |
| `zerion init` | One-shot onboarding — install CLI globally, authenticate, install agent skills | `zerion init` |
| `zerion init -y --browser` | Non-interactive init that opens dashboard.zerion.io for the API key | `npx -y zerion-cli init -y --browser` |
| `zerion setup skills` | Install Zerion agent skills into detected coding agents | `zerion setup skills` |
| `zerion setup skills --agent claude-code` | Install into a specific agent | `zerion setup skills --agent claude-code` |
Expand Down
43 changes: 10 additions & 33 deletions cli/commands/init.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,20 @@ import { existsSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { print, printError } from "../utils/common/output.js";
import { readSecret } from "../utils/common/prompt.js";
import { getApiKey, setConfigValue } from "../utils/config.js";
import { openBrowser } from "../utils/common/browser.js";
import { DASHBOARD_URL } from "../utils/common/constants.js";
import { getApiKey } from "../utils/config.js";
import { runInteractiveAuth } from "../utils/api/interactive-auth.js";

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

const HELP = {
usage: "zerion init [options]",
description:
"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.",
"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.",
flags: {
"--yes, -y": "Non-interactive — skip prompts and install ALL skills (otherwise user picks)",
"--browser": "Open dashboard.zerion.io in the default browser during auth",
"--browser": "With --yes: open dashboard.zerion.io to grab an API key (interactive runs offer browser login directly)",
"--no-install": "Skip the global `npm install -g zerion-cli` step",
"--no-auth": "Skip the API key configuration step",
"--no-skills": "Skip the agent skills install step",
Expand Down Expand Up @@ -60,17 +61,6 @@ function detectAgent() {
return null;
}

function openBrowser(url) {
const cmd =
process.platform === "darwin"
? "open"
: process.platform === "win32"
? "start"
: "xdg-open";
const args = process.platform === "win32" ? ["", url] : [url];
spawnSync(cmd, args, { stdio: "ignore", shell: process.platform === "win32" });
}

function ensureGlobalInstall() {
// Two skip conditions:
// 1. Running from a global install (not npx temp dir).
Expand Down Expand Up @@ -98,6 +88,8 @@ async function ensureApiKey({ yes, browser }) {
if (yes) {
log(` ! No API key configured. Get one at ${DASHBOARD_URL} and run:`);
log(` zerion config set apiKey <your-key>`);
log(` (or run 'zerion login' for browser authentication)`);
if (browser) openBrowser(DASHBOARD_URL);
return { ok: true, skipped: true, reason: "non_interactive" };
}

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

log(` Get an API key at ${DASHBOARD_URL}`);
if (browser) {
log(` Opening browser...`);
openBrowser(DASHBOARD_URL);
}

const key = await readSecret(" Paste your API key (or press Enter to skip): ", { mask: true });
if (!key) {
log(" ! Skipped — set later with: zerion config set apiKey <your-key>");
return { ok: true, skipped: true, reason: "user_skipped" };
}
if (!key.startsWith("zk_")) {
log(` ! Warning: keys typically start with "zk_". Saving anyway.`);
}
setConfigValue("apiKey", key);
log(" ✓ API key saved to config");
return { ok: true, skipped: false };
// Interactive: browser login (default), paste a key, or pay-per-call.
return runInteractiveAuth({ log, open: true });
}

function installSkills({ agent, yes }) {
Expand Down
77 changes: 77 additions & 0 deletions cli/commands/login.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { print, printError } from "../utils/common/output.js";
import { getApiKey, setConfigValue } from "../utils/config.js";
import { DASHBOARD_URL } from "../utils/common/constants.js";
import { authenticateWithBrowser } from "../utils/api/oauth.js";
import { runInteractiveAuth } from "../utils/api/interactive-auth.js";

const HELP = {
usage: "zerion login [options]",
description:
"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.",
flags: {
"--browser": "Skip the picker and go straight to browser authentication",
"--no-open": "Print the authorize URL but don't auto-open the browser",
},
examples: {
"zerion login": "Interactive — pick browser auth, paste a key, or pay-per-call",
"zerion login --browser": "Go straight to browser authentication",
"zerion login --browser --no-open": "Browser auth on a remote/headless host — copy the printed URL",
},
};

function log(line = "") {
process.stderr.write(line + "\n");
}

export default async function login(args, flags) {
if (flags.help || flags.h) {
print(HELP);
return;
}

// parseFlags maps `--no-open` to `flags.open = false`.
const open = flags.open !== false;
const dashboardUrl = DASHBOARD_URL;

if (getApiKey()) {
log(" ! An API key is already configured — continuing will replace it.");
}

// --browser: skip the picker. Works without a TTY (approval happens in the
// browser, out-of-band), so it's the headless-friendly path.
if (flags.browser) {
try {
const { apiKey } = await authenticateWithBrowser({ dashboardUrl, open, log });
setConfigValue("apiKey", apiKey);
log(" ✓ Authenticated — API key saved to config");
print({ ok: true, action: "login", method: "oauth" });
} catch (err) {
printError(err.code || "login_failed", err.message);
process.exit(1);
}
return;
}

if (!process.stdin.isTTY) {
printError(
"not_interactive",
"zerion login needs an interactive terminal. Use --browser for headless browser auth, " +
"or set ZERION_API_KEY / run: zerion config set apiKey <your-key>"
);
process.exit(1);
}

const res = await runInteractiveAuth({ log, open, dashboardUrl });
if (!res.ok) {
printError(res.reason || "login_failed", res.message || "Login failed");
process.exit(1);
}

print({
ok: true,
action: "login",
method: res.method ?? null,
skipped: Boolean(res.skipped),
...(res.reason ? { reason: res.reason } : {}),
});
}
7 changes: 5 additions & 2 deletions cli/router.js
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,8 @@ function printUsage() {
"--allowlist <addresses>": "Only allow interaction with listed addresses",
},
env: {
"ZERION_API_KEY": "API key (get at dashboard.zerion.io)",
"ZERION_API_KEY": "API key (get at dashboard.zerion.io, or run `zerion login`)",
"ZERION_DASHBOARD_URL": "Override the dashboard base URL for `zerion login` / `init` (default: https://dashboard.zerion.io)",
"WALLET_PRIVATE_KEY": "Private key for pay-per-call: 0x-hex → x402 on Base; base58 → x402 on Solana; 0x-hex → also works for MPP",
"EVM_PRIVATE_KEY": "EVM private key for x402 on Base (overrides WALLET_PRIVATE_KEY for EVM)",
"SOLANA_PRIVATE_KEY": "Solana private key for x402 on Solana (overrides WALLET_PRIVATE_KEY for Solana)",
Expand All @@ -129,7 +130,9 @@ function printUsage() {
"slippage": "Default slippage % for swaps (default: 2)",
},
setup: {
"init": "One-shot onboarding: install CLI globally, configure API key, install agent skills",
"login": "Authenticate — browser (dashboard) login, paste an API key, or pay-per-call",
"login --browser": "Browser authentication: opens dashboard.zerion.io, captures the key via loopback",
"init": "One-shot onboarding: install CLI globally, authenticate, install agent skills",
"init -y --browser": "Non-interactive init that opens dashboard.zerion.io for the API key",
"setup skills": "Install Zerion agent skills via `npx skills add zeriontech/zerion-ai` (45+ hosts)",
},
Expand Down
104 changes: 104 additions & 0 deletions cli/utils/api/interactive-auth.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* Interactive auth-method picker shared by `zerion init` and `zerion login`.
*
* Presents the currently available ways to authenticate — browser login first
* (the modern default, like Claude Code), then pasting an existing API key,
* then a pointer to pay-per-call (x402 / MPP) which needs no API key. Requires
* a raw-mode TTY; callers guard on `process.stdin.isTTY` before invoking.
*/

import { selectOne, BACK } from "../common/select.js";
import { readSecret } from "../common/prompt.js";
import { setConfigValue } from "../config.js";
import { DASHBOARD_URL } from "../common/constants.js";
import { authenticateWithBrowser } from "./oauth.js";

const METHODS = [
{ id: "oauth", label: "Authenticate with the Zerion dashboard (opens browser)" },
{ id: "paste", label: "Paste an existing API key" },
{ id: "payg", label: "Use pay-per-call instead (x402 / MPP — no API key)" },
];

/**
* Show the auth-method menu. Returns the chosen method id, or null if the user
* backed out (Esc).
* @param {{ includePayg?: boolean }} [opts]
* @returns {Promise<"oauth" | "paste" | "payg" | null>}
*/
export async function selectAuthMethod({ includePayg = true } = {}) {
const methods = includePayg ? METHODS : METHODS.filter((m) => m.id !== "payg");
const idx = await selectOne(
"How would you like to authenticate?",
methods.map((m) => m.label),
{ defaultIndex: 0 }
);
if (idx === BACK) return null;
return methods[idx].id;
}

function printPaygGuidance(log) {
log("");
log(" Pay-per-call needs no API key — set a private key and pass --x402 or --mpp:");
log(" export WALLET_PRIVATE_KEY=0x... # x402 on Base (EVM) or MPP on Tempo");
log(" export WALLET_PRIVATE_KEY=<base58> # x402 on Solana");
log(" zerion portfolio <address> --x402 # or --mpp");
log(" Note: pay-per-call covers analytics only; trading needs an API key.");
}

/**
* Run the interactive auth setup: pick a method and execute it, persisting the
* API key to config on success. Never throws for expected outcomes (denied /
* timeout / cancel / skip) — returns a structured result so callers decide how
* loud to be.
*
* @param {{
* log?: (line?: string) => void,
* open?: boolean,
* dashboardUrl?: string,
* includePayg?: boolean,
* }} [opts]
* @returns {Promise<{ ok: boolean, method?: string, skipped?: boolean, reason?: string, message?: string }>}
*/
export async function runInteractiveAuth({
log = (line = "") => process.stderr.write(line + "\n"),
open = true,
dashboardUrl = DASHBOARD_URL,
includePayg = true,
} = {}) {
const method = await selectAuthMethod({ includePayg });

if (method === null) {
log(" ! Cancelled — no changes made.");
return { ok: true, skipped: true, reason: "user_cancelled" };
}

if (method === "oauth") {
try {
const { apiKey } = await authenticateWithBrowser({ dashboardUrl, open, log });
setConfigValue("apiKey", apiKey);
log(" ✓ Authenticated — API key saved to config");
return { ok: true, method: "oauth" };
} catch (err) {
log(` ! Browser authorization failed: ${err.message}`);
return { ok: false, method: "oauth", reason: err.code || "oauth_failed", message: err.message };
}
}

if (method === "paste") {
const key = await readSecret(" Paste your API key (or press Enter to skip): ", { mask: true });
if (!key) {
log(" ! Skipped — set later with: zerion config set apiKey <your-key>");
return { ok: true, skipped: true, method: "paste", reason: "user_skipped" };
}
if (!key.startsWith("zk_")) {
log(` ! Warning: keys typically start with "zk_". Saving anyway.`);
}
setConfigValue("apiKey", key);
log(" ✓ API key saved to config");
return { ok: true, method: "paste" };
}

// payg — informational only; nothing is persisted.
printPaygGuidance(log);
return { ok: true, skipped: true, method: "payg", reason: "pay_per_call" };
}
Loading
Loading