Skip to content
Open
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
30 changes: 18 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,15 @@ CLI for [Zerion Wallet](https://zerion.io). Analyze wallets, sign, swap, and bri
npm install -g zerion-cli
```

Or set up everything in one command (install CLI globally, configure your API key, and add skills across all detected coding agents):
Or set up everything in one command (install CLI globally, authenticate via browser, and add skills across all detected coding agents):

```bash
npx -y zerion-cli init -y --browser
npx -y zerion-cli init
```

- `-y` runs setup non-interactively
- `--browser` opens [dashboard.zerion.io](https://dashboard.zerion.io) so you can grab an API key and paste it back
- opens your browser to [dashboard.zerion.io](https://dashboard.zerion.io), waits for you to click **Authorize**, then saves the API key automatically (PKCE flow — no manual paste)
- skills install globally to every detected AI coding agent by default
- pass `-y` to run non-interactively in CI; auth is skipped and you can finish later with `zerion login`

Requires Node.js 20 or later.

Expand Down Expand Up @@ -126,21 +126,24 @@ Three options. The CLI auto-detects which is active.

### A) API key (recommended)

Get a key at **[dashboard.zerion.io](https://dashboard.zerion.io)** — it's free and takes a minute. Keys begin with `zk_`.
Run the browser-based login flow — it opens [dashboard.zerion.io](https://dashboard.zerion.io), waits for you to click **Authorize**, and saves the key for you (PKCE; no manual paste):

```bash
export ZERION_API_KEY="zk_..."
zerion login # opens browser, completes via PKCE, saves the key
zerion logout # clear the saved API key (and any agent tokens)
```

- 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 only do this once — the key persists in `~/.zerion/config.json` (mode 0o600).

You can also persist it via config:
For non-interactive setups (CI, scripts, containers) you can supply the key directly:

```bash
zerion config set apiKey zk_...
zerion login --api-key zk_... # save a key non-interactively
export ZERION_API_KEY="zk_..." # or just export it; CLI auto-detects
```

Keys begin with `zk_` (e.g. `zk_dev_…`). 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 @@ -283,8 +286,11 @@ 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 init -y --browser` | Non-interactive init that opens dashboard.zerion.io for the API key | `npx -y zerion-cli init -y --browser` |
| `zerion init` | One-shot onboarding — install CLI globally, browser-auth via PKCE, install agent skills | `zerion init` |
| `zerion init -y` | Non-interactive init for CI; skips auth (run `zerion login` later) | `npx -y zerion-cli init -y` |
| `zerion login` | Browser-based login (PKCE) — opens dashboard.zerion.io and saves the key | `zerion login` |
| `zerion login --api-key zk_...` | Non-interactive login with a key you already have | `zerion login --api-key zk_dev_...` |
| `zerion logout` | Clear the saved API key and any agent tokens | `zerion logout` |
| `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
67 changes: 25 additions & 42 deletions cli/commands/init.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { spawnSync } from "node:child_process";
import { print, printError } from "../utils/common/output.js";
import { readSecret } from "../utils/common/prompt.js";
import { browserLogin } from "../utils/auth/browser-flow.js";
import { getApiKey, setConfigValue } from "../utils/config.js";

const ZERION_AGENT_REPO = "zeriontech/zerion-ai";
Expand All @@ -9,18 +9,20 @@ 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.",
"One-shot onboarding: install the CLI globally, authenticate via browser (PKCE), and install Zerion agent skills into detected coding agents.",
flags: {
"--yes, -y": "Non-interactive — skip prompts, pass --yes to skills installer",
"--browser": "Open dashboard.zerion.io in the default browser during auth",
"--yes, -y":
"Non-interactive — skip browser auth (run `zerion login` later), pass --yes to skills installer",
"--no-install": "Skip the global `npm install -g zerion-cli` step",
"--no-auth": "Skip the API key configuration step",
"--no-auth": "Skip the authentication step",
"--no-skills": "Skip the agent skills install step",
"--agent <name>": "Scope skills install to one agent (e.g. claude-code, cursor)",
},
examples: {
"npx -y zerion-cli init -y --browser":
"Bootstrap end-to-end non-interactively, opening the dashboard for the API key",
"npx -y zerion-cli init":
"Bootstrap end-to-end interactively — opens the browser for PKCE login and saves the key",
"npx -y zerion-cli init -y":
"Non-interactive bootstrap; skips auth so it works in CI. Run `zerion login` later",
"zerion init --no-install --agent claude-code":
"Skip self-install and only set up Claude Code",
},
Expand All @@ -35,17 +37,6 @@ function isNpxTempInvocation() {
return path.includes("/_npx/") || path.includes("\\_npx\\");
}

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() {
if (!isNpxTempInvocation()) {
log(" ✓ CLI already installed globally");
Expand All @@ -60,42 +51,35 @@ function ensureGlobalInstall() {
return { ok: true, skipped: false };
}

async function ensureApiKey({ yes, browser }) {
async function ensureApiKey({ yes }) {
const existing = getApiKey();
if (existing) {
log(" ✓ Already authenticated");
return { ok: true, skipped: true };
}

// Non-interactive (CI, scripts) — PKCE needs a human click in the browser.
// Skip cleanly and tell the user how to finish later.
if (yes) {
log(` ! No API key configured. Get one at ${DASHBOARD_URL} and run:`);
log(` zerion config set apiKey <your-key>`);
log(` ! Skipped — run "zerion login" interactively to authenticate via browser.`);
return { ok: true, skipped: true, reason: "non_interactive" };
}

if (!process.stdin.isTTY) {
log(` ! No API key configured and stdin is not interactive.`);
log(` Set ZERION_API_KEY or run: zerion config set apiKey <your-key>`);
log(` ! No TTY — run "zerion login" interactively or set ZERION_API_KEY.`);
return { ok: true, skipped: true, reason: "non_tty" };
}

log(` Get an API key at ${DASHBOARD_URL}`);
if (browser) {
log(` Opening browser...`);
openBrowser(DASHBOARD_URL);
try {
const result = await browserLogin();
setConfigValue("apiKey", result.apiKey);
const who = result.teamName || result.email || "Zerion user";
log(` ✓ Authenticated as ${who}`);
return { ok: true, skipped: false };
} catch (err) {
log(` ! Login failed: ${err.message || err}`);
log(` Run "zerion login" later to retry, or set ZERION_API_KEY manually.`);
return { ok: true, skipped: true, reason: "login_failed" };
}

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 };
}

function installSkills({ agent }) {
Expand Down Expand Up @@ -133,7 +117,6 @@ export default async function init(args, flags) {
}

const yes = Boolean(flags.yes || flags.y);
const browser = Boolean(flags.browser);
// parseFlags maps `--no-install` to `flags.install = false`
const skipInstall = flags.install === false;
const skipAuth = flags.auth === false;
Expand All @@ -160,7 +143,7 @@ export default async function init(args, flags) {
log("[2/3] Authenticate");
const authRes = skipAuth
? { ok: true, skipped: true, reason: "flag" }
: await ensureApiKey({ yes, browser });
: await ensureApiKey({ yes });
steps.push({ step: "auth", ...authRes });

log("");
Expand Down
150 changes: 150 additions & 0 deletions cli/commands/login.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import readline from "node:readline";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { setConfigValue, getApiKey } from "../utils/config.js";
import { print, printError } from "../utils/common/output.js";
import { browserLogin } from "../utils/auth/browser-flow.js";
import { readSecret } from "../utils/common/prompt.js";
import { API_BASE, CONFIG_PATH } from "../utils/common/constants.js";

const __dirname = dirname(fileURLToPath(import.meta.url));
const pkg = JSON.parse(readFileSync(join(__dirname, "../../package.json"), "utf8"));

function prompt(question) {
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
return new Promise((resolve) =>
rl.question(question, (answer) => {
rl.close();
resolve(answer.trim());
})
);
}

function banner() {
const w = (s) => process.stderr.write(s + "\n");
w("");
w(` zerion cli v${pkg.version}`);
w(` Wallet analysis & autonomous trading for AI agents`);
w("");
}

function maskKey(key) {
if (!key || typeof key !== "string") return "(unknown)";
if (key.length <= 10) return key;
return `${key.slice(0, 6)}…${key.slice(-4)}`;
}

// Dashboard issues keys prefixed `zk_dev_` or `zk_prod_`. Older guidance
// mentioned `zk-` so we accept either to avoid rejecting valid keys.
function isValidKeyFormat(key) {
return typeof key === "string" && /^zk[_-]/.test(key);
}

function successBlock({ team, method, key }) {
const w = (s) => process.stderr.write(s + "\n");
w("");
w(`✓ Login successful!`);
if (team) w(` Team: ${team}`);
w(` API: ${API_BASE}`);
w(` Key: ${maskKey(key)}`);
w(` Config: ${CONFIG_PATH}`);
w(` Mode: ${method}`);
w("");
}

export default async function loginCmd(args, flags) {
const existingKey = getApiKey();
if (existingKey && !flags.force) {
process.stderr.write("Already logged in. Use --force to replace the current API key.\n");
print({ loggedIn: true, api: API_BASE, config: CONFIG_PATH, keyPrefix: maskKey(existingKey) });
return;
}

if (flags["api-key"]) {
if (flags.browser) {
process.stderr.write("Note: --api-key takes precedence over --browser.\n");
}
const key = flags["api-key"];
if (!isValidKeyFormat(key)) {
printError("invalid_key_format", "API keys start with 'zk_' (e.g. zk_dev_…)");
process.exit(1);
}
setConfigValue("apiKey", key);
print({ loggedIn: true, method: "api-key", api: API_BASE, config: CONFIG_PATH, keyPrefix: maskKey(key) });
return;
}

if (flags.browser) {
if (!flags.quiet) banner();
return runBrowser({ quiet: flags.quiet });
}

// Interactive menu needs a TTY. In non-TTY contexts (CI, pipes, containers),
// an interactive prompt blocks forever — fail loudly with a fix.
if (!process.stdin.isTTY) {
printError(
"no_tty",
"Interactive login requires a terminal. Use --browser, --api-key <key>, or set ZERION_API_KEY."
);
process.exit(1);
}

if (!flags.quiet) banner();
const w = (s) => process.stderr.write(s + "\n");
w(`Welcome! To get started, authenticate with your Zerion account.`);
w("");
w(` 1. Login with browser (recommended)`);
w(` 2. Enter API key manually`);
w("");
w(`Tip: You can also set ZERION_API_KEY environment variable`);
w(` API endpoint: ${API_BASE}`);
w("");

const choice = await prompt("Enter choice [1/2]: ");
if (choice === "" || choice === "1") {
return runBrowser({ quiet: flags.quiet });
}
if (choice !== "2") {
printError("invalid_choice", "Enter 1 or 2");
process.exit(1);
}

const key = await readSecret("Enter your Zerion API key: ", { mask: true });
if (!isValidKeyFormat(key)) {
printError("invalid_key_format", "API keys start with 'zk_' (e.g. zk_dev_…)");
process.exit(1);
}
setConfigValue("apiKey", key);
successBlock({ method: "api-key", key });
print({ loggedIn: true, method: "api-key", api: API_BASE });
}

async function runBrowser({ quiet = false } = {}) {
try {
const result = await browserLogin();
setConfigValue("apiKey", result.apiKey);
successBlock({ team: result.teamName || "(unknown)", method: "browser", key: result.apiKey });
print({
loggedIn: true,
method: "browser",
email: result.email,
team: result.teamName,
api: API_BASE,
config: CONFIG_PATH,
keyPrefix: maskKey(result.apiKey),
});
} catch (err) {
// Top-level `zerion login` is a standalone command — exit non-zero on
// failure. When called inline from another flow (wallet create/import
// pass quiet: true), rethrow so the caller can recover instead of
// killing the outer process mid-setup.
if (quiet) {
const e = err instanceof Error ? err : new Error(String(err));
e.code = e.code || "login_failed";
throw e;
}
printError("login_failed", err.message || "Login failed");
process.exit(1);
}
}
22 changes: 22 additions & 0 deletions cli/commands/logout.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { unsetConfigValue } from "../utils/config.js";
import { print } from "../utils/common/output.js";
import { CONFIG_PATH } from "../utils/common/constants.js";

export default async function logoutCmd() {
unsetConfigValue("apiKey");
// Clear agent tokens too — they're tied to this account and won't work after logout.
unsetConfigValue("agentTokens");
process.stderr.write(`✓ Logged out successfully\n Config: ${CONFIG_PATH}\n`);

// ZERION_API_KEY overrides the saved config, so logout alone doesn't end
// the session if the user exported it in their shell. Surface it.
const envKeySet = !!process.env.ZERION_API_KEY;
if (envKeySet) {
process.stderr.write(
" Note: ZERION_API_KEY is still set in this shell. " +
"Run `unset ZERION_API_KEY` to fully log out.\n"
);
}

print({ loggedOut: true, config: CONFIG_PATH, envKeySet });
}
4 changes: 4 additions & 0 deletions cli/commands/wallet/create.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { setConfigValue, getConfigValue } from "../../utils/config.js";
import { readPassphrase, readSecret } from "../../utils/common/prompt.js";
import { PASSPHRASE_WARNING } from "../../utils/common/constants.js";
import { offerAgentToken } from "../../utils/wallet/offer-agent-token.js";
import { offerLogin } from "../../utils/wallet/offer-login.js";

export default async function walletCreate(args, flags) {
const name = flags.name || args[0] || generateName();
Expand Down Expand Up @@ -40,6 +41,9 @@ export default async function walletCreate(args, flags) {
isDefault: getConfigValue("defaultWallet") === name,
});

// Offer API key login first — agent tokens / trading / analysis all need one.
await offerLogin();

// Offer agent token creation as part of wallet setup
await offerAgentToken(name, passphrase);
} catch (err) {
Expand Down
Loading
Loading