From 4aaef58cb8476af02414fa7a634b8268b11bc5ee Mon Sep 17 00:00:00 2001 From: yorkeccak Date: Thu, 25 Jun 2026 19:20:37 +0100 Subject: [PATCH 1/6] Add device-OAuth login and account command group `valyu login` now defaults to the RFC 8628 device flow (browser approval, no key pasting); `--key` is preserved for manual/CI. On success it stores the minted val_ key and its id, and emits line-delimited JSON events (device_code/auth_waiting/auth_success) so agents can drive it headlessly. `valyu logout` best-effort revokes the key server-side. New `valyu account` group against api.valyu.ai/v1/account/*: whoami, keys list|create|revoke|rotate, balance, topup, usage, datasets. `keys create --cap ` is the budget-capped agent-key flow: mint a key that cannot spend past the cap, hand it to an autonomous agent. Errors surface the RFC 9457 machine code + hint. - src/lib/account-client.ts: AccountClient + ACCOUNT_API_BASE (VALYU_ACCOUNT_API_BASE override) - src/commands/account.ts, src/commands/auth/{login,logout}.ts, src/cli.ts - src/lib/config.ts: profiles optionally store valyu_key_id (backward compatible) - skill docs updated; version 1.2.0 --- package.json | 2 +- skills/valyu-cli/SKILL.md | 36 +- skills/valyu-cli/references/account.md | 105 ++++++ skills/valyu-cli/references/auth.md | 70 +++- src/cli.ts | 4 +- src/commands/account.ts | 452 +++++++++++++++++++++++++ src/commands/auth/login.ts | 273 +++++++++++---- src/commands/auth/logout.ts | 43 ++- src/lib/account-client.ts | 396 ++++++++++++++++++++++ src/lib/config.ts | 23 +- src/lib/version.ts | 2 +- 11 files changed, 1326 insertions(+), 80 deletions(-) create mode 100644 skills/valyu-cli/references/account.md create mode 100644 src/commands/account.ts create mode 100644 src/lib/account-client.ts 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..881019f 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, usage, datasets +│ ├── whoami # org, tier, calling key + budget +│ ├── keys list / create / revoke / rotate # create --cap = budget-capped agent key +│ ├── balance / topup # credits (topup returns a Stripe checkout URL) +│ ├── usage / datasets # spend over time + 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/datasets 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, usage, 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..03fabd6 --- /dev/null +++ b/skills/valyu-cli/references/account.md @@ -0,0 +1,105 @@ +# valyu account + +Self-service account management: API keys (with budget caps + dataset scoping), +credit balance, top-ups, usage, 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 + scoped) +│ ├── revoke # revoke immediately (irreversible) +│ └── rotate # revoke old secret, mint a new one (same settings) +├── balance # credit balance + PAYG usage + tier +├── topup # Stripe Checkout link (NEVER charges directly) +├── usage [--start --end --group-by] # spend over time +└── 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 inference + 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`. + +Richer example - monthly cap, scoped to two datasets, search-only: + +```bash +valyu account keys create \ + --name research-bot --cap 50 --window monthly \ + --datasets web,valyu/valyu-arxiv --scopes inference --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. | +| `--datasets ` | Comma-separated datasets to scope the key to (subset of what you can reach). | +| `--scopes ` | Comma-separated scopes (default `inference`; must be a subset of yours). | +| `--type ` | Key type (default `user`). | +| `--tier-ceiling ` | Maximum tier the key can reach. | +| `--rate-limit ` | Rate limit in requests per minute. | +| `--expires ` | Expiry as an ISO 8601 timestamp. | + +**Escalation invariants** (enforced server-side): requested `scopes`, `cap`, and +`datasets` can never exceed the calling key's own. Violations return `403` +`scope_escalation` / `cap_escalation` / `dataset_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 +# Prints a Stripe Checkout URL. Top-ups NEVER charge a card directly - a human +# completes checkout; 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. + +## usage & datasets + +```bash +valyu account usage --start 2026-06-01 --group-by dataset -q +valyu account datasets -q # available_datasets + key_scoped_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..5effc2d 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 scopes to request (default: `account:read keys:read keys:write billing:read inference`) | | `--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,15 @@ valyu login --key val_xxx --profile production ## logout +Revokes the device-minted key server-side (best-effort) and removes stored credentials. + ``` -valyu logout [--profile ] [--yes] +valyu logout [--profile ] [--no-revoke] [--yes] ``` - Without `--profile`: removes all credentials - With `--profile`: removes only that profile +- `--no-revoke`: skip the server-side key revocation (just forget locally) - `--yes`: skips confirmation prompt ## whoami @@ -85,8 +119,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..066531d --- /dev/null +++ b/src/commands/account.ts @@ -0,0 +1,452 @@ +import { Command } from '@commander-js/extra-typings'; +import pc from 'picocolors'; +import type { GlobalOpts } from '../lib/client.js'; +import { requireApiKey } from '../lib/client.js'; +import { + AccountClient, + outputAccountError, + type CreatedKey, + type MeResponse, + type BalanceResponse, + type DatasetsResponse, +} from '../lib/account-client.js'; +import { outputResult } from '../lib/output.js'; +import { createSpinner } from '../lib/spinner.js'; +import { openInBrowser } from '../lib/browser.js'; +import { relTime } from '../lib/format.js'; + +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.scoped_datasets && k.scoped_datasets.length) { + console.log(` ${pc.dim('datasets')} ${k.scoped_datasets.join(', ')}`); + } + 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 and dataset-scoped)') + .requiredOption('--name ', 'Human-readable key name') + .option('--cap ', 'Spend cap in USD (omit for uncapped)') + .option('--window ', 'Cap window: total | monthly', 'total') + .option('--datasets ', 'Comma-separated datasets to scope the key to') + .option('--scopes ', 'Comma-separated scopes (default: inference)') + .option('--type ', 'Key type: user | service_account', 'user') + .option('--tier-ceiling ', 'Maximum tier the key can reach') + .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, scoped to two datasets, search-only')} + ${pc.cyan('$ valyu account keys create --name research-bot --cap 50 --window monthly \\\\')} + ${pc.cyan(' --datasets web,valyu/valyu-arxiv --scopes inference')} +`, + ) + .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, + scopedDatasets: opts.datasets ? splitList(opts.datasets) : undefined, + tierCeiling: opts.tierCeiling, + 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')}`); + } + if (key.scoped_datasets && key.scoped_datasets.length) { + console.log(` ${pc.dim('datasets')} ${key.scoped_datasets.join(', ')}`); + } + 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('Create a Stripe Checkout link to add credits (never charges a card directly)') + .argument('', 'Amount in USD to add') + .option('--open', 'Open the checkout URL in the browser') + .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); + } + const c = client(globalOpts); + const spinner = createSpinner('Creating checkout...', globalOpts.quiet); + const { data, error } = await c.topup(amt); + if (error) { + spinner.fail('Top-up failed'); + outputAccountError(error, globalOpts.json); + } + spinner.stop('Checkout ready'); + + if (isJson(globalOpts)) { + outputResult(data, { json: true }); + return; + } + console.log(''); + console.log(` ${pc.bold('Add')} ${pc.green(fmtUsd(data!.amount_usd))} ${pc.bold('credits - complete checkout:')}`); + 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) await openInBrowser(data!.checkout_url); + }); + +// ─── usage ──────────────────────────────────────────────────────────────────── + +const usageCmd = new Command('usage') + .description('Show spend over time') + .option('--start ', 'Start date (YYYY-MM-DD)') + .option('--end ', 'End date (YYYY-MM-DD)') + .option('--bucket ', 'Time bucket (day)', 'day') + .option('--group-by ', 'Group by: product | dataset | key', 'product') + .action(async (opts, cmd) => { + const globalOpts = cmd.optsWithGlobals() as GlobalOpts; + const c = client(globalOpts); + const spinner = createSpinner('Loading usage...', globalOpts.quiet); + const { data, error } = await c.usage({ + start: opts.start, + end: opts.end, + bucket: opts.bucket, + groupBy: opts.groupBy, + }); + if (error) { + spinner.fail('Failed to load usage'); + outputAccountError(error, globalOpts.json); + } + spinner.stop('Usage loaded'); + + if (isJson(globalOpts)) { + outputResult(data, { json: true }); + return; + } + console.log(''); + console.log(` ${pc.bold('Total spend:')} ${pc.green(fmtUsd(data!.total_spend_usd))}`); + if (data!.start || data!.end) { + console.log(` ${pc.dim(`${data!.start ?? '...'} -> ${data!.end ?? 'now'}, by ${data!.group_by}`)}`); + } + if (data!.series.length === 0) { + console.log(` ${pc.dim('No per-period breakdown available for this range.')}`); + } else { + console.log(''); + for (const row of data!.series) { + const date = String(row.date ?? ''); + const group = String(row[data!.group_by] ?? ''); + const amount = fmtUsd(Number(row.amount_usd ?? 0)); + console.log(` ${pc.dim(date)} ${group.padEnd(14)} ${amount}`); + } + } + console.log(''); + }); + +// ─── 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(', ')}`); + if (d.key_scoped_datasets && d.key_scoped_datasets.length) { + console.log(''); + console.log(` ${pc.bold('This key is scoped to:')}`); + console.log(` ${d.key_scoped_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, usage, 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 usage --group-by dataset')} +`, + ) + .addCommand(whoamiCmd) + .addCommand(keysCmd) + .addCommand(balanceCmd) + .addCommand(topupCmd) + .addCommand(usageCmd) + .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..98a0e63 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 type { GlobalOpts } from '../../lib/client.js'; -import { removeApiKey, listProfiles } from '../../lib/config.js'; +import { AccountClient } from '../../lib/account-client.js'; +import { + removeApiKey, + listProfiles, + resolveApiKey, + getValyuKeyId, + getActiveProfile, +} 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('Revoke the device-minted key (best-effort) and remove stored credentials') .option('--profile ', 'Profile to remove (default: all profiles)') + .option('--no-revoke', 'Skip the server-side key revocation') .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,15 +30,30 @@ 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); } } + // Best-effort server-side revoke of the key minted by `valyu login` for the + // target profile (or the active one). Never blocks logout on a network error. + let revoked: string | null = null; + if (opts.revoke !== false) { + const target = profile ?? getActiveProfile(); + const keyId = getValyuKeyId(target); + const resolved = resolveApiKey(globalOpts.apiKey, target); + if (keyId && resolved) { + try { + const { data } = await new AccountClient(resolved.key).revokeKey(keyId); + if (data?.status === 'revoked') revoked = keyId; + } catch { + // Ignore - local credentials are removed regardless. + } + } + } + const removed = removeApiKey(profile); if (!removed) { @@ -40,8 +64,13 @@ export const logoutCommand = new Command('logout') } if (globalOpts.json || !isInteractive()) { - outputResult({ success: true, profile: profile ?? 'all' }, { json: true }); + outputResult({ success: true, profile: profile ?? 'all', revoked }, { 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}`, + ); } }); diff --git a/src/lib/account-client.ts b/src/lib/account-client.ts new file mode 100644 index 0000000..461bae3 --- /dev/null +++ b/src/lib/account-client.ts @@ -0,0 +1,396 @@ +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 (see ACCOUNT_API_PLAN §0.2). +export const CLI_CLIENT_ID = process.env.VALYU_CLI_CLIENT_ID ?? 'val_cli_public'; + +// Default requested scopes for device login: everything a self-provisioning +// agent needs EXCEPT billing:write (money movement is opt-in on the consent page). +export const DEFAULT_DEVICE_SCOPE = 'account:read keys:read keys:write billing:read inference'; + +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[]; + scoped_datasets: string[] | null; + tier_ceiling: string | null; + 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 { + checkout_url: string; + amount_usd: number; + expires_at: string | null; +} + +export interface UsageResponse { + start: string | null; + end: string | null; + bucket: string; + group_by: string; + total_spend_usd: number; + series: Array>; +} + +export interface DatasetsResponse { + tier: string; + available_datasets: string[]; + key_scoped_datasets: string[] | null; + tiers: Array<{ tier: string; price: string; adds: string[] }>; +} + +export interface CreateKeyOpts { + name: string; + type?: 'user' | 'service_account'; + scopes?: string[]; + scopedDatasets?: string[]; + tierCeiling?: string; + creditCapUsd?: number; + capWindow?: 'total' | 'monthly'; + rateLimitRpm?: number; + expiresAt?: string; +} + +export interface UsageQuery { + start?: string; + end?: string; + bucket?: string; + groupBy?: 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, + scoped_datasets: opts.scopedDatasets, + tier_ceiling: opts.tierCeiling, + 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> { + return this.request('POST', `/keys/${encodeURIComponent(id)}`, { + 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'); + } + + async topup(amountUsd: number): Promise> { + return this.request('POST', '/balance/topup', { + headers: { 'Idempotency-Key': randomUUID() }, + body: { amount_usd: amountUsd }, + }); + } + + async usage(params: UsageQuery = {}): Promise> { + return this.request('GET', '/usage', { + query: { + start: params.start, + end: params.end, + bucket: params.bucket, + group_by: params.groupBy, + }, + }); + } + + 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..988daa5 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -11,10 +11,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 { @@ -66,14 +68,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'; From d9c87c5bed90008edea1827e9c522006682c60cd Mon Sep 17 00:00:00 2001 From: yorkeccak Date: Fri, 26 Jun 2026 12:02:57 +0100 Subject: [PATCH 2/6] Account commands: drop --datasets and --tier-ceiling Per-key dataset scoping and tier ceilings were removed from the Account API; a key is bounded by budget cap + scopes + expiry. Drop the flags, the createKey options, and the key_scoped_datasets response field/display. --- skills/valyu-cli/SKILL.md | 2 +- skills/valyu-cli/references/account.md | 18 ++++++++---------- src/commands/account.ts | 21 +++------------------ src/lib/account-client.ts | 7 ------- 4 files changed, 12 insertions(+), 36 deletions(-) diff --git a/skills/valyu-cli/SKILL.md b/skills/valyu-cli/SKILL.md index 881019f..534e21d 100644 --- a/skills/valyu-cli/SKILL.md +++ b/skills/valyu-cli/SKILL.md @@ -102,7 +102,7 @@ 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/datasets can never exceed +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 diff --git a/skills/valyu-cli/references/account.md b/skills/valyu-cli/references/account.md index 03fabd6..22ca733 100644 --- a/skills/valyu-cli/references/account.md +++ b/skills/valyu-cli/references/account.md @@ -1,6 +1,6 @@ # valyu account -Self-service account management: API keys (with budget caps + dataset scoping), +Self-service account management: API keys (with budget caps), credit balance, top-ups, usage, 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. @@ -10,7 +10,7 @@ valyu account ├── whoami # org, tier, calling key + its budget ├── keys │ ├── list # all keys for the org (default) -│ ├── create --name [...] # mint a key (optionally capped + scoped) +│ ├── 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 @@ -47,12 +47,12 @@ 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`. -Richer example - monthly cap, scoped to two datasets, search-only: +Richer example - monthly cap, search-only: ```bash valyu account keys create \ --name research-bot --cap 50 --window monthly \ - --datasets web,valyu/valyu-arxiv --scopes inference --json + --scopes inference --json ``` ### `keys create` options @@ -62,16 +62,14 @@ valyu account keys create \ | `--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. | -| `--datasets ` | Comma-separated datasets to scope the key to (subset of what you can reach). | | `--scopes ` | Comma-separated scopes (default `inference`; must be a subset of yours). | | `--type ` | Key type (default `user`). | -| `--tier-ceiling ` | Maximum tier the key can reach. | | `--rate-limit ` | Rate limit in requests per minute. | | `--expires ` | Expiry as an ISO 8601 timestamp. | -**Escalation invariants** (enforced server-side): requested `scopes`, `cap`, and -`datasets` can never exceed the calling key's own. Violations return `403` -`scope_escalation` / `cap_escalation` / `dataset_escalation` with a `recovery` +**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 @@ -92,7 +90,7 @@ Add `--open` to open the checkout URL in a browser. ```bash valyu account usage --start 2026-06-01 --group-by dataset -q -valyu account datasets -q # available_datasets + key_scoped_datasets + tier ladder +valyu account datasets -q # available_datasets + tier ladder ``` `datasets` is the replanning surface: on a `403 tier_insufficient` an agent reads diff --git a/src/commands/account.ts b/src/commands/account.ts index 066531d..48c082b 100644 --- a/src/commands/account.ts +++ b/src/commands/account.ts @@ -118,9 +118,6 @@ const keysListCmd = new Command('list') 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.scoped_datasets && k.scoped_datasets.length) { - console.log(` ${pc.dim('datasets')} ${k.scoped_datasets.join(', ')}`); - } 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(''); @@ -130,14 +127,12 @@ const keysListCmd = new Command('list') // ─── keys create ────────────────────────────────────────────────────────────── const keysCreateCmd = new Command('create') - .description('Create a new API key (optionally budget-capped and dataset-scoped)') + .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('--datasets ', 'Comma-separated datasets to scope the key to') .option('--scopes ', 'Comma-separated scopes (default: inference)') .option('--type ', 'Key type: user | service_account', 'user') - .option('--tier-ceiling ', 'Maximum tier the key can reach') .option('--rate-limit ', 'Rate limit in requests per minute') .option('--expires ', 'Expiry as an ISO 8601 timestamp') .addHelpText( @@ -148,9 +143,9 @@ ${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, scoped to two datasets, search-only')} + ${pc.dim('# Monthly cap, search-only')} ${pc.cyan('$ valyu account keys create --name research-bot --cap 50 --window monthly \\\\')} - ${pc.cyan(' --datasets web,valyu/valyu-arxiv --scopes inference')} + ${pc.cyan(' --scopes inference')} `, ) .action(async (opts, cmd) => { @@ -173,8 +168,6 @@ ${pc.dim('Examples:')} name: opts.name, type, scopes: opts.scopes ? splitList(opts.scopes) : undefined, - scopedDatasets: opts.datasets ? splitList(opts.datasets) : undefined, - tierCeiling: opts.tierCeiling, creditCapUsd: cap, capWindow: cap !== undefined ? window : undefined, rateLimitRpm: rateLimit, @@ -202,9 +195,6 @@ ${pc.dim('Examples:')} } else { console.log(` ${pc.dim('budget')} ${pc.yellow('uncapped')}`); } - if (key.scoped_datasets && key.scoped_datasets.length) { - console.log(` ${pc.dim('datasets')} ${key.scoped_datasets.join(', ')}`); - } console.log(''); console.log(` ${pc.yellow('Save this secret now - it is shown only once:')}`); console.log(''); @@ -413,11 +403,6 @@ const datasetsCmd = new Command('datasets') 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(', ')}`); - if (d.key_scoped_datasets && d.key_scoped_datasets.length) { - console.log(''); - console.log(` ${pc.bold('This key is scoped to:')}`); - console.log(` ${d.key_scoped_datasets.join(', ')}`); - } console.log(''); console.log(` ${pc.bold('Tier ladder')}`); for (const t of d.tiers) { diff --git a/src/lib/account-client.ts b/src/lib/account-client.ts index 461bae3..3a3f82d 100644 --- a/src/lib/account-client.ts +++ b/src/lib/account-client.ts @@ -100,8 +100,6 @@ export interface AccountKey { created_at?: string | null; last_used_at?: string | null; scopes: string[]; - scoped_datasets: string[] | null; - tier_ceiling: string | null; credit_cap_usd: number | null; credit_spent_usd: number; cap_window: string | null; @@ -177,7 +175,6 @@ export interface UsageResponse { export interface DatasetsResponse { tier: string; available_datasets: string[]; - key_scoped_datasets: string[] | null; tiers: Array<{ tier: string; price: string; adds: string[] }>; } @@ -185,8 +182,6 @@ export interface CreateKeyOpts { name: string; type?: 'user' | 'service_account'; scopes?: string[]; - scopedDatasets?: string[]; - tierCeiling?: string; creditCapUsd?: number; capWindow?: 'total' | 'monthly'; rateLimitRpm?: number; @@ -309,8 +304,6 @@ export class AccountClient { name: opts.name, type: opts.type, scopes: opts.scopes, - scoped_datasets: opts.scopedDatasets, - tier_ceiling: opts.tierCeiling, credit_cap_usd: opts.creditCapUsd, cap_window: opts.capWindow, rate_limit_rpm: opts.rateLimitRpm, From 885c0632ca27cfb56062263814d09fe573fe9ecb Mon Sep 17 00:00:00 2001 From: yorkeccak Date: Fri, 26 Jun 2026 13:06:26 +0100 Subject: [PATCH 3/6] Account CLI: drop 'inference' scope; a no-scopes key is search-only DEFAULT_DEVICE_SCOPE is management-only (account:read keys:read keys:write billing:read). keys create with no --scopes mints a search-only agent key; help text + skill docs updated to reflect that search/data access is automatic. --- skills/valyu-cli/references/account.md | 22 ++++++++++++++++------ skills/valyu-cli/references/auth.md | 2 +- src/commands/account.ts | 10 ++++++---- src/lib/account-client.ts | 8 +++++--- 4 files changed, 28 insertions(+), 14 deletions(-) diff --git a/skills/valyu-cli/references/account.md b/skills/valyu-cli/references/account.md index 22ca733..23f5fbf 100644 --- a/skills/valyu-cli/references/account.md +++ b/skills/valyu-cli/references/account.md @@ -34,7 +34,7 @@ valyu account keys create --name agent --cap 5 ``` Created key agent val_a1b2c3d4 id - scopes inference + scopes none budget $5.00 total spend cap Save this secret now - it is shown only once: @@ -45,14 +45,24 @@ Save this secret now - it is shown only once: 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`. +`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, search-only: +Richer example - monthly cap, still search-only: ```bash valyu account keys create \ - --name research-bot --cap 50 --window monthly \ - --scopes inference --json + --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 @@ -62,7 +72,7 @@ valyu account keys create \ | `--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 scopes (default `inference`; must be a subset of yours). | +| `--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. | diff --git a/skills/valyu-cli/references/auth.md b/skills/valyu-cli/references/auth.md index 5effc2d..98d1f34 100644 --- a/skills/valyu-cli/references/auth.md +++ b/skills/valyu-cli/references/auth.md @@ -18,7 +18,7 @@ valyu login --key [--profile ] | `--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 scopes to request (default: `account:read keys:read keys:write billing:read inference`) | +| `--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") | **Device flow (default, recommended):** diff --git a/src/commands/account.ts b/src/commands/account.ts index 48c082b..09b27fe 100644 --- a/src/commands/account.ts +++ b/src/commands/account.ts @@ -131,7 +131,7 @@ const keysCreateCmd = new Command('create') .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 scopes (default: inference)') + .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') @@ -143,9 +143,11 @@ ${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')} - ${pc.cyan('$ valyu account keys create --name research-bot --cap 50 --window monthly \\\\')} - ${pc.cyan(' --scopes inference')} + ${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) => { diff --git a/src/lib/account-client.ts b/src/lib/account-client.ts index 3a3f82d..b7a30d5 100644 --- a/src/lib/account-client.ts +++ b/src/lib/account-client.ts @@ -10,9 +10,11 @@ export const ACCOUNT_API_BASE = ( // Fixed public CLI client_id for the RFC 8628 device flow (see ACCOUNT_API_PLAN §0.2). export const CLI_CLIENT_ID = process.env.VALYU_CLI_CLIENT_ID ?? 'val_cli_public'; -// Default requested scopes for device login: everything a self-provisioning -// agent needs EXCEPT billing:write (money movement is opt-in on the consent page). -export const DEFAULT_DEVICE_SCOPE = 'account:read keys:read keys:write billing:read inference'; +// 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'; From 83d0725b5578e77c442ef720e687f4e988f77532 Mon Sep 17 00:00:00 2001 From: yorkeccak Date: Fri, 26 Jun 2026 13:33:31 +0100 Subject: [PATCH 4/6] Account CLI: logout forgets locally by default; topup auto-charge; drop usage - logout now just removes local credentials by default (like gh/aws/docker); new --revoke flag also kills the key server-side (replaces --no-revoke) - topup handles both outcomes: '+ Added $N (charged your card on file)' when charged, or the checkout link when no card is on file - remove the 'usage' subcommand + client method/types --- skills/valyu-cli/SKILL.md | 8 +-- skills/valyu-cli/references/account.md | 18 ++++--- skills/valyu-cli/references/auth.md | 8 +-- src/commands/account.ts | 71 ++++++-------------------- src/commands/auth/logout.ts | 11 ++-- src/lib/account-client.ts | 33 ++---------- 6 files changed, 44 insertions(+), 105 deletions(-) diff --git a/skills/valyu-cli/SKILL.md b/skills/valyu-cli/SKILL.md index 534e21d..f1300b6 100644 --- a/skills/valyu-cli/SKILL.md +++ b/skills/valyu-cli/SKILL.md @@ -52,11 +52,11 @@ valyu │ ├── create / update / delete # manage your org's templates (file-based) ├── batch # parallel deepresearch jobs with shared config ├── sources # list available proprietary data sources -├── account # self-service: keys, balance, usage, datasets +├── 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 returns a Stripe checkout URL) -│ ├── usage / datasets # spend over time + tier entitlements +│ ├── 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 @@ -277,5 +277,5 @@ valyu deepresearch create \ - **AI answer (`answer`)** → [references/answer.md](references/answer.md) - **URL content extraction (`contents`)** → [references/contents.md](references/contents.md) - **Auth, profiles, login (device flow)** → [references/auth.md](references/auth.md) -- **Account: keys, budget caps, balance, top-ups, usage, datasets** → [references/account.md](references/account.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 index 23f5fbf..87e6a6b 100644 --- a/skills/valyu-cli/references/account.md +++ b/skills/valyu-cli/references/account.md @@ -1,7 +1,7 @@ # valyu account Self-service account management: API keys (with budget caps), -credit balance, top-ups, usage, and dataset entitlements. Every subcommand is +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. @@ -14,8 +14,7 @@ valyu account │ ├── revoke # revoke immediately (irreversible) │ └── rotate # revoke old secret, mint a new one (same settings) ├── balance # credit balance + PAYG usage + tier -├── topup # Stripe Checkout link (NEVER charges directly) -├── usage [--start --end --group-by] # spend over time +├── topup # add credits (charges card on file, else checkout link) └── datasets # what this key/org can reach + tier ladder ``` @@ -89,17 +88,20 @@ valyu account balance -q # {"credit_balance_usd":42.18,"payg_usage_usd":7.82,...,"tier":"tier_2"} valyu account topup 25 -# Prints a Stripe Checkout URL. Top-ups NEVER charge a card directly - a human -# completes checkout; credits land via the Stripe webhook. +# 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. +Add `--open` to open the checkout URL in a browser (only applies when a checkout +link is returned). -## usage & datasets +## datasets ```bash -valyu account usage --start 2026-06-01 --group-by dataset -q valyu account datasets -q # available_datasets + tier ladder ``` diff --git a/skills/valyu-cli/references/auth.md b/skills/valyu-cli/references/auth.md index 98d1f34..903a831 100644 --- a/skills/valyu-cli/references/auth.md +++ b/skills/valyu-cli/references/auth.md @@ -56,15 +56,17 @@ valyu login --key val_xxx --profile production ## logout -Revokes the device-minted key server-side (best-effort) and removes stored credentials. +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 ] [--no-revoke] [--yes] +valyu logout [--profile ] [--revoke] [--yes] ``` - Without `--profile`: removes all credentials - With `--profile`: removes only that profile -- `--no-revoke`: skip the server-side key revocation (just forget locally) +- `--revoke`: also revoke the device-minted key server-side (best-effort) - `--yes`: skips confirmation prompt ## whoami diff --git a/src/commands/account.ts b/src/commands/account.ts index 09b27fe..2220433 100644 --- a/src/commands/account.ts +++ b/src/commands/account.ts @@ -302,9 +302,9 @@ const balanceCmd = new Command('balance') // ─── topup ──────────────────────────────────────────────────────────────────── const topupCmd = new Command('topup') - .description('Create a Stripe Checkout link to add credits (never charges a card directly)') + .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') + .option('--open', 'Open the checkout URL in the browser (only when a checkout link is returned)') .action(async (amount, opts, cmd) => { const globalOpts = cmd.optsWithGlobals() as GlobalOpts; const amt = Number(amount); @@ -312,73 +312,33 @@ const topupCmd = new Command('topup') outputAccountError({ message: 'amount must be a positive number.', code: 'invalid_amount' }, globalOpts.json); } const c = client(globalOpts); - const spinner = createSpinner('Creating checkout...', globalOpts.quiet); + 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('Checkout ready'); + spinner.stop('Top-up ready'); if (isJson(globalOpts)) { outputResult(data, { json: true }); return; } console.log(''); - console.log(` ${pc.bold('Add')} ${pc.green(fmtUsd(data!.amount_usd))} ${pc.bold('credits - complete checkout:')}`); + 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) await openInBrowser(data!.checkout_url); - }); - -// ─── usage ──────────────────────────────────────────────────────────────────── - -const usageCmd = new Command('usage') - .description('Show spend over time') - .option('--start ', 'Start date (YYYY-MM-DD)') - .option('--end ', 'End date (YYYY-MM-DD)') - .option('--bucket ', 'Time bucket (day)', 'day') - .option('--group-by ', 'Group by: product | dataset | key', 'product') - .action(async (opts, cmd) => { - const globalOpts = cmd.optsWithGlobals() as GlobalOpts; - const c = client(globalOpts); - const spinner = createSpinner('Loading usage...', globalOpts.quiet); - const { data, error } = await c.usage({ - start: opts.start, - end: opts.end, - bucket: opts.bucket, - groupBy: opts.groupBy, - }); - if (error) { - spinner.fail('Failed to load usage'); - outputAccountError(error, globalOpts.json); - } - spinner.stop('Usage loaded'); - - if (isJson(globalOpts)) { - outputResult(data, { json: true }); - return; - } - console.log(''); - console.log(` ${pc.bold('Total spend:')} ${pc.green(fmtUsd(data!.total_spend_usd))}`); - if (data!.start || data!.end) { - console.log(` ${pc.dim(`${data!.start ?? '...'} -> ${data!.end ?? 'now'}, by ${data!.group_by}`)}`); - } - if (data!.series.length === 0) { - console.log(` ${pc.dim('No per-period breakdown available for this range.')}`); - } else { - console.log(''); - for (const row of data!.series) { - const date = String(row.date ?? ''); - const group = String(row[data!.group_by] ?? ''); - const amount = fmtUsd(Number(row.amount_usd ?? 0)); - console.log(` ${pc.dim(date)} ${group.padEnd(14)} ${amount}`); - } - } - console.log(''); + if (opts.open && data!.checkout_url) await openInBrowser(data!.checkout_url); }); // ─── datasets ───────────────────────────────────────────────────────────────── @@ -417,7 +377,7 @@ const datasetsCmd = new Command('datasets') // ─── group ──────────────────────────────────────────────────────────────────── export const accountCommand = new Command('account') - .description('Manage your Valyu account: keys, balance, usage, datasets') + .description('Manage your Valyu account: keys, balance, top-ups, datasets') .addHelpText( 'after', ` @@ -428,12 +388,11 @@ ${pc.dim('Examples:')} ${pc.cyan('$ valyu account keys list')} ${pc.cyan('$ valyu account balance')} ${pc.cyan('$ valyu account topup 25')} - ${pc.cyan('$ valyu account usage --group-by dataset')} + ${pc.cyan('$ valyu account datasets')} `, ) .addCommand(whoamiCmd) .addCommand(keysCmd) .addCommand(balanceCmd) .addCommand(topupCmd) - .addCommand(usageCmd) .addCommand(datasetsCmd); diff --git a/src/commands/auth/logout.ts b/src/commands/auth/logout.ts index 98a0e63..0f94585 100644 --- a/src/commands/auth/logout.ts +++ b/src/commands/auth/logout.ts @@ -13,9 +13,9 @@ import { outputError, outputResult } from '../../lib/output.js'; import { isInteractive } from '../../lib/tty.js'; export const logoutCommand = new Command('logout') - .description('Revoke the device-minted key (best-effort) and remove stored credentials') + .description('Remove stored credentials locally (use --revoke to also kill the key server-side)') .option('--profile ', 'Profile to remove (default: all profiles)') - .option('--no-revoke', 'Skip the server-side key revocation') + .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 & { @@ -37,10 +37,11 @@ export const logoutCommand = new Command('logout') } } - // Best-effort server-side revoke of the key minted by `valyu login` for the - // target profile (or the active one). Never blocks logout on a network error. + // 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. let revoked: string | null = null; - if (opts.revoke !== false) { + if (opts.revoke) { const target = profile ?? getActiveProfile(); const keyId = getValyuKeyId(target); const resolved = resolveApiKey(globalOpts.apiKey, target); diff --git a/src/lib/account-client.ts b/src/lib/account-client.ts index b7a30d5..9cef5b6 100644 --- a/src/lib/account-client.ts +++ b/src/lib/account-client.ts @@ -160,18 +160,11 @@ export interface BalanceResponse { } export interface TopupResponse { - checkout_url: string; + charged?: boolean; amount_usd: number; - expires_at: string | null; -} - -export interface UsageResponse { - start: string | null; - end: string | null; - bucket: string; - group_by: string; - total_spend_usd: number; - series: Array>; + message?: string; + checkout_url?: string; + expires_at?: string | null; } export interface DatasetsResponse { @@ -190,13 +183,6 @@ export interface CreateKeyOpts { expiresAt?: string; } -export interface UsageQuery { - start?: string; - end?: string; - bucket?: string; - groupBy?: string; -} - type Result = { data: T | null; error: AccountApiError | null }; // ─── Client ────────────────────────────────────────────────────────────────── @@ -342,17 +328,6 @@ export class AccountClient { }); } - async usage(params: UsageQuery = {}): Promise> { - return this.request('GET', '/usage', { - query: { - start: params.start, - end: params.end, - bucket: params.bucket, - group_by: params.groupBy, - }, - }); - } - async datasets(): Promise> { return this.request('GET', '/datasets'); } From 1197acf2f83e5f69bb86443307e59ebfeaa606dc Mon Sep 17 00:00:00 2001 From: yorkeccak Date: Sun, 28 Jun 2026 02:37:30 +0100 Subject: [PATCH 5/6] Account CLI fixes: topup idempotency + confirm, rotate key, logout revoke surfacing - topup: optional --idempotency-key for safe retries; --yes flag + interactive confirm for top-ups above $100 (agents/non-TTY still auto-charge). - rotate: send the required Idempotency-Key (was 400ing every rotation). - logout --revoke: surface the revoke outcome (warning + structured field) instead of swallowing failures under a success message. --- src/commands/account.ts | 98 ++++++++++++++++++++++++++++--------- src/commands/auth/logout.ts | 69 +++++++++++++++++++++----- src/lib/account-client.ts | 14 ++++-- 3 files changed, 145 insertions(+), 36 deletions(-) diff --git a/src/commands/account.ts b/src/commands/account.ts index 2220433..0b57911 100644 --- a/src/commands/account.ts +++ b/src/commands/account.ts @@ -1,19 +1,25 @@ +import * as p from '@clack/prompts'; import { Command } from '@commander-js/extra-typings'; import pc from 'picocolors'; -import type { GlobalOpts } from '../lib/client.js'; -import { requireApiKey } from '../lib/client.js'; import { AccountClient, - outputAccountError, - type CreatedKey, - type MeResponse, type BalanceResponse, + type CreatedKey, type DatasetsResponse, + type MeResponse, + outputAccountError, } from '../lib/account-client.js'; -import { outputResult } from '../lib/output.js'; -import { createSpinner } from '../lib/spinner.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); @@ -35,7 +41,11 @@ function fmtUsd(n: number | null | undefined): string { return `$${n.toFixed(2)}`; } -function fmtCap(key: { credit_cap_usd: number | null; credit_spent_usd: number; cap_window: string | null }): string { +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}` : ''; @@ -74,17 +84,25 @@ const whoamiCmd = new Command('whoami') return; } console.log(''); - console.log(` ${pc.bold('Organisation:')} ${me.name ?? pc.dim('(unnamed)')} ${pc.dim(me.org_id)}`); + 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')}`); + 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( + ` ${pc.bold('Datasets:')} ${pc.dim(`${me.allowed_datasets.length} available`)}`, + ); console.log(''); }); @@ -109,7 +127,9 @@ const keysListCmd = new Command('list') 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`); + 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(''); @@ -131,7 +151,10 @@ const keysCreateCmd = new Command('create') .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( + '--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') @@ -155,13 +178,19 @@ ${pc.dim('Examples:')} 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); + 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); + outputAccountError( + { message: '--rate-limit must be a number.', code: 'invalid_rate_limit' }, + globalOpts.json, + ); } const c = client(globalOpts); @@ -202,7 +231,9 @@ ${pc.dim('Examples:')} 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( + ` ${pc.dim('Use it with:')} ${pc.dim(`VALYU_API_KEY=${key.key_prefix}... valyu search "..."`)}`, + ); console.log(''); }); @@ -248,7 +279,9 @@ const keysRotateCmd = new Command('rotate') return; } console.log(''); - console.log(` ${pc.green('Rotated')} ${pc.dim(data!.rotated_from)} ${pc.dim('->')} ${pc.bold(data!.key_prefix)}`); + 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(''); @@ -287,7 +320,9 @@ const balanceCmd = new Command('balance') 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')}`); + 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)}`); @@ -305,12 +340,27 @@ 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); + 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); @@ -332,7 +382,9 @@ const topupCmd = new Command('topup') console.log(''); return; } - console.log(` ${pc.bold('Add')} ${pc.green(fmtUsd(data!.amount_usd))} ${pc.bold('credits - complete payment:')}`); + 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(''); @@ -369,7 +421,9 @@ const datasetsCmd = new Command('datasets') 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( + ` ${pc.cyan(t.tier.padEnd(7))} ${pc.dim(t.price.padEnd(9))} +${t.adds.join(', ')}${marker}`, + ); } console.log(''); }); diff --git a/src/commands/auth/logout.ts b/src/commands/auth/logout.ts index 0f94585..164d4b9 100644 --- a/src/commands/auth/logout.ts +++ b/src/commands/auth/logout.ts @@ -1,13 +1,13 @@ import * as p from '@clack/prompts'; import { Command } from '@commander-js/extra-typings'; -import type { GlobalOpts } from '../../lib/client.js'; import { AccountClient } from '../../lib/account-client.js'; +import type { GlobalOpts } from '../../lib/client.js'; import { - removeApiKey, + getActiveProfile, + getValyuKeyId, listProfiles, + removeApiKey, resolveApiKey, - getValyuKeyId, - getActiveProfile, } from '../../lib/config.js'; import { outputError, outputResult } from '../../lib/output.js'; import { isInteractive } from '../../lib/tty.js'; @@ -39,18 +39,38 @@ export const logoutCommand = new Command('logout') // 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. + // 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) { + 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; - } catch { - // Ignore - local credentials are removed regardless. + 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); } } } @@ -59,13 +79,25 @@ export const logoutCommand = new Command('logout') 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', revoked }, { json: true }); + outputResult( + { + success: true, + profile: profile ?? 'all', + revoked, + revoke_status: revokeStatus, + revoke_error: revokeError, + }, + { json: true }, + ); } else { const suffix = revoked ? ' (key revoked server-side)' : ''; console.log( @@ -73,5 +105,20 @@ export const logoutCommand = new Command('logout') ? ` 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 index 9cef5b6..b5d69f9 100644 --- a/src/lib/account-client.ts +++ b/src/lib/account-client.ts @@ -7,7 +7,7 @@ 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 (see ACCOUNT_API_PLAN §0.2). +// 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 @@ -305,7 +305,11 @@ export class AccountClient { } 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' }, }); } @@ -321,9 +325,13 @@ export class AccountClient { return this.request('GET', '/balance'); } - async topup(amountUsd: number): Promise> { + // 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': randomUUID() }, + headers: { 'Idempotency-Key': idempotencyKey ?? randomUUID() }, body: { amount_usd: amountUsd }, }); } From 3800ca178e632b9adf859363a66aad9615412702 Mon Sep 17 00:00:00 2001 From: yorkeccak Date: Sun, 28 Jun 2026 03:20:55 +0100 Subject: [PATCH 6/6] Security: re-assert 0600 on the credentials file every write writeFileSync mode only applies on create, so a pre-existing looser-permissioned credentials file kept its bits. chmod 0600 (and 0700 on the dir) on every write. --- src/lib/config.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/lib/config.ts b/src/lib/config.ts index 988daa5..96c7f82 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -1,4 +1,5 @@ import { + chmodSync, existsSync, mkdirSync, readFileSync, @@ -50,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 {