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
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,15 @@ CLI-first banking for agents starts with the `zero` CLI.
```bash
curl -fsSL https://zerofinance.ai/install | bash

# Authenticate
# Authenticate (agent-native API flow)
zero auth agentlogin \
--email finance-agent@acme.com \
--company-name "Acme Inc" \
--admin-token $ZERO_FINANCE_ADMIN_TOKEN

# Note: defaults to creating one Ethereum wallet when wallets are not specified

# Or browser login for humans
zero auth connect

# Or: zero auth login --api-key sk_live_xxx
Expand Down
11 changes: 10 additions & 1 deletion packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,15 @@ The CLI binary is `zero` (alias: `zero-bank`).
## Quick start

```bash
# Authenticate
# Authenticate (agent-native, API-only)
zero auth agentlogin \
--email finance-agent@acme.com \
--company-name "Acme Inc" \
--admin-token $ZERO_FINANCE_ADMIN_TOKEN

# Note: defaults to creating one Ethereum wallet when wallets are not specified

# Or browser login for humans
zero auth connect

# Or: zero auth login --api-key sk_live_xxx
Expand All @@ -38,6 +46,7 @@ zero invoices send --invoice-id inv_xxx

## Common commands

- `zero auth agentlogin` — Privy + API key provisioning over API
- `zero auth connect` — browser-based login
- `zero auth login` — store API key manually
- `zero balance` — spendable, earning, and idle balances
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ export async function apiRequest<T>(

if (!apiKey && !options.adminToken) {
throw new Error(
'Missing API key. Run `zero auth connect` or `zero auth login --api-key <key>`',
'Missing API key. Run `zero auth agentlogin --email <email>` or `zero auth login --api-key <key>`',
);
}

Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export async function requireConfig() {
const config = await loadConfig();
if (!config.apiKey) {
throw new Error(
'Missing API key. Run `zero auth connect` or `zero auth login --api-key <key>`',
'Missing API key. Run `zero auth agentlogin --email <email>` or `zero auth login --api-key <key>`',
);
}
return {
Expand Down
252 changes: 217 additions & 35 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ function enableDebug(debug?: boolean) {
const execFileAsync = promisify(execFile);
const DEFAULT_BASE_URL = 'https://www.0.finance';
const CONNECT_TIMEOUT_MS = 120_000;
const AGENT_LOGIN_DEFAULT_KEY_NAME = 'Agent API Login';

function createStateToken() {
return randomBytes(16).toString('hex');
Expand Down Expand Up @@ -152,6 +153,12 @@ async function waitForToken(
}

async function promptForApiKey() {
if (!stdinStream.isTTY) {
throw new Error(
'Cannot prompt for API key in non-interactive mode. Use `zero auth login --api-key <key>` or `zero auth agentlogin --email <email>`.',
);
}

const prompt = readline.createInterface({
input: stdinStream,
output: stdoutStream,
Expand Down Expand Up @@ -209,6 +216,12 @@ async function runAuthConnect(options: {
}

if (!apiKey) {
if (!stdinStream.isTTY) {
throw new Error(
'Browser callback timed out in non-interactive mode. Use `zero auth agentlogin --email <email> --admin-token <token>` or provide an API key with `zero auth login --api-key <key>`.',
);
}

console.log('If the browser flow did not complete, paste your API key.');
apiKey = await promptForApiKey();
}
Expand All @@ -233,6 +246,145 @@ function resolveAdminToken(token?: string) {
return token || process.env.ZERO_FINANCE_ADMIN_TOKEN || '';
}

type AgentLoginOptions = {
baseUrl?: string;
email?: string;
phone?: string;
privyUserId?: string;
workspaceName?: string;
companyName?: string;
beneficiaryType?: 'business' | 'individual';
firstName?: string;
lastName?: string;
apiKeyName?: string;
apiKeyExpiresAt?: string;
createDirectSigner?: boolean;
starterAccounts?: boolean;
walletsJson?: string;
adminToken?: string;
};

type AgentLoginResponse = {
privy_user_id: string;
workspace_id: string;
workspace_name?: string | null;
api_key: string;
api_key_id: string;
wallet?: {
address?: string | null;
requested?: unknown[];
provisioned?: boolean;
provisioning_error?: string | null;
};
kyb?: {
status: string;
sub_status?: string | null;
flow_link?: string | null;
marked_done?: boolean;
};
starter_accounts?: {
attempted?: boolean;
created?: boolean;
skipped_reason?: string | null;
destination_address?: string | null;
};
};

async function runAgentLogin(options: AgentLoginOptions) {
const adminToken = resolveAdminToken(options.adminToken);
if (!adminToken) {
throw new Error(
'Admin token required. Pass --admin-token or set ZERO_FINANCE_ADMIN_TOKEN.',
);
}

if (!options.email && !options.phone && !options.privyUserId) {
throw new Error('Provide --email, --phone, or --privy-user-id');
}

if (
options.beneficiaryType &&
options.beneficiaryType !== 'business' &&
options.beneficiaryType !== 'individual'
) {
throw new Error('beneficiary type must be `business` or `individual`');
}

const wallets = options.walletsJson
? await readJsonFile(options.walletsJson)
: undefined;

if (wallets !== undefined && !Array.isArray(wallets)) {
throw new Error('--wallets-json must contain a JSON array');
}

const baseUrl = options.baseUrl || DEFAULT_BASE_URL;
const payload = {
email: options.email,
phone: options.phone,
privy_user_id: options.privyUserId,
workspace_name: options.workspaceName,
company_name: options.companyName,
beneficiary_type: options.beneficiaryType,
first_name: options.firstName,
last_name: options.lastName,
api_key_name: options.apiKeyName || AGENT_LOGIN_DEFAULT_KEY_NAME,
api_key_expires_at: options.apiKeyExpiresAt,
create_direct_signer: options.createDirectSigner,
create_starter_accounts: options.starterAccounts,
wallets,
};

const data = await apiRequest<AgentLoginResponse>('/api/cli/agent-login', {
method: 'POST',
body: payload,
adminToken,
baseUrl,
apiKey: '',
});

if (!data?.api_key) {
throw new Error('Agent login response did not include an API key');
}

await saveConfig({ apiKey: data.api_key, baseUrl });

output({
success: true,
method: 'agent_api',
workspace_id: data.workspace_id,
workspace_name: data.workspace_name ?? null,
privy_user_id: data.privy_user_id,
api_key_id: data.api_key_id,
wallet: data.wallet,
kyb: data.kyb,
starter_accounts: data.starter_accounts,
next: 'Run `zero auth whoami` to verify the connection.',
});
}

function configureAgentLoginCommand(command: Command) {
return command
.option('--base-url <url>', 'Base URL for the API', DEFAULT_BASE_URL)
.option('--email <email>', 'Agent email')
.option('--phone <phone>', 'Agent phone number')
.option('--privy-user-id <id>', 'Use an existing Privy user ID')
.option('--workspace-name <name>', 'Workspace display name')
.option('--company-name <name>', 'Company name for KYB context')
.option('--beneficiary-type <type>', 'business or individual')
.option('--first-name <name>', 'First name for individual setups')
.option('--last-name <name>', 'Last name for individual setups')
.option('--api-key-name <name>', 'Generated API key label')
.option('--api-key-expires-at <date>', 'API key expiration ISO date')
.option(
'--wallets-json <path>',
'Wallets JSON array (defaults to one Ethereum wallet)',
)
.option('--create-direct-signer', 'Create Privy direct signer')
.option('--no-starter-accounts', 'Skip starter virtual account setup')
.option('--admin-token <token>', 'Admin token');
}

async function readJsonFile(path: string) {
const content = await fs.readFile(path, 'utf8');
return JSON.parse(content);
Expand Down Expand Up @@ -286,31 +438,25 @@ configCmd

const auth = program.command('auth').description('Authentication');

auth
.option('--base-url <url>', 'Base URL for the API', DEFAULT_BASE_URL)
.option('--no-browser', 'Do not open browser automatically')
.option('--manual', 'Paste API key manually instead of callback')
.action(async (opts) => {
await runAuthConnect({
baseUrl: opts.baseUrl,
browser: opts.browser,
manual: opts.manual,
function configureBrowserConnectCommand(command: Command) {
return command
.option('--base-url <url>', 'Base URL for the API', DEFAULT_BASE_URL)
.option('--no-browser', 'Do not open browser automatically')
.option('--manual', 'Paste API key manually instead of callback')
.action(async (opts) => {
await runAuthConnect({
baseUrl: opts.baseUrl,
browser: opts.browser,
manual: opts.manual,
});
});
});
}

auth
.command('connect')
.description('Open the browser and connect the CLI')
.option('--base-url <url>', 'Base URL for the API', DEFAULT_BASE_URL)
.option('--no-browser', 'Do not open browser automatically')
.option('--manual', 'Paste API key manually instead of callback')
.action(async (opts) => {
await runAuthConnect({
baseUrl: opts.baseUrl,
browser: opts.browser,
manual: opts.manual,
});
});
configureBrowserConnectCommand(
auth
.command('connect', { isDefault: true })
.description('Open the browser and connect the CLI'),
);

auth
.command('login')
Expand All @@ -322,6 +468,30 @@ auth
output({ success: true });
});

configureAgentLoginCommand(
auth
.command('agentlogin')
.description('Provision an agent account and save API key'),
).action(async (opts) => {
await runAgentLogin({
baseUrl: opts.baseUrl,
email: opts.email,
phone: opts.phone,
privyUserId: opts.privyUserId,
workspaceName: opts.workspaceName,
companyName: opts.companyName,
beneficiaryType: opts.beneficiaryType,
firstName: opts.firstName,
lastName: opts.lastName,
apiKeyName: opts.apiKeyName,
apiKeyExpiresAt: opts.apiKeyExpiresAt,
createDirectSigner: opts.createDirectSigner,
starterAccounts: opts.starterAccounts,
walletsJson: opts.walletsJson,
adminToken: opts.adminToken,
});
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicated agent login action handler across two registrations

Low Severity

configureAgentLoginCommand omits the .action() handler, unlike configureBrowserConnectCommand which includes it. This forces the 14-property opts-to-runAgentLogin mapping to be duplicated verbatim at both auth agentlogin and top-level agentlogin registration sites. Adding or renaming an option requires updating both copies, which is fragile and inconsistent with the browser-connect pattern.

Additional Locations (1)

Fix in Cursor Fix in Web


auth
.command('whoami')
.description('Show workspace context for current API key')
Expand All @@ -338,19 +508,31 @@ auth
output({ success: true });
});

program
.command('login')
.description('Open the browser and connect the CLI')
.option('--base-url <url>', 'Base URL for the API', DEFAULT_BASE_URL)
.option('--no-browser', 'Do not open browser automatically')
.option('--manual', 'Paste API key manually instead of callback')
.action(async (opts) => {
await runAuthConnect({
baseUrl: opts.baseUrl,
browser: opts.browser,
manual: opts.manual,
});
configureBrowserConnectCommand(
program.command('login').description('Open the browser and connect the CLI'),
);

configureAgentLoginCommand(
program.command('agentlogin').description('Agent-native login via Privy API'),
).action(async (opts) => {
await runAgentLogin({
baseUrl: opts.baseUrl,
email: opts.email,
phone: opts.phone,
privyUserId: opts.privyUserId,
workspaceName: opts.workspaceName,
companyName: opts.companyName,
beneficiaryType: opts.beneficiaryType,
firstName: opts.firstName,
lastName: opts.lastName,
apiKeyName: opts.apiKeyName,
apiKeyExpiresAt: opts.apiKeyExpiresAt,
createDirectSigner: opts.createDirectSigner,
starterAccounts: opts.starterAccounts,
walletsJson: opts.walletsJson,
adminToken: opts.adminToken,
});
});

const bank = program.command('bank').description('Bank operations');

Expand Down
Loading