diff --git a/package.json b/package.json index ab29fbd..c7ef4f2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@valyu/cli", - "version": "1.1.0", + "version": "1.2.0", "description": "The search CLI for knowledge workers", "license": "MIT", "repository": { diff --git a/skills/valyu-cli/SKILL.md b/skills/valyu-cli/SKILL.md index 6f89312..f1300b6 100644 --- a/skills/valyu-cli/SKILL.md +++ b/skills/valyu-cli/SKILL.md @@ -12,7 +12,7 @@ description: > license: MIT metadata: author: valyu - version: "1.1.0" + version: "1.2.0" homepage: https://valyu.ai source: https://github.com/valyuAI/valyu-cli inputs: @@ -26,6 +26,7 @@ references: - references/deepresearch.md - references/workflows.md - references/auth.md + - references/account.md - references/error-codes.md --- @@ -51,7 +52,12 @@ valyu │ ├── create / update / delete # manage your org's templates (file-based) ├── batch # parallel deepresearch jobs with shared config ├── sources # list available proprietary data sources -├── login / logout / whoami # auth +├── account # self-service: keys, balance, top-ups, datasets +│ ├── whoami # org, tier, calling key + budget +│ ├── keys list / create / revoke / rotate # create --cap = budget-capped agent key +│ ├── balance / topup # credits (topup charges card on file, else checkout URL) +│ ├── datasets # tier entitlements +├── login / logout / whoami # auth (login defaults to browser device flow) ├── doctor # setup + connectivity check ├── upgrade # detect install source, show / run upgrade command └── open # open platform / docs / API keys in browser @@ -79,6 +85,26 @@ valyu deepresearch update "$ID" "Also cover regulatory risk" # mid-flight stee valyu deepresearch create "..." --webhook-url https://your-app.com/hook -q ``` +## Self-provisioning (zero-to-first-call for agents) + +`valyu login` defaults to the **browser device flow** - it mints and stores a +scoped `val_` key, so an agent never has to ask a human to paste a secret. In +`--json` mode it streams line-delimited events (`device_code` → `auth_waiting` → +`auth_success`); surface `verification_uri_complete` to a human and read lines +until `auth_success`. + +```bash +valyu login --json # drive device flow, parse NDJSON events +valyu account balance -q # check credit; topup if zero +valyu account keys create --name agent --cap 5 -q # budget-capped sub-agent key +valyu search web "..." -q +``` + +**Budget-capped agent keys** are the headline `account` pattern: `--cap 5` mints a +key that can spend at most $5 before the data plane returns `402 spend_cap_reached`. +The secret is shown exactly once. Requested scopes/cap can never exceed +the calling key's own (server-enforced). Full details: [references/account.md](references/account.md). + ## Global flags | Flag | Description | @@ -203,6 +229,9 @@ valyu deepresearch create \ | Discover available research templates | `valyu workflows list` | | Many parallel deepresearch tasks with shared config | `valyu batch create ...` | | Discover available proprietary data sources | `valyu sources list` | +| Provision a budget-capped key for a sub-agent | `valyu account keys create --name agent --cap 5` | +| Check credit balance / add credits | `valyu account balance` / `valyu account topup 25` | +| See what datasets this key can reach (replan on 403) | `valyu account datasets` | | Upgrade the CLI itself | `valyu upgrade` | ## Search types (for `valyu search `) @@ -247,5 +276,6 @@ valyu deepresearch create \ - **Search (web / paper / finance / sec / bio / patent / economics / news)** → [references/search.md](references/search.md) - **AI answer (`answer`)** → [references/answer.md](references/answer.md) - **URL content extraction (`contents`)** → [references/contents.md](references/contents.md) -- **Auth, profiles, login** → [references/auth.md](references/auth.md) +- **Auth, profiles, login (device flow)** → [references/auth.md](references/auth.md) +- **Account: keys, budget caps, balance, top-ups, datasets** → [references/account.md](references/account.md) - **Error codes** → [references/error-codes.md](references/error-codes.md) diff --git a/skills/valyu-cli/references/account.md b/skills/valyu-cli/references/account.md new file mode 100644 index 0000000..87e6a6b --- /dev/null +++ b/skills/valyu-cli/references/account.md @@ -0,0 +1,115 @@ +# valyu account + +Self-service account management: API keys (with budget caps), +credit balance, top-ups, and dataset entitlements. Every subcommand is +`--json`-capable and human-pretty by default. Errors use the RFC 9457 + recovery +envelope (`code`, `hint`, `recovery`) so agents can branch and self-heal. + +``` +valyu account +├── whoami # org, tier, calling key + its budget +├── keys +│ ├── list # all keys for the org (default) +│ ├── create --name [...] # mint a key (optionally capped) +│ ├── revoke # revoke immediately (irreversible) +│ └── rotate # revoke old secret, mint a new one (same settings) +├── balance # credit balance + PAYG usage + tier +├── topup # add credits (charges card on file, else checkout link) +└── datasets # what this key/org can reach + tier ladder +``` + +All commands resolve auth like the rest of the CLI: `--api-key` > `VALYU_API_KEY` +> stored profile (`valyu login`). The calling key must hold the relevant scope +(`keys:read`/`keys:write`/`billing:read`/`billing:write`). + +## The budget-capped agent key (headline pattern) + +Provision a sub-key a sub-agent can burn through without risking the whole balance: + +```bash +valyu account keys create --name agent --cap 5 +``` + +``` +Created key agent val_a1b2c3d4 + id + scopes none + budget $5.00 total spend cap + +Save this secret now - it is shown only once: + + val_............................................................ + +``` + +The secret (`api_key`) is returned exactly once. Hand it to the sub-agent as +`VALYU_API_KEY`; when spend hits the cap the data plane returns +`402 spend_cap_reached`. With no `--scopes` the key is **search-only**: it can +call the data plane (search access is implicit in holding a valid key) and read +its own `whoami`, but cannot manage keys or billing. That is the right default +for an agent key. + +Richer example - monthly cap, still search-only: + +```bash +valyu account keys create \ + --name research-bot --cap 50 --window monthly --json +``` + +Add `--scopes` only when the key needs to **manage** the account (read it, mint +or rotate keys, move money): + +```bash +valyu account keys create \ + --name ops --scopes account:read,keys:read,keys:write --json +``` + +### `keys create` options + +| Flag | Description | +|------|-------------| +| `--name ` | Required. Human-readable key name (unique per org). | +| `--cap ` | Spend cap in USD. Omit for uncapped (subject to your own remaining cap). | +| `--window ` | Cap window (default `total`). `monthly` resets each month. | +| `--scopes ` | Comma-separated **management** scopes: `account:read`, `keys:read`, `keys:write`, `billing:read`, `billing:write`. Default none (search-only); search/data access is automatic. Must be a subset of yours. | +| `--type ` | Key type (default `user`). | +| `--rate-limit ` | Rate limit in requests per minute. | +| `--expires ` | Expiry as an ISO 8601 timestamp. | + +**Escalation invariants** (enforced server-side): requested `scopes` and `cap` +can never exceed the calling key's own. Violations return `403` +`scope_escalation` / `cap_escalation` with a `recovery` +block showing your ceiling. + +## balance & topup + +```bash +valyu account balance -q +# {"credit_balance_usd":42.18,"payg_usage_usd":7.82,...,"tier":"tier_2"} + +valyu account topup 25 +# Charges your card on file and lands the credits immediately: +# {"charged":true,"amount_usd":25,...} -> "✓ Added $25 to your balance" +# If there's no card on file, it returns a Stripe Checkout link instead: +# {"checkout_url":"https://...","amount_usd":25} -> a human completes payment; +# credits land via the Stripe webhook. +``` + +`topup` requires the `billing:write` scope (opt-in at device-login consent). +Add `--open` to open the checkout URL in a browser (only applies when a checkout +link is returned). + +## datasets + +```bash +valyu account datasets -q # available_datasets + tier ladder +``` + +`datasets` is the replanning surface: on a `403 tier_insufficient` an agent reads +`available_datasets` to pick a reachable source instead. + +## JSON for agents + +Add `-q` (or `--json`) to any subcommand for machine output. On error the process +exits 1 and prints `{"error":{"message","code","hint","retryable","recovery"}}` - +branch on `code`, follow `recovery` (e.g. `topup_url`, `increase_cap_url`). diff --git a/skills/valyu-cli/references/auth.md b/skills/valyu-cli/references/auth.md index 9af79f2..903a831 100644 --- a/skills/valyu-cli/references/auth.md +++ b/skills/valyu-cli/references/auth.md @@ -4,20 +4,51 @@ Authentication and credential management. ## login -Store a Valyu API key. +Authenticate the CLI. **By default this runs the browser device flow** (RFC 8628): +it mints a fresh, scoped `val_` API key for your org and stores it - nothing to +copy-paste. Use `--key` for manual / CI logins. ``` -valyu login [--key ] [--profile ] +valyu login [--device] [--no-browser] [--scope ] [--profile ] +valyu login --key [--profile ] ``` | Flag | Description | |------|-------------| -| `--key ` | API key to store (required in non-interactive mode) | +| `--key ` | Authenticate with an existing API key (manual / CI mode) | +| `--device` | Force the device flow (default when `--key` is omitted) | +| `--no-browser` | Print the verification URL + code instead of opening a browser | +| `--scope ` | Space-separated management scopes to request (default: `account:read keys:read keys:write billing:read`; search/data access is automatic) | | `--profile ` | Profile name (default: "default") | -**Interactive:** Shows link to platform.valyu.ai/user/account/apikeys, then prompts for key. +**Device flow (default, recommended):** -**Non-interactive (CI/agents):** +```bash +valyu login +# 1. Prints a user code (e.g. WDJB-MJHT) and opens platform.valyu.ai/device +# 2. You log in + approve in the browser (choose scopes + optional budget cap) +# 3. The CLI mints + stores a scoped val_ key +``` + +The minted key's id is stored alongside it so `valyu logout` can revoke it +server-side. `billing:write` is NOT requested by default - tick it on the consent +screen if the key needs to move money (top-ups). + +**Agent onboarding (line-delimited JSON events):** with `--json` (or in a non-TTY) +`login` streams one JSON object per line so an agent can drive the flow: + +```bash +valyu login --json +{"event":"device_code","user_code":"WDJB-MJHT","verification_uri":"https://platform.valyu.ai/device","verification_uri_complete":"https://platform.valyu.ai/device?user_code=WDJB-MJHT","expires_in":900,"interval":5} +{"event":"auth_waiting","status":"authorization_pending","attempt":1} +{"event":"auth_success","profile":"default","valyu_key_id":"...","key_prefix":"val_a1b2c3d4","scope":"...","config_path":"..."} +``` + +Surface `verification_uri_complete` (or `user_code`) to a human, then keep reading +lines until `auth_success` (or `auth_error`). The CLI honours the server's +`interval` / `slow_down` / `expires_in` automatically. + +**Manual / CI:** ```bash valyu login --key val_xxx --profile production # → {"success":true,"config_path":"...","profile":"production"} @@ -25,12 +56,17 @@ valyu login --key val_xxx --profile production ## logout +Removes stored credentials locally by default (like `gh`/`aws`/`docker`). The +device-minted key keeps working until it expires or is revoked; pass `--revoke` +to also kill it server-side. + ``` -valyu logout [--profile ] [--yes] +valyu logout [--profile ] [--revoke] [--yes] ``` - Without `--profile`: removes all credentials - With `--profile`: removes only that profile +- `--revoke`: also revoke the device-minted key server-side (best-effort) - `--yes`: skips confirmation prompt ## whoami @@ -85,8 +121,30 @@ valyu search web "query" --api-key val_xxx -q { "active_profile": "default", "profiles": { - "default": { "api_key": "val_xxx" }, + "default": { "api_key": "val_xxx", "valyu_key_id": "uuid" }, "production": { "api_key": "val_yyy" } } } ``` + +`valyu_key_id` is only present for device-flow logins (it lets `logout` revoke the +key server-side). Manual `--key` logins omit it - backward compatible. + +## Account management + +Once logged in, manage keys, budget, and usage with `valyu account ...`. See +[account.md](account.md). The headline pattern is provisioning a budget-capped key +for a sub-agent: + +```bash +valyu account keys create --name agent --cap 5 +``` + +## Custom base URL + +The account + device endpoints default to `https://api.valyu.ai/v1/account`. +Point the CLI at a different deployment with `VALYU_ACCOUNT_API_BASE`: + +```bash +VALYU_ACCOUNT_API_BASE=https://your-account-api.example.com/v1/account valyu login +``` diff --git a/src/cli.ts b/src/cli.ts index 69f4830..93d8871 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -9,6 +9,7 @@ import { deepresearchCommand } from './commands/deepresearch/index.js'; import { workflowsCommand } from './commands/workflows/index.js'; import { batchCommand } from './commands/batch/index.js'; import { whoamiCommand } from './commands/whoami.js'; +import { accountCommand } from './commands/account.js'; import { doctorCommand } from './commands/doctor.js'; import { openCommand } from './commands/open.js'; import { upgradeCommand } from './commands/upgrade.js'; @@ -98,6 +99,7 @@ ${pc.dim('Examples:')} .addCommand(workflowsCommand) .addCommand(batchCommand) .addCommand(sourcesCommand) + .addCommand(accountCommand) .addCommand(whoamiCommand) .addCommand(doctorCommand) .addCommand(openCommand) @@ -107,7 +109,7 @@ program .parseAsync() .then(() => { const ran = program.args[0]; - if (ran === 'login' || ran === 'logout' || ran === 'upgrade') return; + if (ran === 'login' || ran === 'logout' || ran === 'upgrade' || ran === 'account') return; return checkForUpdates().catch(() => {}); }) .catch((err) => { diff --git a/src/commands/account.ts b/src/commands/account.ts new file mode 100644 index 0000000..0b57911 --- /dev/null +++ b/src/commands/account.ts @@ -0,0 +1,452 @@ +import * as p from '@clack/prompts'; +import { Command } from '@commander-js/extra-typings'; +import pc from 'picocolors'; +import { + AccountClient, + type BalanceResponse, + type CreatedKey, + type DatasetsResponse, + type MeResponse, + outputAccountError, +} from '../lib/account-client.js'; +import { openInBrowser } from '../lib/browser.js'; +import type { GlobalOpts } from '../lib/client.js'; +import { requireApiKey } from '../lib/client.js'; +import { relTime } from '../lib/format.js'; +import { outputResult } from '../lib/output.js'; +import { createSpinner } from '../lib/spinner.js'; +import { isInteractive } from '../lib/tty.js'; + +// Above this amount we confirm an interactive top-up so a typo (2500 vs 25) +// can't charge a card instantly. Agents/non-TTY are unaffected (auto-charge). +const TOPUP_CONFIRM_THRESHOLD_USD = 100; + +function client(globalOpts: GlobalOpts): AccountClient { + return new AccountClient(requireApiKey(globalOpts).key); +} + +function isJson(globalOpts: GlobalOpts): boolean { + return Boolean(globalOpts.json || globalOpts.quiet || !process.stdout.isTTY); +} + +function splitList(value: string): string[] { + return value + .split(',') + .map((s) => s.trim()) + .filter(Boolean); +} + +function fmtUsd(n: number | null | undefined): string { + if (n === null || n === undefined) return '-'; + return `$${n.toFixed(2)}`; +} + +function fmtCap(key: { + credit_cap_usd: number | null; + credit_spent_usd: number; + cap_window: string | null; +}): string { + if (key.credit_cap_usd === null || key.credit_cap_usd === undefined) return pc.dim('uncapped'); + const remaining = Math.max(0, key.credit_cap_usd - (key.credit_spent_usd ?? 0)); + const window = key.cap_window ? ` ${key.cap_window}` : ''; + return `${fmtUsd(key.credit_spent_usd)} / ${fmtUsd(key.credit_cap_usd)}${window} ${pc.dim(`(${fmtUsd(remaining)} left)`)}`; +} + +const STATUS_COLOR: Record string> = { + active: pc.green, + inactive: pc.yellow, + archived: pc.dim, + revoked: pc.red, +}; + +function colorStatus(s: string): string { + return (STATUS_COLOR[s] ?? pc.white)(s); +} + +// ─── whoami ─────────────────────────────────────────────────────────────────── + +const whoamiCmd = new Command('whoami') + .description('Show the org, tier, and calling key for the current credentials') + .action(async (_opts, cmd) => { + const globalOpts = cmd.optsWithGlobals() as GlobalOpts; + const c = client(globalOpts); + const spinner = createSpinner('Loading account...', globalOpts.quiet); + const { data, error } = await c.me(); + if (error) { + spinner.fail('Failed to load account'); + outputAccountError(error, globalOpts.json); + } + spinner.stop('Account loaded'); + const me = data as MeResponse; + + if (isJson(globalOpts)) { + outputResult(me, { json: true }); + return; + } + console.log(''); + console.log( + ` ${pc.bold('Organisation:')} ${me.name ?? pc.dim('(unnamed)')} ${pc.dim(me.org_id)}`, + ); + console.log(` ${pc.bold('Tier:')} ${pc.cyan(me.tier)}`); + console.log(` ${pc.bold('Billing:')} ${me.will_invoice ? 'invoiced' : 'pay-as-you-go'}`); + console.log( + ` ${pc.bold('Key:')} ${me.key.name ?? pc.dim('(unnamed)')} ${pc.dim(me.key.id ?? '')}`, + ); + console.log( + ` ${pc.bold('Scopes:')} ${(me.key.scopes ?? []).join(', ') || pc.dim('none')}`, + ); + if (me.key.credit_cap_usd !== null && me.key.credit_cap_usd !== undefined) { + console.log( + ` ${pc.bold('Budget:')} ${fmtUsd(me.key.credit_spent_usd)} / ${fmtUsd(me.key.credit_cap_usd)}`, + ); + } + console.log( + ` ${pc.bold('Datasets:')} ${pc.dim(`${me.allowed_datasets.length} available`)}`, + ); + console.log(''); + }); + +// ─── keys list ──────────────────────────────────────────────────────────────── + +const keysListCmd = new Command('list') + .description('List API keys for the organisation') + .action(async (_opts, cmd) => { + const globalOpts = cmd.optsWithGlobals() as GlobalOpts; + const c = client(globalOpts); + const spinner = createSpinner('Loading keys...', globalOpts.quiet); + const { data, error } = await c.listKeys(); + if (error) { + spinner.fail('Failed to load keys'); + outputAccountError(error, globalOpts.json); + } + const keys = data!.keys ?? []; + spinner.stop(`${keys.length} key${keys.length === 1 ? '' : 's'}`); + + if (isJson(globalOpts)) { + outputResult(data, { json: true }); + return; + } + if (keys.length === 0) { + console.log( + `\n ${pc.dim('No keys yet. Create one:')} ${pc.cyan('valyu account keys create --name my-agent --cap 5')}\n`, + ); + return; + } + console.log(''); + for (const k of keys) { + console.log(` ${pc.bold(k.name)} ${pc.dim(k.key_prefix)} ${colorStatus(k.status)}`); + console.log(` ${pc.dim('id')} ${k.id}`); + console.log(` ${pc.dim('scopes')} ${(k.scopes ?? []).join(', ') || pc.dim('none')}`); + console.log(` ${pc.dim('budget')} ${fmtCap(k)}`); + if (k.expires_at) console.log(` ${pc.dim('expires')} ${k.expires_at}`); + if (k.last_used_at) console.log(` ${pc.dim('used')} ${relTime(k.last_used_at)}`); + console.log(''); + } + }); + +// ─── keys create ────────────────────────────────────────────────────────────── + +const keysCreateCmd = new Command('create') + .description('Create a new API key (optionally budget-capped)') + .requiredOption('--name ', 'Human-readable key name') + .option('--cap ', 'Spend cap in USD (omit for uncapped)') + .option('--window ', 'Cap window: total | monthly', 'total') + .option( + '--scopes ', + 'Comma-separated MANAGEMENT scopes: account:read, keys:read, keys:write, billing:read, billing:write (default: none - search/data access is automatic)', + ) + .option('--type ', 'Key type: user | service_account', 'user') + .option('--rate-limit ', 'Rate limit in requests per minute') + .option('--expires ', 'Expiry as an ISO 8601 timestamp') + .addHelpText( + 'after', + ` +${pc.dim('Examples:')} + + ${pc.dim('# Budget-capped agent key - the headline pattern')} + ${pc.cyan('$ valyu account keys create --name agent --cap 5')} + + ${pc.dim('# Monthly cap, search-only (no management scopes - the agent default)')} + ${pc.cyan('$ valyu account keys create --name research-bot --cap 50 --window monthly')} + + ${pc.dim('# Grant management scopes so the key can read the account + rotate keys')} + ${pc.cyan('$ valyu account keys create --name ops --scopes account:read,keys:read,keys:write')} +`, + ) + .action(async (opts, cmd) => { + const globalOpts = cmd.optsWithGlobals() as GlobalOpts; + + const cap = opts.cap !== undefined ? Number(opts.cap) : undefined; + if (cap !== undefined && (Number.isNaN(cap) || cap <= 0)) { + outputAccountError( + { message: '--cap must be a positive number.', code: 'invalid_cap' }, + globalOpts.json, + ); + } + const window = opts.window === 'monthly' ? 'monthly' : 'total'; + const type = opts.type === 'service_account' ? 'service_account' : 'user'; + const rateLimit = opts.rateLimit !== undefined ? Number(opts.rateLimit) : undefined; + if (rateLimit !== undefined && Number.isNaN(rateLimit)) { + outputAccountError( + { message: '--rate-limit must be a number.', code: 'invalid_rate_limit' }, + globalOpts.json, + ); + } + + const c = client(globalOpts); + const spinner = createSpinner('Creating key...', globalOpts.quiet); + const { data, error } = await c.createKey({ + name: opts.name, + type, + scopes: opts.scopes ? splitList(opts.scopes) : undefined, + creditCapUsd: cap, + capWindow: cap !== undefined ? window : undefined, + rateLimitRpm: rateLimit, + expiresAt: opts.expires, + }); + if (error) { + spinner.fail('Key creation failed'); + outputAccountError(error, globalOpts.json); + } + spinner.stop('Key created'); + const key = data as CreatedKey; + + if (isJson(globalOpts)) { + outputResult(key, { json: true }); + return; + } + console.log(''); + console.log(` ${pc.green('Created key')} ${pc.bold(key.name)} ${pc.dim(key.key_prefix)}`); + console.log(` ${pc.dim('id')} ${key.id}`); + console.log(` ${pc.dim('scopes')} ${(key.scopes ?? []).join(', ') || pc.dim('none')}`); + if (key.credit_cap_usd !== null && key.credit_cap_usd !== undefined) { + console.log( + ` ${pc.dim('budget')} ${pc.bold(fmtUsd(key.credit_cap_usd))}${key.cap_window ? ` ${key.cap_window}` : ''} ${pc.dim('spend cap')}`, + ); + } else { + console.log(` ${pc.dim('budget')} ${pc.yellow('uncapped')}`); + } + console.log(''); + console.log(` ${pc.yellow('Save this secret now - it is shown only once:')}`); + console.log(''); + console.log(` ${pc.bold(key.api_key)}`); + console.log(''); + console.log( + ` ${pc.dim('Use it with:')} ${pc.dim(`VALYU_API_KEY=${key.key_prefix}... valyu search "..."`)}`, + ); + console.log(''); + }); + +// ─── keys revoke ────────────────────────────────────────────────────────────── + +const keysRevokeCmd = new Command('revoke') + .description('Revoke an API key by id (immediate, irreversible)') + .argument('', 'Key id to revoke') + .action(async (id, _opts, cmd) => { + const globalOpts = cmd.optsWithGlobals() as GlobalOpts; + const c = client(globalOpts); + const spinner = createSpinner('Revoking key...', globalOpts.quiet); + const { data, error } = await c.revokeKey(id); + if (error) { + spinner.fail('Revoke failed'); + outputAccountError(error, globalOpts.json); + } + spinner.stop('Key revoked'); + if (isJson(globalOpts)) { + outputResult(data, { json: true }); + return; + } + console.log(`\n ${pc.red('Revoked')} ${pc.dim(id)}\n`); + }); + +// ─── keys rotate ────────────────────────────────────────────────────────────── + +const keysRotateCmd = new Command('rotate') + .description('Rotate an API key: revoke the old secret, mint a new one with the same settings') + .argument('', 'Key id to rotate') + .action(async (id, _opts, cmd) => { + const globalOpts = cmd.optsWithGlobals() as GlobalOpts; + const c = client(globalOpts); + const spinner = createSpinner('Rotating key...', globalOpts.quiet); + const { data, error } = await c.rotateKey(id); + if (error) { + spinner.fail('Rotate failed'); + outputAccountError(error, globalOpts.json); + } + spinner.stop('Key rotated'); + if (isJson(globalOpts)) { + outputResult(data, { json: true }); + return; + } + console.log(''); + console.log( + ` ${pc.green('Rotated')} ${pc.dim(data!.rotated_from)} ${pc.dim('->')} ${pc.bold(data!.key_prefix)}`, + ); + console.log(''); + console.log(` ${pc.yellow('New secret - shown only once:')}`); + console.log(''); + console.log(` ${pc.bold(data!.api_key)}`); + console.log(''); + }); + +const keysCmd = new Command('keys') + .description('Manage API keys') + .addCommand(keysListCmd, { isDefault: true }) + .addCommand(keysCreateCmd) + .addCommand(keysRevokeCmd) + .addCommand(keysRotateCmd); + +// ─── balance ────────────────────────────────────────────────────────────────── + +const balanceCmd = new Command('balance') + .description('Show the organisation credit balance and usage') + .action(async (_opts, cmd) => { + const globalOpts = cmd.optsWithGlobals() as GlobalOpts; + const c = client(globalOpts); + const spinner = createSpinner('Loading balance...', globalOpts.quiet); + const { data, error } = await c.balance(); + if (error) { + spinner.fail('Failed to load balance'); + outputAccountError(error, globalOpts.json); + } + spinner.stop('Balance loaded'); + const b = data as BalanceResponse; + + if (isJson(globalOpts)) { + outputResult(b, { json: true }); + return; + } + console.log(''); + console.log(` ${pc.bold('Balance:')} ${pc.green(fmtUsd(b.credit_balance_usd))}`); + console.log(` ${pc.bold('PAYG used:')} ${fmtUsd(b.payg_usage_usd)}`); + if (b.signup_credits_remaining_usd > 0) { + console.log( + ` ${pc.bold('Free:')} ${fmtUsd(b.signup_credits_remaining_usd)} ${pc.dim('signup credits')}`, + ); + } + if (b.credit_limit_enabled && b.credit_limit_usd !== null) { + console.log(` ${pc.bold('Limit:')} ${fmtUsd(b.credit_limit_usd)}`); + } + console.log(` ${pc.bold('Tier:')} ${pc.cyan(b.tier)}`); + if (b.credit_balance_usd <= 0) { + console.log(`\n ${pc.dim('Top up:')} ${pc.cyan('valyu account topup 25')}`); + } + console.log(''); + }); + +// ─── topup ──────────────────────────────────────────────────────────────────── + +const topupCmd = new Command('topup') + .description('Add credits - charges your card on file, or returns a checkout link if none') + .argument('', 'Amount in USD to add') + .option('--open', 'Open the checkout URL in the browser (only when a checkout link is returned)') + .option('-y, --yes', 'Skip the confirmation prompt for large top-ups') + .action(async (amount, opts, cmd) => { + const globalOpts = cmd.optsWithGlobals() as GlobalOpts; + const amt = Number(amount); + if (Number.isNaN(amt) || amt <= 0) { + outputAccountError( + { message: 'amount must be a positive number.', code: 'invalid_amount' }, + globalOpts.json, + ); + } + + // Guard against fat-finger charges: confirm large amounts in an interactive + // terminal unless --yes. Non-TTY/agent mode auto-charges as before. + if (!opts.yes && amt > TOPUP_CONFIRM_THRESHOLD_USD && isInteractive() && !globalOpts.json) { + const confirmed = await p.confirm({ message: `Charge ${fmtUsd(amt)} to your card on file?` }); + if (p.isCancel(confirmed) || !confirmed) { + p.cancel('Top-up cancelled.'); + process.exit(0); + } + } + + const c = client(globalOpts); + const spinner = createSpinner('Processing top-up...', globalOpts.quiet); + const { data, error } = await c.topup(amt); + if (error) { + spinner.fail('Top-up failed'); + outputAccountError(error, globalOpts.json); + } + spinner.stop('Top-up ready'); + + if (isJson(globalOpts)) { + outputResult(data, { json: true }); + return; + } + console.log(''); + if (data!.charged) { + console.log( + ` ${pc.green('✓')} ${pc.bold('Added')} ${pc.green(fmtUsd(data!.amount_usd))} ${pc.bold('to your balance')} ${pc.dim('(charged your card on file).')}`, + ); + console.log(''); + return; + } + console.log( + ` ${pc.bold('Add')} ${pc.green(fmtUsd(data!.amount_usd))} ${pc.bold('credits - complete payment:')}`, + ); + console.log(''); + console.log(` ${pc.cyan(data!.checkout_url)}`); + console.log(''); + console.log(` ${pc.dim('Credits land via the Stripe webhook once payment completes.')}`); + console.log(''); + if (opts.open && data!.checkout_url) await openInBrowser(data!.checkout_url); + }); + +// ─── datasets ───────────────────────────────────────────────────────────────── + +const datasetsCmd = new Command('datasets') + .description('Show the datasets this key/org can reach and the tier ladder') + .action(async (_opts, cmd) => { + const globalOpts = cmd.optsWithGlobals() as GlobalOpts; + const c = client(globalOpts); + const spinner = createSpinner('Loading datasets...', globalOpts.quiet); + const { data, error } = await c.datasets(); + if (error) { + spinner.fail('Failed to load datasets'); + outputAccountError(error, globalOpts.json); + } + spinner.stop('Datasets loaded'); + const d = data as DatasetsResponse; + + if (isJson(globalOpts)) { + outputResult(d, { json: true }); + return; + } + console.log(''); + console.log(` ${pc.bold('Tier:')} ${pc.cyan(d.tier)}`); + console.log(` ${pc.bold('Available datasets')} ${pc.dim(`(${d.available_datasets.length})`)}`); + console.log(` ${d.available_datasets.join(', ')}`); + console.log(''); + console.log(` ${pc.bold('Tier ladder')}`); + for (const t of d.tiers) { + const marker = t.tier === d.tier ? pc.green(' (current)') : ''; + console.log( + ` ${pc.cyan(t.tier.padEnd(7))} ${pc.dim(t.price.padEnd(9))} +${t.adds.join(', ')}${marker}`, + ); + } + console.log(''); + }); + +// ─── group ──────────────────────────────────────────────────────────────────── + +export const accountCommand = new Command('account') + .description('Manage your Valyu account: keys, balance, top-ups, datasets') + .addHelpText( + 'after', + ` +${pc.dim('Examples:')} + + ${pc.cyan('$ valyu account whoami')} + ${pc.cyan('$ valyu account keys create --name agent --cap 5')} + ${pc.cyan('$ valyu account keys list')} + ${pc.cyan('$ valyu account balance')} + ${pc.cyan('$ valyu account topup 25')} + ${pc.cyan('$ valyu account datasets')} +`, + ) + .addCommand(whoamiCmd) + .addCommand(keysCmd) + .addCommand(balanceCmd) + .addCommand(topupCmd) + .addCommand(datasetsCmd); diff --git a/src/commands/auth/login.ts b/src/commands/auth/login.ts index 5f5e25a..0e207e9 100644 --- a/src/commands/auth/login.ts +++ b/src/commands/auth/login.ts @@ -3,97 +3,256 @@ import { Command } from '@commander-js/extra-typings'; import pc from 'picocolors'; import type { GlobalOpts } from '../../lib/client.js'; import { ValyuClient, PLATFORM_URL } from '../../lib/client.js'; -import { storeApiKey, getCredentialsPath } from '../../lib/config.js'; +import { + AccountClient, + DEFAULT_DEVICE_SCOPE, + type AccountApiError, + type DeviceCodeResponse, +} from '../../lib/account-client.js'; +import { storeApiKey } from '../../lib/config.js'; import { outputError, outputResult } from '../../lib/output.js'; import { createSpinner } from '../../lib/spinner.js'; +import { openInBrowser } from '../../lib/browser.js'; import { isInteractive } from '../../lib/tty.js'; const API_KEYS_URL = `${PLATFORM_URL}/user/account/apikeys`; +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + export const loginCommand = new Command('login') - .description('Save a Valyu API key') - .option('--key ', 'API key to store (required in non-interactive mode)') + .description('Authenticate the CLI (device flow by default; mints a scoped Valyu key)') + .option('--key ', 'Authenticate with an existing API key (manual / CI mode)') + .option('--device', 'Force the browser device flow (default when --key is omitted)') + .option('--no-browser', 'Do not auto-open the browser; print the URL to visit instead') + .option('--scope ', 'Space-separated scopes to request for the minted key') .option('--profile ', 'Profile name to save the key under (default: "default")') .addHelpText( 'after', ` ${pc.dim('Examples:')} - ${pc.dim('$ valyu login')} - ${pc.dim('$ valyu login --key val_xxx')} - ${pc.dim('$ VALYU_API_KEY=val_xxx valyu login --key $VALYU_API_KEY')} + ${pc.dim('# Browser device flow - mints a scoped val_ key, nothing to paste')} + ${pc.cyan('$ valyu login')} + + ${pc.dim('# Headless device flow (prints the URL + code, no browser)')} + ${pc.cyan('$ valyu login --no-browser')} + + ${pc.dim('# Agent / CI: parse line-delimited JSON events')} + ${pc.cyan('$ valyu login --json')} + + ${pc.dim('# Manual / CI with an existing key')} + ${pc.cyan('$ valyu login --key val_xxx')} `, ) .action(async (opts, cmd) => { - const globalOpts = cmd.optsWithGlobals() as GlobalOpts & { key?: string; profile?: string }; + const globalOpts = cmd.optsWithGlobals() as GlobalOpts & { + key?: string; + device?: boolean; + browser?: boolean; + scope?: string; + profile?: string; + }; const profileName = opts.profile ?? globalOpts.profile ?? 'default'; - let apiKey = opts.key?.trim(); - - if (!apiKey) { - if (!isInteractive() || globalOpts.json) { - outputError( - { - message: 'Missing --key flag. Provide your Valyu API key in non-interactive mode.', - code: 'missing_key', - }, - { json: globalOpts.json }, - ); - } + const manualKey = opts.key?.trim(); - p.intro(`${pc.cyan('Valyu')} Authentication`); + // A supplied --key uses the manual path; otherwise (and with explicit + // --device) we run the RFC 8628 browser device flow, the default. + if (manualKey) { + await manualLogin(manualKey, profileName, globalOpts); + return; + } - p.note( - `Get your free API key at ${pc.cyan(API_KEYS_URL)}`, - 'Where to get your key', - ); + await deviceLogin(profileName, globalOpts, { + scope: opts.scope, + openBrowser: opts.browser !== false, + }); + }); - const result = await p.password({ - message: 'Enter your Valyu API key:', - validate: (value) => { - if (!value) return 'API key is required'; - if (value.length < 16) return 'API key appears too short'; - return undefined; - }, - }); +// ─── Device flow (default) ──────────────────────────────────────────────────── - if (p.isCancel(result)) { - p.cancel('Login cancelled.'); - process.exit(0); - } +function isJsonMode(globalOpts: GlobalOpts): boolean { + return Boolean(globalOpts.json || globalOpts.quiet || !isInteractive()); +} - apiKey = result.trim(); +function emitEvent(obj: Record): void { + // Single line per event so agents can parse the stream incrementally. + process.stdout.write(JSON.stringify(obj) + '\n'); +} + +async function deviceLogin( + profile: string, + globalOpts: GlobalOpts, + flow: { scope?: string; openBrowser: boolean }, +): Promise { + const json = isJsonMode(globalOpts); + const client = new AccountClient(); + + const failDevice = (err: AccountApiError): never => { + if (json) { + emitEvent({ event: 'auth_error', error: { message: err.message, code: err.code, hint: err.hint } }); + process.exit(1); } + outputError({ message: err.message, code: err.code }, { json: false }); + }; - if (!apiKey || apiKey.length < 16) { - outputError( - { message: 'Invalid API key format.', code: 'invalid_key_format' }, - { json: globalOpts.json }, + // 1. Request a device + user code. + const { data: dc, error: codeErr } = await client.deviceCode(flow.scope ?? DEFAULT_DEVICE_SCOPE); + if (codeErr || !dc) { + failDevice(codeErr ?? { message: 'Failed to start device authorization' }); + return; + } + + // 2. Surface the user code + verification URL, then open the browser. + if (json) { + emitEvent({ + event: 'device_code', + user_code: dc.user_code, + verification_uri: dc.verification_uri, + verification_uri_complete: dc.verification_uri_complete, + expires_in: dc.expires_in, + interval: dc.interval, + }); + } else { + printDevicePrompt(dc); + } + + if (flow.openBrowser && !json) { + await openInBrowser(dc.verification_uri_complete); + } + + // 3. Poll the token endpoint honouring interval / slow_down / expires_in. + const spinner = json ? null : createSpinner('Waiting for you to approve in the browser...', globalOpts.quiet); + const deadline = Date.now() + dc.expires_in * 1000; + let interval = dc.interval; + let attempt = 0; + + while (Date.now() < deadline) { + await sleep(interval * 1000); + attempt += 1; + const poll = await client.deviceToken(dc.device_code); + + if (poll.status === 'success') { + const token = poll.token; + const configPath = storeApiKey(token.access_token, profile, token.valyu_key_id ?? undefined); + if (json) { + emitEvent({ + event: 'auth_success', + profile, + valyu_key_id: token.valyu_key_id, + key_prefix: token.key_prefix, + scope: token.scope, + config_path: configPath, + }); + return; + } + spinner?.stop('Authorized'); + p.outro( + `Logged in as ${pc.cyan(token.key_prefix)} - key stored for profile ${pc.cyan(`'${profile}'`)}`, ); + return; } - const spinner = createSpinner('Validating API key...', globalOpts.quiet); + if (poll.status === 'slow_down') { + interval += 5; // RFC 8628: back off by 5s cumulatively. + if (json) emitEvent({ event: 'auth_waiting', status: 'slow_down', attempt, interval }); + continue; + } + if (poll.status === 'pending') { + if (json) emitEvent({ event: 'auth_waiting', status: 'authorization_pending', attempt }); + continue; + } + if (poll.status === 'denied') { + spinner?.fail('Authorization denied'); + failDevice({ message: 'Authorization was denied in the browser.', code: 'access_denied' }); + return; + } + if (poll.status === 'expired') { + spinner?.fail('Device code expired'); + failDevice({ message: 'The device code expired. Run `valyu login` again.', code: 'expired_token' }); + return; + } + // status === 'error' + spinner?.fail('Login failed'); + failDevice(poll.error); + return; + } - const client = new ValyuClient(apiKey); - const { valid, error } = await client.validateKey(); + spinner?.fail('Device code expired'); + failDevice({ message: 'Timed out waiting for approval. Run `valyu login` again.', code: 'expired_token' }); +} - if (!valid) { - spinner.fail('API key validation failed'); +function printDevicePrompt(dc: DeviceCodeResponse): void { + p.intro(`${pc.cyan('Valyu')} Authentication`); + p.note( + `${pc.dim('1.')} Open ${pc.cyan(dc.verification_uri)}\n` + + `${pc.dim('2.')} Enter the code below and approve\n\n` + + ` ${pc.bold(pc.cyan(dc.user_code))}\n\n` + + `${pc.dim('Or open directly:')} ${pc.dim(dc.verification_uri_complete)}`, + 'Confirm this code matches your screen, then approve', + ); +} + +// ─── Manual key flow (--key / CI) ───────────────────────────────────────────── + +async function manualLogin( + providedKey: string | undefined, + profileName: string, + globalOpts: GlobalOpts, +): Promise { + let apiKey = providedKey; + + if (!apiKey) { + if (!isInteractive() || globalOpts.json) { outputError( - { message: error ?? 'Invalid API key', code: 'validation_failed' }, + { + message: 'Missing --key flag. Provide your Valyu API key in non-interactive mode.', + code: 'missing_key', + }, { json: globalOpts.json }, ); } - spinner.stop('API key is valid'); + p.intro(`${pc.cyan('Valyu')} Authentication`); + p.note(`Get your free API key at ${pc.cyan(API_KEYS_URL)}`, 'Where to get your key'); - const configPath = storeApiKey(apiKey, profileName); + const result = await p.password({ + message: 'Enter your Valyu API key:', + validate: (value) => { + if (!value) return 'API key is required'; + if (value.length < 16) return 'API key appears too short'; + return undefined; + }, + }); - if (globalOpts.json || !isInteractive()) { - outputResult( - { success: true, config_path: configPath, profile: profileName }, - { json: true }, - ); - } else { - p.outro(`Logged in. API key stored for profile ${pc.cyan(`'${profileName}'`)}`); + if (p.isCancel(result)) { + p.cancel('Login cancelled.'); + process.exit(0); } - }); + + apiKey = result.trim(); + } + + if (!apiKey || apiKey.length < 16) { + outputError({ message: 'Invalid API key format.', code: 'invalid_key_format' }, { json: globalOpts.json }); + } + + const spinner = createSpinner('Validating API key...', globalOpts.quiet); + const client = new ValyuClient(apiKey); + const { valid, error } = await client.validateKey(); + + if (!valid) { + spinner.fail('API key validation failed'); + outputError({ message: error ?? 'Invalid API key', code: 'validation_failed' }, { json: globalOpts.json }); + } + + spinner.stop('API key is valid'); + + // Manual keys: we don't know the server-side key id, so no valyu_key_id. + const configPath = storeApiKey(apiKey, profileName); + + if (globalOpts.json || !isInteractive()) { + outputResult({ success: true, config_path: configPath, profile: profileName }, { json: true }); + } else { + p.outro(`Logged in. API key stored for profile ${pc.cyan(`'${profileName}'`)}`); + } +} diff --git a/src/commands/auth/logout.ts b/src/commands/auth/logout.ts index 26f8a82..164d4b9 100644 --- a/src/commands/auth/logout.ts +++ b/src/commands/auth/logout.ts @@ -1,17 +1,26 @@ import * as p from '@clack/prompts'; import { Command } from '@commander-js/extra-typings'; +import { AccountClient } from '../../lib/account-client.js'; import type { GlobalOpts } from '../../lib/client.js'; -import { removeApiKey, listProfiles } from '../../lib/config.js'; +import { + getActiveProfile, + getValyuKeyId, + listProfiles, + removeApiKey, + resolveApiKey, +} from '../../lib/config.js'; import { outputError, outputResult } from '../../lib/output.js'; import { isInteractive } from '../../lib/tty.js'; export const logoutCommand = new Command('logout') - .description('Remove stored API key') + .description('Remove stored credentials locally (use --revoke to also kill the key server-side)') .option('--profile ', 'Profile to remove (default: all profiles)') + .option('--revoke', 'Also revoke the device-minted key server-side (best-effort)') .option('--yes', 'Skip confirmation prompt') .action(async (opts, cmd) => { const globalOpts = cmd.optsWithGlobals() as GlobalOpts & { profile?: string; + revoke?: boolean; yes?: boolean; }; const profile = opts.profile ?? globalOpts.profile; @@ -21,27 +30,95 @@ export const logoutCommand = new Command('logout') const what = profile ? `profile '${profile}'` : `all ${profiles.length} profile(s): ${profiles.join(', ')}`; - const confirmed = await p.confirm({ - message: `Remove ${what}?`, - }); + const confirmed = await p.confirm({ message: `Remove ${what}?` }); if (p.isCancel(confirmed) || !confirmed) { p.cancel('Logout cancelled.'); process.exit(0); } } + // By default logout only forgets local credentials (like gh/aws/docker). + // With --revoke, also kill the key minted by `valyu login` server-side for + // the target profile (or the active one). Never blocks logout on a network + // error, but the revoke OUTCOME is always surfaced (not swallowed) — this is + // the command used to kill a leaked key, so a silent failure is dangerous. + // revoke_status: 'revoked' | 'failed' | 'skipped' | 'not_requested'. + let revoked: string | null = null; + let revokeStatus: 'revoked' | 'failed' | 'skipped' | 'not_requested' = 'not_requested'; + let revokeError: string | null = null; + if (opts.revoke) { + const target = profile ?? getActiveProfile(); + const keyId = getValyuKeyId(target); + const resolved = resolveApiKey(globalOpts.apiKey, target); + if (!keyId || !resolved) { + // No device-minted key id stored (e.g. a `--key` login) — nothing to + // revoke server-side. Report it rather than implying success. + revokeStatus = 'skipped'; + revokeError = !keyId + ? 'no stored valyu_key_id for this profile (not a device-minted login)' + : 'no API key available to authenticate the revoke call'; + } else { + try { + const { data } = await new AccountClient(resolved.key).revokeKey(keyId); + if (data?.status === 'revoked') { + revoked = keyId; + revokeStatus = 'revoked'; + } else { + revokeStatus = 'failed'; + revokeError = `server did not confirm revocation (status: ${data?.status ?? 'unknown'})`; + } + } catch (err) { + // Do NOT block logout, but surface the failure — the key may still be live. + revokeStatus = 'failed'; + revokeError = err instanceof Error ? err.message : String(err); + } + } + } + const removed = removeApiKey(profile); if (!removed) { outputError( - { message: profile ? `Profile '${profile}' not found` : 'No credentials found', code: 'not_found' }, + { + message: profile ? `Profile '${profile}' not found` : 'No credentials found', + code: 'not_found', + }, { json: globalOpts.json }, ); } if (globalOpts.json || !isInteractive()) { - outputResult({ success: true, profile: profile ?? 'all' }, { json: true }); + outputResult( + { + success: true, + profile: profile ?? 'all', + revoked, + revoke_status: revokeStatus, + revoke_error: revokeError, + }, + { json: true }, + ); } else { - console.log(profile ? ` Removed profile '${profile}'` : ' Logged out. All credentials removed.'); + const suffix = revoked ? ' (key revoked server-side)' : ''; + console.log( + profile + ? ` Removed profile '${profile}'${suffix}` + : ` Logged out. All credentials removed.${suffix}`, + ); + // Surface a non-success revoke outcome explicitly — never let --revoke + // imply the key is dead when it might still be live. + if (opts.revoke && revokeStatus === 'failed') { + console.error( + ` WARNING: server-side key revocation FAILED${revokeError ? ` (${revokeError})` : ''}.`, + ); + console.error( + ' The key may still be active — revoke it manually with `valyu keys revoke`.', + ); + } else if (opts.revoke && revokeStatus === 'skipped') { + console.error( + ` WARNING: --revoke was requested but skipped${revokeError ? ` (${revokeError})` : ''}.`, + ); + console.error(' Local credentials were removed, but no key was revoked server-side.'); + } } }); diff --git a/src/lib/account-client.ts b/src/lib/account-client.ts new file mode 100644 index 0000000..b5d69f9 --- /dev/null +++ b/src/lib/account-client.ts @@ -0,0 +1,374 @@ +import { randomUUID } from 'node:crypto'; +import pc from 'picocolors'; +import { VERSION } from './version.js'; + +// Control-plane base. Overridable for staging via VALYU_ACCOUNT_API_BASE. +export const ACCOUNT_API_BASE = ( + process.env.VALYU_ACCOUNT_API_BASE ?? 'https://api.valyu.ai/v1/account' +).replace(/\/+$/, ''); + +// Fixed public CLI client_id for the RFC 8628 device flow. +export const CLI_CLIENT_ID = process.env.VALYU_CLI_CLIENT_ID ?? 'val_cli_public'; + +// Default requested management scopes for device login: everything a +// self-provisioning agent needs EXCEPT billing:write (money movement is opt-in +// on the consent page). Search/data access is implicit in holding a valid key, +// so it is NOT a scope here. +export const DEFAULT_DEVICE_SCOPE = 'account:read keys:read keys:write billing:read'; + +const DEVICE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code'; + +// ─── Error shape (RFC 9457 + recovery envelope) ────────────────────────────── + +export interface AccountApiError { + message: string; + code?: string; + hint?: string; + status?: number; + retryable?: boolean; + recovery?: Record; + fieldErrors?: Array>; +} + +type RawEnvelope = { + code?: string; + title?: string; + detail?: string; + hint?: string; + retryable?: boolean; + recovery?: Record; + field_errors?: Array>; + error?: string; + message?: string; +}; + +function parseAccountError(status: number, body: RawEnvelope | null): AccountApiError { + if (body && typeof body === 'object') { + // RFC 9457 account envelope (has a machine `code`). + if (body.code) { + return { + message: body.detail ?? body.title ?? `HTTP ${status}`, + code: body.code, + hint: body.hint, + status, + retryable: body.retryable, + recovery: body.recovery, + fieldErrors: body.field_errors, + }; + } + // RFC 8628 device form, or a plain {error|message} body. + if (body.error) return { message: body.error, code: body.error, status }; + if (body.message) return { message: body.message, status, code: `http_${status}` }; + } + return { message: `HTTP ${status}`, status, code: `http_${status}` }; +} + +// ─── Response types for the api.valyu.ai/v1/account/* endpoints ─ + +export interface DeviceCodeResponse { + device_code: string; + user_code: string; + verification_uri: string; + verification_uri_complete: string; + expires_in: number; + interval: number; +} + +export interface DeviceTokenResponse { + access_token: string; + token_type: string; + scope: string; + valyu_key_id: string | null; + key_prefix: string; +} + +// Single-poll result for the device token endpoint. The CLI loop branches on +// `status` to honour RFC 8628 polling discipline. +export type DeviceTokenPoll = + | { status: 'success'; token: DeviceTokenResponse } + | { status: 'pending' } + | { status: 'slow_down' } + | { status: 'denied' } + | { status: 'expired' } + | { status: 'error'; error: AccountApiError }; + +export interface AccountKey { + id: string; + name: string; + key_prefix: string; + status: string; + type: string; + created_by?: { id: string | null; type: string }; + created_at?: string | null; + last_used_at?: string | null; + scopes: string[]; + credit_cap_usd: number | null; + credit_spent_usd: number; + cap_window: string | null; + rate_limit_rpm: number | null; + expires_at: string | null; +} + +export interface CreatedKey extends Partial { + id: string; + api_key: string; + key_prefix: string; + name: string; + scopes: string[]; + credit_cap_usd: number | null; + cap_window: string | null; + status: string; +} + +export interface RotatedKey { + id: string; + api_key: string; + key_prefix: string; + status: string; + rotated_from: string; +} + +export interface RevokedKey { + id: string; + status: string; +} + +export interface MeResponse { + org_id: string; + name: string | null; + tier: string; + allowed_datasets: string[]; + will_invoice: boolean; + grace_period_ends: string | null; + key: { + id: string | null; + name: string | null; + scopes: string[]; + credit_cap_usd: number | null; + credit_spent_usd: number | null; + }; +} + +export interface BalanceResponse { + credit_balance_usd: number; + payg_usage_usd: number; + signup_credits_remaining_usd: number; + credit_limit_usd: number | null; + credit_limit_enabled: boolean; + will_invoice: boolean; + tier: string; +} + +export interface TopupResponse { + charged?: boolean; + amount_usd: number; + message?: string; + checkout_url?: string; + expires_at?: string | null; +} + +export interface DatasetsResponse { + tier: string; + available_datasets: string[]; + tiers: Array<{ tier: string; price: string; adds: string[] }>; +} + +export interface CreateKeyOpts { + name: string; + type?: 'user' | 'service_account'; + scopes?: string[]; + creditCapUsd?: number; + capWindow?: 'total' | 'monthly'; + rateLimitRpm?: number; + expiresAt?: string; +} + +type Result = { data: T | null; error: AccountApiError | null }; + +// ─── Client ────────────────────────────────────────────────────────────────── + +export class AccountClient { + private apiKey?: string; + + // apiKey is optional: the device-flow endpoints are public. + constructor(apiKey?: string) { + this.apiKey = apiKey; + } + + private async request( + method: 'GET' | 'POST' | 'DELETE', + path: string, + opts: { + body?: Record; + query?: Record; + headers?: Record; + auth?: boolean; + } = {}, + ): Promise> { + const headers: Record = { + 'User-Agent': `valyu-cli/${VERSION}`, + ...opts.headers, + }; + if (opts.body !== undefined) headers['Content-Type'] = 'application/json'; + if (opts.auth !== false && this.apiKey) headers['x-api-key'] = this.apiKey; + + let url = `${ACCOUNT_API_BASE}${path}`; + if (opts.query) { + const qs = new URLSearchParams(); + for (const [k, v] of Object.entries(opts.query)) { + if (v !== undefined) qs.set(k, v); + } + const s = qs.toString(); + if (s) url += `?${s}`; + } + + let res: Response; + try { + res = await fetch(url, { + method, + headers, + body: opts.body !== undefined ? JSON.stringify(stripUndefined(opts.body)) : undefined, + }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Network error'; + return { data: null, error: { message, code: 'network_error' } }; + } + + let parsed: unknown = null; + const text = await res.text(); + if (text) { + try { + parsed = JSON.parse(text); + } catch { + parsed = null; + } + } + + if (!res.ok) { + return { data: null, error: parseAccountError(res.status, parsed as RawEnvelope) }; + } + return { data: (parsed as T) ?? null, error: null }; + } + + // ── Device flow (public, no auth) ── + + async deviceCode(scope?: string): Promise> { + return this.request('POST', '/device/code', { + auth: false, + body: { client_id: CLI_CLIENT_ID, scope: scope ?? DEFAULT_DEVICE_SCOPE }, + }); + } + + // One poll of the token endpoint. The device endpoints return RFC 8628 + // `{"error":"..."}` with HTTP 400, so we translate those into poll states. + async deviceToken(deviceCode: string): Promise { + const { data, error } = await this.request('POST', '/device/token', { + auth: false, + body: { grant_type: DEVICE_GRANT_TYPE, device_code: deviceCode, client_id: CLI_CLIENT_ID }, + }); + if (data && data.access_token) return { status: 'success', token: data }; + const code = error?.code; + if (code === 'authorization_pending') return { status: 'pending' }; + if (code === 'slow_down') return { status: 'slow_down' }; + if (code === 'access_denied') return { status: 'denied' }; + if (code === 'expired_token') return { status: 'expired' }; + return { status: 'error', error: error ?? { message: 'Unknown device token error' } }; + } + + // ── Account (auth required) ── + + async me(): Promise> { + return this.request('GET', '/me'); + } + + async listKeys(): Promise> { + return this.request<{ keys: AccountKey[] }>('GET', '/keys'); + } + + async createKey(opts: CreateKeyOpts): Promise> { + return this.request('POST', '/keys', { + headers: { 'Idempotency-Key': randomUUID() }, + body: { + name: opts.name, + type: opts.type, + scopes: opts.scopes, + credit_cap_usd: opts.creditCapUsd, + cap_window: opts.capWindow, + rate_limit_rpm: opts.rateLimitRpm, + expires_at: opts.expiresAt, + }, + }); + } + + async revokeKey(id: string): Promise> { + return this.request('DELETE', `/keys/${encodeURIComponent(id)}`); + } + + async rotateKey(id: string): Promise> { + // The server requires an Idempotency-Key on key mutations (rotate mints a + // new secret); without one it 400s. A fresh UUID is fine here because the + // CLI invokes rotate once per call. + return this.request('POST', `/keys/${encodeURIComponent(id)}`, { + headers: { 'Idempotency-Key': randomUUID() }, + body: { action: 'rotate' }, + }); + } + + async updateKey( + id: string, + patch: { name?: string; status?: 'active' | 'inactive' | 'archived' }, + ): Promise> { + return this.request('POST', `/keys/${encodeURIComponent(id)}`, { body: patch }); + } + + async balance(): Promise> { + return this.request('GET', '/balance'); + } + + // A caller-supplied idempotency key makes a retry safe: reuse the SAME key on + // a retry and the server collapses it to a single charge. When none is given + // we default to a fresh UUID (each call is a distinct, intentional top-up, so + // we must NOT auto-derive a stable key from the amount). + async topup(amountUsd: number, idempotencyKey?: string): Promise> { + return this.request('POST', '/balance/topup', { + headers: { 'Idempotency-Key': idempotencyKey ?? randomUUID() }, + body: { amount_usd: amountUsd }, + }); + } + + async datasets(): Promise> { + return this.request('GET', '/datasets'); + } +} + +function stripUndefined(obj: Record): Record { + return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined)); +} + +// Render an account error consistently. JSON mode preserves code/hint/recovery +// (which agents use to self-heal); human mode prints message + hint. +export function outputAccountError(err: AccountApiError, json?: boolean): never { + const asJson = json || !process.stdout.isTTY; + if (asJson) { + console.error( + JSON.stringify( + { + error: { + message: err.message, + code: err.code ?? 'unknown', + hint: err.hint, + retryable: err.retryable, + recovery: err.recovery, + field_errors: err.fieldErrors, + }, + }, + null, + 2, + ), + ); + } else { + console.error(`${pc.red('Error:')} ${err.message}`); + if (err.hint) console.error(` ${pc.dim('Hint:')} ${err.hint}`); + } + process.exit(1); +} diff --git a/src/lib/config.ts b/src/lib/config.ts index 493b130..96c7f82 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -1,4 +1,5 @@ import { + chmodSync, existsSync, mkdirSync, readFileSync, @@ -11,10 +12,12 @@ import { join } from 'node:path'; export type ApiKeySource = 'flag' | 'env' | 'config'; export type ResolvedKey = { key: string; source: ApiKeySource }; +export type ProfileEntry = { api_key?: string; valyu_key_id?: string }; + export type CredentialsFile = { api_key?: string; active_profile: string; - profiles: Record; + profiles: Record; }; export function getConfigDir(): string { @@ -48,8 +51,17 @@ export function readCredentials(): CredentialsFile | null { export function writeCredentials(data: CredentialsFile): void { const dir = getConfigDir(); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - writeFileSync(getCredentialsPath(), JSON.stringify(data, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 }); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 }); + const path = getCredentialsPath(); + writeFileSync(path, JSON.stringify(data, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 }); + // mode on writeFileSync only applies on CREATE; re-assert 0o600 every write so a + // pre-existing looser-permissioned file (legacy build / out-of-band) is tightened. + try { + chmodSync(path, 0o600); + chmodSync(dir, 0o700); + } catch { + // best-effort (e.g. Windows) - the plaintext key write already succeeded. + } } export function resolveApiKey(flagValue?: string, profile?: string): ResolvedKey | null { @@ -66,14 +78,29 @@ export function resolveApiKey(flagValue?: string, profile?: string): ResolvedKey return null; } -export function storeApiKey(apiKey: string, profile = 'default'): string { +export function storeApiKey(apiKey: string, profile = 'default', valyuKeyId?: string): string { const existing = readCredentials() ?? { active_profile: 'default', profiles: {} }; - existing.profiles[profile] = { api_key: apiKey }; + const prev = existing.profiles[profile] ?? {}; + existing.profiles[profile] = { ...prev, api_key: apiKey }; + // Track the server-side key id so `valyu logout` can revoke it. Only set when + // provided; manual `--key` logins won't know it, and that's fine. + if (valyuKeyId !== undefined) { + existing.profiles[profile].valyu_key_id = valyuKeyId; + } else { + delete existing.profiles[profile].valyu_key_id; + } existing.active_profile = profile; writeCredentials(existing); return getCredentialsPath(); } +export function getValyuKeyId(profile?: string): string | undefined { + const creds = readCredentials(); + if (!creds) return undefined; + const name = profile ?? creds.active_profile ?? 'default'; + return creds.profiles[name]?.valyu_key_id; +} + export function removeApiKey(profile?: string): boolean { const creds = readCredentials(); if (!creds) return false; diff --git a/src/lib/version.ts b/src/lib/version.ts index 30f4e08..635ec41 100644 --- a/src/lib/version.ts +++ b/src/lib/version.ts @@ -1,2 +1,2 @@ -export const VERSION = '1.1.0'; +export const VERSION = '1.2.0'; export const PACKAGE_NAME = '@valyu/cli';