diff --git a/.env.example b/.env.example deleted file mode 100644 index 5ea7a42..0000000 --- a/.env.example +++ /dev/null @@ -1,2 +0,0 @@ -# Get your API key from https://www.bitrefill.com/account/developers -BITREFILL_API_KEY=YOUR_BITREFILL_API_KEY \ No newline at end of file diff --git a/README.md b/README.md index b7be51d..47df309 100644 --- a/README.md +++ b/README.md @@ -14,78 +14,49 @@ npm install -g @bitrefill/cli ## Quick start (`init`) -The fastest way to set up the CLI: - -```bash -bitrefill init -``` - -This walks you through a one-time setup: - -1. Prompts for your API key (masked input) -- get one at [bitrefill.com/account/developers](https://www.bitrefill.com/account/developers) -2. Validates the key against the Bitrefill MCP server -3. Stores the key in `~/.config/bitrefill-cli/credentials.json` (permissions `0600`) -4. If [OpenClaw](https://github.com/openclaw/openclaw) is detected, registers Bitrefill as an MCP server and generates a `SKILL.md` for agents - Non-interactive and agent-driven usage: ```bash -# Pass the key directly (scripts, CI, OpenClaw agents) -bitrefill init --api-key YOUR_API_KEY --non-interactive - -# Or via environment variable -export BITREFILL_API_KEY=YOUR_API_KEY -bitrefill init --non-interactive +# Initialize in non-interactive mode +bitrefill init # Force OpenClaw integration even if not auto-detected bitrefill init --openclaw ``` -After `init`, the stored key is picked up automatically -- no need to pass `--api-key` on every invocation. +```bash +bitrefill login --email you@example.com +bitrefill verify --code 123456 +``` ### OpenClaw + Telegram If you use [OpenClaw](https://github.com/openclaw/openclaw) as your AI agent gateway (e.g. via Telegram), `bitrefill init` does extra work: -- Writes `BITREFILL_API_KEY` to `~/.openclaw/.env` (read by the gateway at activation) -- Adds an MCP server entry to `~/.openclaw/openclaw.json` using `${BITREFILL_API_KEY}` -- the config file never contains the actual key +- Adds an MCP server entry to `~/.openclaw/openclaw.json` - Generates `~/.openclaw/skills/bitrefill/SKILL.md` so the agent knows about all available tools After init, tell your Telegram bot: *"Search for Netflix gift cards on Bitrefill"*. ## Authentication -### API Key (recommended) - -Generate an API key at [bitrefill.com/account/developers](https://www.bitrefill.com/account/developers). After running `bitrefill init`, the key is stored locally and used automatically. +### Sign in -You can also pass it explicitly: +Two steps. Use the emailed verification `--code`; add `--otp` if your account also requires a second factor (for example TOTP). ```bash -# Flag -bitrefill --api-key YOUR_API_KEY search-products --query "Netflix" - -# Environment variable -export BITREFILL_API_KEY=YOUR_API_KEY -bitrefill search-products --query "Netflix" +bitrefill login --email you@example.com +bitrefill verify --code 123456 ``` -Key resolution priority: `--api-key` flag > `BITREFILL_API_KEY` env var > stored credentials file. - -### OAuth - -On first run without an API key, the CLI opens your browser for OAuth authorization. Credentials are stored in `~/.config/bitrefill-cli/`. - ### Non-interactive / CI -In environments without a TTY (e.g. CI, Docker, scripts), or when `CI=true`, the CLI cannot complete browser-based OAuth. Use `bitrefill init` first, or pass `--api-key` / `BITREFILL_API_KEY`. - -Node does not load `.env` files automatically. After editing `.env`, either export variables in your shell (`set -a && source .env && set +a` in bash/zsh) or pass `--api-key` on the command line. +In environments without a TTY (e.g. CI, Docker, scripts), ensure credentials are provided by your runtime and then run `bitrefill init`. ## Usage ```bash -bitrefill [--api-key ] [--json] [--no-interactive] [options] +bitrefill [--json] [--no-interactive] [options] ``` ### Human-readable output (default) @@ -110,11 +81,10 @@ bitrefill --json search-products --query "Amazon" --per_page 1 | jq '.products[0 Generates Markdown from the MCP `tools/list` response: tool names, descriptions, parameter tables, JSON Schema, example `bitrefill …` invocations, and example MCP `tools/call` payloads. Intended for **CLAUDE.md**, **Cursor** rules, or **`.github/copilot-instructions.md`**. - **stdout** by default, or **`-o` / `--output `** to write a file. -- Uses the same auth as other commands (`--api-key`, `BITREFILL_API_KEY`, or OAuth). -- The generated **Connection** line shows a redacted MCP URL (`…/mcp/`), not your real key. +- Uses the same auth as other commands. +- The generated **Connection** line shows a redacted MCP URL, not sensitive credentials. ```bash -export BITREFILL_API_KEY=YOUR_API_KEY bitrefill llm-context -o BITREFILL-MCP.md # or: bitrefill llm-context > BITREFILL-MCP.md ``` @@ -140,7 +110,7 @@ bitrefill list-orders # List available commands bitrefill --help -# Clear stored credentials +# Clear local login session bitrefill logout # Export tool docs for coding agents (see "LLM context" above) diff --git a/package.json b/package.json index 59314e7..1e1f7a6 100644 --- a/package.json +++ b/package.json @@ -22,12 +22,12 @@ ], "packageManager": "pnpm@10.27.0", "dependencies": { - "@modelcontextprotocol/sdk": "^1.27.1", + "@modelcontextprotocol/sdk": "^1.29.0", "@toon-format/toon": "^2.1.0", "commander": "^14.0.3" }, "devDependencies": { - "@types/node": "^25.3.0", + "@types/node": "^25.7.0", "prettier": "^3.8.1", "tsx": "^4.21.0", "typescript": "^5.8.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f7b6f8d..04148dd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,13 +4,16 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + mcp-oauth-server: link:../../dev/mcpoauth + importers: .: dependencies: '@modelcontextprotocol/sdk': - specifier: ^1.27.1 - version: 1.27.1(zod@4.3.6) + specifier: ^1.29.0 + version: 1.29.0(zod@4.3.6) '@toon-format/toon': specifier: ^2.1.0 version: 2.1.0 @@ -19,8 +22,8 @@ importers: version: 14.0.3 devDependencies: '@types/node': - specifier: ^25.3.0 - version: 25.3.0 + specifier: ^25.7.0 + version: 25.7.0 prettier: specifier: ^3.8.1 version: 3.8.1 @@ -32,7 +35,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.4 - version: 3.2.4(@types/node@25.3.0)(tsx@4.21.0) + version: 3.2.4(@types/node@25.7.0)(tsx@4.21.0) packages: @@ -201,8 +204,8 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - '@modelcontextprotocol/sdk@1.27.1': - resolution: {integrity: sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==} + '@modelcontextprotocol/sdk@1.29.0': + resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} engines: {node: '>=18'} peerDependencies: '@cfworker/json-schema': ^4.1.1 @@ -348,8 +351,8 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - '@types/node@25.3.0': - resolution: {integrity: sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==} + '@types/node@25.7.0': + resolution: {integrity: sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg==} '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} @@ -855,8 +858,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - undici-types@7.18.2: - resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + undici-types@7.21.0: + resolution: {integrity: sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ==} unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} @@ -1046,7 +1049,7 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} - '@modelcontextprotocol/sdk@1.27.1(zod@4.3.6)': + '@modelcontextprotocol/sdk@1.29.0(zod@4.3.6)': dependencies: '@hono/node-server': 1.19.9(hono@4.12.2) ajv: 8.18.0 @@ -1154,9 +1157,9 @@ snapshots: '@types/estree@1.0.8': {} - '@types/node@25.3.0': + '@types/node@25.7.0': dependencies: - undici-types: 7.18.2 + undici-types: 7.21.0 '@vitest/expect@3.2.4': dependencies: @@ -1166,13 +1169,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@25.3.0)(tsx@4.21.0))': + '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@25.7.0)(tsx@4.21.0))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@25.3.0)(tsx@4.21.0) + vite: 7.3.1(@types/node@25.7.0)(tsx@4.21.0) '@vitest/pretty-format@3.2.4': dependencies: @@ -1710,19 +1713,19 @@ snapshots: typescript@5.9.3: {} - undici-types@7.18.2: {} + undici-types@7.21.0: {} unpipe@1.0.0: {} vary@1.1.2: {} - vite-node@3.2.4(@types/node@25.3.0)(tsx@4.21.0): + vite-node@3.2.4(@types/node@25.7.0)(tsx@4.21.0): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.1(@types/node@25.3.0)(tsx@4.21.0) + vite: 7.3.1(@types/node@25.7.0)(tsx@4.21.0) transitivePeerDependencies: - '@types/node' - jiti @@ -1737,7 +1740,7 @@ snapshots: - tsx - yaml - vite@7.3.1(@types/node@25.3.0)(tsx@4.21.0): + vite@7.3.1(@types/node@25.7.0)(tsx@4.21.0): dependencies: esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.4) @@ -1746,15 +1749,15 @@ snapshots: rollup: 4.60.1 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 25.3.0 + '@types/node': 25.7.0 fsevents: 2.3.3 tsx: 4.21.0 - vitest@3.2.4(@types/node@25.3.0)(tsx@4.21.0): + vitest@3.2.4(@types/node@25.7.0)(tsx@4.21.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@25.3.0)(tsx@4.21.0)) + '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@25.7.0)(tsx@4.21.0)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -1772,11 +1775,11 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.1(@types/node@25.3.0)(tsx@4.21.0) - vite-node: 3.2.4(@types/node@25.3.0)(tsx@4.21.0) + vite: 7.3.1(@types/node@25.7.0)(tsx@4.21.0) + vite-node: 3.2.4(@types/node@25.7.0)(tsx@4.21.0) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 25.3.0 + '@types/node': 25.7.0 transitivePeerDependencies: - jiti - less diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..0e22d5e --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +overrides: + mcp-oauth-server: link:../../dev/mcpoauth diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..9bd30c2 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,5 @@ +export const DEFAULT_BASE_MCP_URL = 'https://api.bitrefill.com/mcp'; + +export const BASE_MCP_URL = process.env.BASE_URL || DEFAULT_BASE_MCP_URL; + +export const IS_DEFAULT_BASE_MCP_URL = BASE_MCP_URL === DEFAULT_BASE_MCP_URL; diff --git a/src/credentials.test.ts b/src/credentials.test.ts deleted file mode 100644 index 33b8572..0000000 --- a/src/credentials.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import fs from 'node:fs'; -import path from 'node:path'; -import os from 'node:os'; -import { - writeCredentials, - readCredentials, - deleteCredentials, - redactKey, -} from './credentials.js'; - -const TEST_DIR = path.join(os.tmpdir(), `bitrefill-cli-test-${Date.now()}`); -const CREDENTIALS_DIR = path.join(os.homedir(), '.config', 'bitrefill-cli'); -const CREDENTIALS_FILE = path.join(CREDENTIALS_DIR, 'credentials.json'); - -describe('redactKey', () => { - it('redacts a long key showing first 4 and last 3 chars', () => { - expect(redactKey('br_live_abcdefghijk')).toBe('br_l...ijk'); - }); - - it('fully masks keys shorter than 10 characters', () => { - expect(redactKey('short')).toBe('***'); - expect(redactKey('123456789')).toBe('***'); - }); - - it('handles exactly 10 character keys', () => { - expect(redactKey('1234567890')).toBe('1234...890'); - }); -}); - -describe('writeCredentials / readCredentials / deleteCredentials', () => { - let originalFile: string | null = null; - - beforeEach(() => { - try { - originalFile = fs.readFileSync(CREDENTIALS_FILE, 'utf-8'); - } catch { - originalFile = null; - } - }); - - afterEach(() => { - if (originalFile !== null) { - fs.writeFileSync(CREDENTIALS_FILE, originalFile); - } else { - try { - fs.unlinkSync(CREDENTIALS_FILE); - } catch { - /* noop */ - } - } - }); - - it('writes and reads back the API key', () => { - writeCredentials('test_key_1234567890'); - const key = readCredentials(); - expect(key).toBe('test_key_1234567890'); - }); - - it('overwrites an existing key on re-write', () => { - writeCredentials('first_key_xxxxxxxxx'); - writeCredentials('second_key_yyyyyyyy'); - expect(readCredentials()).toBe('second_key_yyyyyyyy'); - }); - - it('returns undefined when no credential file exists', () => { - deleteCredentials(); - expect(readCredentials()).toBeUndefined(); - }); - - it('deleteCredentials removes the file', () => { - writeCredentials('to_be_deleted_12345'); - deleteCredentials(); - expect(readCredentials()).toBeUndefined(); - }); - - it('deleteCredentials is safe to call when no file exists', () => { - deleteCredentials(); - expect(() => deleteCredentials()).not.toThrow(); - }); - - it('sets restrictive file permissions (0600)', () => { - writeCredentials('perm_test_key_12345'); - const stat = fs.statSync(CREDENTIALS_FILE); - const mode = stat.mode & 0o777; - expect(mode).toBe(0o600); - }); -}); diff --git a/src/credentials.ts b/src/credentials.ts deleted file mode 100644 index 351ff7a..0000000 --- a/src/credentials.ts +++ /dev/null @@ -1,63 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import os from 'node:os'; - -const CREDENTIALS_DIR = path.join(os.homedir(), '.config', 'bitrefill-cli'); -const CREDENTIALS_FILE = path.join(CREDENTIALS_DIR, 'credentials.json'); - -interface StoredCredentials { - apiKey: string; -} - -export function writeCredentials(apiKey: string): void { - fs.mkdirSync(CREDENTIALS_DIR, { recursive: true, mode: 0o700 }); - const data: StoredCredentials = { apiKey }; - fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify(data, null, 2) + '\n', { - mode: 0o600, - }); - fs.chmodSync(CREDENTIALS_FILE, 0o600); -} - -export function readCredentials(): string | undefined { - try { - const raw = fs.readFileSync(CREDENTIALS_FILE, 'utf-8'); - const data = JSON.parse(raw) as StoredCredentials; - return data.apiKey || undefined; - } catch { - return undefined; - } -} - -export function deleteCredentials(): void { - try { - fs.unlinkSync(CREDENTIALS_FILE); - } catch { - /* file may not exist */ - } -} - -/** - * Redact an API key for display: show the first 4 and last 3 characters. - * Keys shorter than 10 chars are fully masked. - */ -export function redactKey(key: string): string { - if (key.length < 10) return '***'; - return `${key.slice(0, 4)}...${key.slice(-3)}`; -} - -/** - * Resolve the API key from all available sources, in priority order: - * 1. `--api-key` CLI flag - * 2. `BITREFILL_API_KEY` environment variable - * 3. Stored credential file (~/.config/bitrefill-cli/credentials.json) - */ -export function resolveApiKeyWithStore(): string | undefined { - const idx = process.argv.indexOf('--api-key'); - if (idx !== -1 && idx + 1 < process.argv.length) { - return process.argv[idx + 1]; - } - if (process.env.BITREFILL_API_KEY) { - return process.env.BITREFILL_API_KEY; - } - return readCredentials(); -} diff --git a/src/index.ts b/src/index.ts index 5997042..8a5b0d0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,11 +17,9 @@ import type { OAuthClientInformationMixed, OAuthTokens, } from '@modelcontextprotocol/sdk/shared/auth.js'; -import { createServer } from 'node:http'; import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; -import { execSync } from 'node:child_process'; import { createHumanFormatter, createJsonFormatter, @@ -29,62 +27,70 @@ import { } from './output.js'; import { buildOptionsForTool, parseToolArgs } from './tools.js'; import { generateLlmContextMarkdown } from './llm-context.js'; -import { resolveApiKeyWithStore } from './credentials.js'; +import { buildManifest } from './manifest.js'; import { runInit } from './init.js'; +import { BASE_MCP_URL, IS_DEFAULT_BASE_MCP_URL } from './config.js'; + import { VERSION } from './version.js'; /** Subcommands defined by the CLI; MCP tools with the same name are skipped. */ -const RESERVED_TOOL_NAMES = new Set(['logout', 'llm-context', 'init']); - -const BASE_MCP_URL = 'https://api.bitrefill.com/mcp'; +const RESERVED_TOOL_NAMES = new Set([ + 'llm-context', + 'init', + 'login', + 'verify', + 'logout', + 'reset', + 'whoami', + 'manifest', +]); + +const STATE_VERSION = 1; + +const CLI_AUTH_BASE_URL = new URL('/cli/', BASE_MCP_URL); +const CLI_OAUTH_REVOKE_URL = new URL('/oauth/mcp/revoke', BASE_MCP_URL); const CALLBACK_PORT = 8098; const CALLBACK_URL = `http://127.0.0.1:${CALLBACK_PORT}/callback`; const STATE_DIR = path.join(os.homedir(), '.config', 'bitrefill-cli'); -function resolveApiKey(): string | undefined { - return resolveApiKeyWithStore(); -} - -function resolveMcpUrl(apiKey?: string): string { - if (process.env.MCP_URL) return process.env.MCP_URL; - if (apiKey) return `${BASE_MCP_URL}/${apiKey}`; - return BASE_MCP_URL; -} - function resolveJsonMode(): boolean { return process.argv.some((arg) => arg === '--json'); } -function resolveInteractive(): boolean { - if (process.argv.includes('--no-interactive')) return false; - if (process.env.CI === 'true') return false; - if (!process.stdin.isTTY) return false; - return true; -} - function createOutputFormatter(jsonMode: boolean): OutputFormatter { return jsonMode ? createJsonFormatter() : createHumanFormatter(); } // --- Persistent OAuth state --- +interface PendingChallenge { + type?: string; + next_step?: string; + browser_url?: string; +} + interface PersistedState { + version?: number; clientInfo?: OAuthClientInformationMixed; tokens?: OAuthTokens; - codeVerifier?: string; + email?: string; + pendingEmail?: string; + pendingChallenge?: PendingChallenge; discoveryState?: OAuthDiscoveryState; } function stateFilePath(serverUrl: string): string { const host = new URL(serverUrl).host.replace(/[^a-zA-Z0-9.-]/g, '_'); - return path.join(STATE_DIR, `${host}.json`); + return path.join(STATE_DIR, `${host}.v${STATE_VERSION}.json`); } function loadState(serverUrl: string): PersistedState { try { - return JSON.parse( + const parsed = JSON.parse( fs.readFileSync(stateFilePath(serverUrl), 'utf-8') ) as PersistedState; + if (parsed.version !== STATE_VERSION) return {}; + return parsed; } catch { return {}; } @@ -92,29 +98,58 @@ function loadState(serverUrl: string): PersistedState { function saveState(serverUrl: string, state: PersistedState): void { fs.mkdirSync(STATE_DIR, { recursive: true }); - fs.writeFileSync(stateFilePath(serverUrl), JSON.stringify(state, null, 2)); + fs.writeFileSync( + stateFilePath(serverUrl), + JSON.stringify({ ...state, version: STATE_VERSION }, null, 2) + ); } -// --- OAuth --- +function clearAccessTokenState(serverUrl: string): void { + const state = loadState(serverUrl); + delete state.tokens; + delete state.email; + delete state.pendingEmail; + delete state.pendingChallenge; + saveState(serverUrl, state); +} -function createOAuthProvider( - serverUrl: string, - formatter: OutputFormatter -): OAuthClientProvider { +function clearOAuthStore(serverUrl: string): void { + try { + fs.unlinkSync(stateFilePath(serverUrl)); + } catch { + // File missing or unwritable; nothing to do. + } +} + +// --- OAuth --- +function createOAuthProvider(serverUrl: string): OAuthClientProvider { let state = loadState(serverUrl); const persist = () => saveState(serverUrl, state); return { get redirectUrl() { - return CALLBACK_URL; + return undefined; + }, + prepareTokenRequest(scope): URLSearchParams { + const cid = state.clientInfo?.client_id; + if (!cid) { + throw new Error('No OAuth client ID in saved state'); + } + const p = new URLSearchParams({ + client_id: cid, + grant_type: 'client_credentials', + resource: BASE_MCP_URL, + }); + if (scope) p.set('scope', scope); + return p; }, get clientMetadata(): OAuthClientMetadata { return { client_name: 'Bitrefill CLI', + // Required for the MCP SDK schemas but not used. redirect_uris: [CALLBACK_URL], - grant_types: ['authorization_code', 'refresh_token'], - response_types: ['code'], - token_endpoint_auth_method: 'client_secret_post', + grant_types: ['client_credentials'], + token_endpoint_auth_method: 'none', }; }, clientInformation() { @@ -129,21 +164,19 @@ function createOAuthProvider( }, saveTokens(t: OAuthTokens) { state.tokens = t; + delete state.email; persist(); }, - redirectToAuthorization(url: URL) { - formatter.info( - `\nOpen this URL to authorize:\n ${url.toString()}\n` - ); - openBrowser(url.toString()); + redirectToAuthorization() { + // client_credentials only; no browser redirect. }, - saveCodeVerifier(v: string) { - state.codeVerifier = v; - persist(); + saveCodeVerifier() { + // Authorization code flow is not used. }, codeVerifier() { - if (!state.codeVerifier) throw new Error('No code verifier saved'); - return state.codeVerifier; + throw new Error( + 'Authorization code flow is not used by the Bitrefill CLI' + ); }, discoveryState() { return state.discoveryState; @@ -158,204 +191,348 @@ function createOAuthProvider( if (scope === 'all') state = {}; else if (scope === 'tokens') delete state.tokens; else if (scope === 'client') delete state.clientInfo; - else if (scope === 'verifier') delete state.codeVerifier; else if (scope === 'discovery') delete state.discoveryState; persist(); }, }; } -function openBrowser(url: string): void { - try { - const p = process.platform; - if (p === 'darwin') execSync(`open "${url}"`); - else if (p === 'win32') execSync(`start "" "${url}"`); - else execSync(`xdg-open "${url}"`); - } catch { - /* best-effort */ - } -} - -function waitForCallback(): Promise { - return new Promise((resolve, reject) => { - const server = createServer((req, res) => { - if (!req.url?.startsWith('/callback')) { - res.writeHead(404); - res.end(); - return; - } - const parsed = new URL( - req.url, - `http://127.0.0.1:${CALLBACK_PORT}` - ); - const code = parsed.searchParams.get('code'); - const error = parsed.searchParams.get('error'); - if (code) { - res.writeHead(200, { 'Content-Type': 'text/html' }); - res.end( - '

Authorized

You can close this tab.

' - ); - resolve(code); - } else { - res.writeHead(400, { 'Content-Type': 'text/html' }); - res.end( - `

Failed

${error ?? 'Unknown'}

` - ); - reject(new Error(`OAuth error: ${error}`)); - } - setTimeout(() => server.close(), 2000); - }); - server.listen(CALLBACK_PORT, '127.0.0.1'); - server.on('error', reject); - }); +function createOAuthSuppressNoise(formatter: OutputFormatter) { + return (err: Error) => { + if (err instanceof UnauthorizedError) return; + if (err.message?.includes('SSE stream disconnected')) return; + if (err.message?.includes('Failed to open SSE stream')) return; + formatter.clientError(err); + }; } // --- MCP connection --- async function createMcpClient( url: string, - useOAuth: boolean, formatter: OutputFormatter ): Promise<{ client: Client; transport: StreamableHTTPClientTransport }> { - const suppressNoise = (err: Error) => { - if (err instanceof UnauthorizedError) return; - if (err.message?.includes('SSE stream disconnected')) return; - if (err.message?.includes('Failed to open SSE stream')) return; - formatter.clientError(err); - }; + const suppressNoise = createOAuthSuppressNoise(formatter); + const authProvider = createOAuthProvider(url); - if (!useOAuth) { - const c = new Client({ + const connectOnce = async () => { + const client = new Client({ name: 'bitrefill-cli', version: VERSION, }); - c.onerror = suppressNoise; - const t = new StreamableHTTPClientTransport(new URL(url)); - await c.connect(t); - return { client: c, transport: t }; - } - - const authProvider = createOAuthProvider(url, formatter); - - const tryConnect = async () => { - const c = new Client({ - name: 'bitrefill-cli', - version: VERSION, - }); - c.onerror = suppressNoise; - const t = new StreamableHTTPClientTransport(new URL(url), { + client.onerror = suppressNoise; + const transport = new StreamableHTTPClientTransport(new URL(url), { authProvider, }); - await c.connect(t); - return { client: c, transport: t }; + await client.connect(transport); + return { client, transport }; }; try { - return await tryConnect(); + return await connectOnce(); } catch (err) { + // First attempt triggers the OAuth dance via authProvider; retry once + // the tokens are persisted. if (!(err instanceof UnauthorizedError)) throw err; - - formatter.info('Authorization required...'); - const code = await waitForCallback(); - formatter.info('Authorization code received.'); - - const c = new Client({ - name: 'bitrefill-cli', - version: VERSION, - }); - c.onerror = suppressNoise; - const t = new StreamableHTTPClientTransport(new URL(url), { - authProvider, - }); - await t.finishAuth(code); - await c.connect(t); - return { client: c, transport: t }; + return await connectOnce(); } } // --- Init (pre-connect) --- -function isInitCommand(): boolean { - const hasInit = process.argv.some((arg, i) => arg === 'init' && i >= 2); - if (!hasInit) return false; - const hasHelp = - process.argv.includes('--help') || process.argv.includes('-h'); - return !hasHelp; -} - const INIT_HELP = `Usage: bitrefill init [options] -Set up the CLI: validate API key, store credentials, and optionally register with OpenClaw. +Set up the CLI: register with OpenClaw. Options: - --api-key Bitrefill API key --openclaw Force OpenClaw integration even if not auto-detected - --non-interactive Disable interactive prompts -h, --help Display help for command`; +function detectInitInvocation(): { isInit: boolean; hasHelp: boolean } { + const isInit = process.argv.some((arg, i) => arg === 'init' && i >= 2); + const hasHelp = + process.argv.includes('--help') || process.argv.includes('-h'); + return { isInit, hasHelp }; +} + async function handleInit(): Promise { const formatter = createOutputFormatter(resolveJsonMode()); - - const apiKeyIdx = process.argv.indexOf('--api-key'); - const apiKey = - apiKeyIdx !== -1 && apiKeyIdx + 1 < process.argv.length - ? process.argv[apiKeyIdx + 1] - : undefined; - try { await runInit({ - apiKey, openclaw: process.argv.includes('--openclaw'), - nonInteractive: !resolveInteractive(), }); + clearOAuthStore(BASE_MCP_URL); } catch (err) { formatter.error(err); process.exit(1); } } -function isInitHelpCommand(): boolean { - const hasInit = process.argv.some((arg, i) => arg === 'init' && i >= 2); - const hasHelp = - process.argv.includes('--help') || process.argv.includes('-h'); - return hasInit && hasHelp; +function parseJsonResponse(raw: string): unknown { + if (!raw.trim()) return undefined; + try { + return JSON.parse(raw) as unknown; + } catch { + return undefined; + } } -// --- Main --- +function pickAuthErrorMessage(payload: unknown, fallback: string): string { + if (!payload || typeof payload !== 'object') { + return fallback; + } + if ( + 'error' in payload && + typeof payload.error === 'string' && + payload.error.trim() + ) { + return payload.error; + } + if ( + 'message' in payload && + typeof payload.message === 'string' && + payload.message.trim() + ) { + return payload.message; + } + return fallback; +} -async function main(): Promise { - if (isInitCommand()) { - await handleInit(); +async function postCliAuth( + endpoint: 'login' | 'verify', + body: Record, + accessToken: string +): Promise { + const url = new URL(endpoint, CLI_AUTH_BASE_URL); + const response = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ + ...body, + accessToken, + }), + }); + const raw = await response.text(); + const payload = parseJsonResponse(raw); + if (!response.ok) { + throw new Error( + pickAuthErrorMessage( + payload, + `${response.status} ${response.statusText}`.trim() + ) + ); + } + return payload; +} + +async function revokeAccessToken( + token: string, + clientId?: string +): Promise { + const body = new URLSearchParams({ + token, + token_type_hint: 'access_token', + }); + if (clientId?.trim()) { + body.set('client_id', clientId.trim()); + } + + const response = await fetch(CLI_OAUTH_REVOKE_URL, { + method: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded', + }, + body, + }); + + if (!response.ok) { + throw new Error( + `Failed to revoke access token: ${response.status} ${response.statusText}`.trim() + ); + } +} + +function resolveCliAuthAccessToken(): string { + const oauthAccessToken = + loadState(BASE_MCP_URL).tokens?.access_token?.trim(); + if (oauthAccessToken) { + return oauthAccessToken; + } + + throw new Error( + 'Access token is required for login/verify. Configure credentials first with bitrefill init.' + ); +} + +async function revokeIfPresent(formatter: OutputFormatter): Promise { + const state = loadState(BASE_MCP_URL); + const accessToken = state.tokens?.access_token?.trim(); + if (!accessToken) return; + try { + await revokeAccessToken(accessToken, state.clientInfo?.client_id); + } catch (err) { + formatter.clientError( + err instanceof Error ? err : new Error(String(err)) + ); + } +} + +function extractChallenge(payload: unknown): PendingChallenge { + if (!payload || typeof payload !== 'object') return {}; + const p = payload as Record; + return { + type: + typeof p.challenge_type === 'string' ? p.challenge_type : undefined, + next_step: typeof p.next_step === 'string' ? p.next_step : undefined, + browser_url: + typeof p.browser_url === 'string' ? p.browser_url : undefined, + }; +} + +// FLAG-TO-WILLIAM: server-side, consider re-using the same 2FA challenge +// endpoints bitrefill.com uses for web sign-in, so the CLI inherits the +// user's enrolled factor (TOTP / passkey / email / recovery code / ...) +// instead of maintaining a parallel /cli/login + /cli/verify surface. +// Today /cli/login emails a code regardless of enrollment, which silently +// downgrades hardware 2FA to email — an account-takeover vector via email +// compromise. Once the endpoints relay challenge_type / next_step / +// browser_url, the CLI here just relays them (extractChallenge above); +// no further client work is needed for TOTP, recovery codes, or other +// text-shaped factors. Passkey/WebAuthn lands as a browser_url hand-off +// until native FIDO support is on the table. +async function handleLogin( + formatter: OutputFormatter, + email: string +): Promise { + const accessToken = resolveCliAuthAccessToken(); + const payload = await postCliAuth('login', { email }, accessToken); + const challenge = extractChallenge(payload); + + saveState(BASE_MCP_URL, { + ...loadState(BASE_MCP_URL), + pendingEmail: email, + pendingChallenge: challenge, + }); + + if (challenge.browser_url) { + formatter.info( + `Open ${challenge.browser_url} to authenticate, then re-run any command.` + ); return; } + formatter.info( + challenge.next_step ?? + `Sign-in challenge issued for ${email}. Run: bitrefill verify --code [--otp ]` + ); +} + +async function handleVerify( + formatter: OutputFormatter, + code: string, + otp?: string, + commandEmail?: string +): Promise { + const state = loadState(BASE_MCP_URL); + const email = commandEmail?.trim() || state.pendingEmail; + if (!email) { + throw new Error( + 'No pending login. Run: bitrefill login --email first.' + ); + } + if ( + commandEmail && + state.pendingEmail && + commandEmail !== state.pendingEmail + ) { + throw new Error( + `Email mismatch: pending login is for ${state.pendingEmail}. Re-run bitrefill login --email ${commandEmail} first.` + ); + } + + const accessToken = resolveCliAuthAccessToken(); + const payload: Record = { email, code }; + if (otp?.trim()) { + payload.otp = otp.trim(); + } + await postCliAuth('verify', payload, accessToken); + const { + pendingEmail: _e, + pendingChallenge: _c, + ...rest + } = loadState(BASE_MCP_URL); + saveState(BASE_MCP_URL, { ...rest, email }); + formatter.info(`Signed in as ${email}.`); +} + +async function handleLogout(formatter: OutputFormatter): Promise { + await revokeIfPresent(formatter); + clearAccessTokenState(BASE_MCP_URL); + formatter.info('Signed out.'); +} + +function isRegistered(serverUrl: string): string | undefined { + return loadState(serverUrl).email; +} + +function getClientId(serverUrl: string): string | undefined { + return loadState(serverUrl).clientInfo?.client_id; +} + +// --- Main --- - if (isInitHelpCommand()) { +async function main(): Promise { + const init = detectInitInvocation(); + if (init.isInit && init.hasHelp) { console.log(INIT_HELP); return; } + if (init.isInit) { + await handleInit(); + return; + } - const apiKey = resolveApiKey(); const formatter = createOutputFormatter(resolveJsonMode()); - const mcpUrl = resolveMcpUrl(apiKey); - const useOAuth = !apiKey && !process.env.MCP_URL; - - if (useOAuth && !resolveInteractive()) { - formatter.error( - new Error( - 'Authorization required but running in non-interactive mode.\n' + - 'Use --api-key or set BITREFILL_API_KEY to authenticate without a browser.\n' + - 'Or run: bitrefill init' - ) + + if (!IS_DEFAULT_BASE_MCP_URL) { + process.stderr.write( + `[bitrefill] using ${new URL(BASE_MCP_URL).host} (BASE_URL override)\n` ); - process.exit(1); } // Phase 1: connect and discover tools - const { client, transport } = await createMcpClient( - mcpUrl, - useOAuth, - formatter - ); + let connected; + try { + connected = await createMcpClient(BASE_MCP_URL, formatter); + } catch (err) { + if (err instanceof UnauthorizedError) { + throw new Error( + 'Failed to establish a session with Bitrefill. Try: bitrefill reset, then re-run.' + ); + } + if ( + err instanceof Error && + /ENOTFOUND|ECONNREFUSED|fetch failed|getaddrinfo/i.test(err.message) + ) { + throw new Error( + `Cannot reach ${BASE_MCP_URL}. Check your network${IS_DEFAULT_BASE_MCP_URL ? '' : ' and BASE_URL'}.` + ); + } + throw err; + } + const { client, transport } = connected; + + const registeredEmail = isRegistered(BASE_MCP_URL); + const clientId = getClientId(BASE_MCP_URL); + const identity = registeredEmail + ? ({ + status: 'registered', + client_id: clientId, + email: registeredEmail, + } as const) + : ({ status: 'unregistered', client_id: clientId } as const); const toolsResult = await client.request( { method: 'tools/list', params: {} }, @@ -363,31 +540,32 @@ async function main(): Promise { ); const tools = toolsResult.tools; + let description = `Bitrefill CLI - browse, buy, and manage gift cards, mobile top-ups, and eSIMs. + +Terms: https://www.bitrefill.com/terms +Privacy: https://www.bitrefill.com/privacy`; + + if (registeredEmail) { + description += `\n\nSigned in as: ${registeredEmail}${clientId ? ` (client_id: ${clientId})` : ''}`; + } else if (clientId) { + description += `\n\nIdentity: unregistered (client_id: ${clientId})`; + } + + description += `\n\nFor a machine-readable schema of every command, run \`bitrefill manifest\`.`; + // Phase 2: build CLI from discovered tools const program = new Command() .name('bitrefill') - .description( - 'Bitrefill CLI - browse, buy, and manage gift cards, mobile top-ups, and eSIMs.\n\nTerms: https://www.bitrefill.com/terms\nPrivacy: https://www.bitrefill.com/privacy' - ) + .description(description) .version(VERSION) - .option( - '--api-key ', - 'Bitrefill API key (overrides BITREFILL_API_KEY env var)' - ) .option( '--json', 'Output raw JSON (TOON decoded); use with jq. Non-result messages go to stderr.' - ) - .option( - '--no-interactive', - 'Disable browser-based auth and interactive prompts (auto-detected in CI / non-TTY)' ); program .command('init') - .description( - 'Set up the CLI: validate API key, store credentials, and optionally register with OpenClaw' - ) + .description('Set up the CLI: register with OpenClaw') .option( '--openclaw', 'Force OpenClaw integration even if not auto-detected' @@ -396,24 +574,6 @@ async function main(): Promise { formatter.info('init has already been handled.'); }); - program - .command('logout') - .description('Clear stored OAuth credentials') - .action(() => { - if (!useOAuth) { - formatter.info( - 'Using API key authentication — no stored credentials to clear.' - ); - return; - } - try { - fs.unlinkSync(stateFilePath(mcpUrl)); - formatter.info('Cleared stored credentials.'); - } catch { - formatter.info('No stored credentials to clear.'); - } - }); - program .command('llm-context') .description( @@ -425,8 +585,14 @@ async function main(): Promise { ) .action((opts: { output?: string }) => { const md = generateLlmContextMarkdown(tools, { - mcpUrl, + mcpUrl: BASE_MCP_URL, programName: program.name(), + manifest: buildManifest({ + tools, + identity, + mcpUrl: BASE_MCP_URL, + cliVersion: VERSION, + }), }); if (opts.output) { fs.writeFileSync(opts.output, md, 'utf-8'); @@ -435,6 +601,126 @@ async function main(): Promise { } }); + program + .command('manifest') + .description( + 'Emit a JSON manifest describing all commands and their schemas.' + ) + .option('-o, --output ', 'Write JSON to a file instead of stdout') + .action((opts: { output?: string }) => { + const manifest = buildManifest({ + tools, + identity, + mcpUrl: BASE_MCP_URL, + cliVersion: VERSION, + }); + if (opts.output) { + fs.writeFileSync( + opts.output, + JSON.stringify(manifest, null, 2), + 'utf-8' + ); + formatter.info(`Wrote manifest to ${opts.output}`); + return; + } + formatter.result([ + { type: 'text', text: JSON.stringify(manifest) }, + ]); + }); + + program + .command('whoami') + .description('Show the current CLI identity.') + .action(() => { + const email = isRegistered(BASE_MCP_URL); + const cid = getClientId(BASE_MCP_URL); + if (resolveJsonMode()) { + const payload = email + ? { identity: 'registered', email, client_id: cid } + : { identity: 'unregistered', client_id: cid }; + formatter.result([ + { type: 'text', text: JSON.stringify(payload) }, + ]); + return; + } + if (email) { + formatter.info( + `Signed in as: ${email}${cid ? ` (client_id: ${cid})` : ''}` + ); + } else if (cid) { + formatter.info(`Identity: unregistered (client_id: ${cid})`); + } else { + formatter.info( + 'Identity: unregistered (no session). Run any command to establish one.' + ); + } + }); + + program + .command('reset') + .description( + 'Clear all local state (rotates client_id and revokes any active session).' + ) + .action(async () => { + await revokeIfPresent(formatter); + clearOAuthStore(BASE_MCP_URL); + formatter.info( + 'Local state cleared. The next command starts a fresh session.' + ); + }); + + if (!registeredEmail) { + program + .command('login') + .description('Send a verification code to your email.') + .requiredOption( + '--email ', + 'Email to send the verification code to' + ) + .action(async (opts: { email: string }) => { + await handleLogin(formatter, opts.email); + }); + + program + .command('verify') + .description('Verify your email code and optionally provide OTP.') + .option( + '--email ', + 'Email used during login (optional; uses the pending login if omitted)' + ) + .requiredOption( + '--code ', + 'Email verification code sent during login.' + ) + .option( + '--otp ', + 'Optional OTP/TOTP code if your account requires a second factor.' + ) + .action( + async (opts: { + email?: string; + code: string; + otp?: string; + }) => { + await handleVerify( + formatter, + opts.code, + opts.otp, + opts.email + ); + } + ); + } + + if (registeredEmail) { + program + .command('logout') + .description('Revoke the session and sign out (keeps client_id).') + .action(async () => { + await handleLogout(formatter); + }); + } + // Register each MCP tool as a subcommand for (const tool of tools) { if (RESERVED_TOOL_NAMES.has(tool.name)) continue; diff --git a/src/init.test.ts b/src/init.test.ts index ce3571f..05c9563 100644 --- a/src/init.test.ts +++ b/src/init.test.ts @@ -28,56 +28,6 @@ describe('detectOpenClaw', () => { }); }); -describe('OpenClaw .env merge', () => { - const testDir = path.join(os.tmpdir(), `bitrefill-oc-test-${Date.now()}`); - const testEnvFile = path.join(testDir, '.env'); - - beforeEach(() => { - fs.mkdirSync(testDir, { recursive: true }); - }); - - afterEach(() => { - fs.rmSync(testDir, { recursive: true, force: true }); - }); - - it('appends BITREFILL_API_KEY to an empty file', () => { - fs.writeFileSync(testEnvFile, '', 'utf-8'); - - const varLine = 'BITREFILL_API_KEY=test_key_123'; - let content = fs.readFileSync(testEnvFile, 'utf-8'); - const lines = content.split('\n'); - lines.push(varLine); - fs.writeFileSync(testEnvFile, lines.join('\n'), 'utf-8'); - - const result = fs.readFileSync(testEnvFile, 'utf-8'); - expect(result).toContain('BITREFILL_API_KEY=test_key_123'); - }); - - it('replaces existing BITREFILL_API_KEY line', () => { - fs.writeFileSync( - testEnvFile, - 'OTHER_VAR=foo\nBITREFILL_API_KEY=old_key\nANOTHER=bar\n', - 'utf-8' - ); - - let content = fs.readFileSync(testEnvFile, 'utf-8'); - const lines = content.split('\n'); - const idx = lines.findIndex((l: string) => - l.startsWith('BITREFILL_API_KEY=') - ); - if (idx !== -1) { - lines[idx] = 'BITREFILL_API_KEY=new_key_456'; - } - fs.writeFileSync(testEnvFile, lines.join('\n'), 'utf-8'); - - const result = fs.readFileSync(testEnvFile, 'utf-8'); - expect(result).toContain('BITREFILL_API_KEY=new_key_456'); - expect(result).not.toContain('old_key'); - expect(result).toContain('OTHER_VAR=foo'); - expect(result).toContain('ANOTHER=bar'); - }); -}); - describe('OpenClaw config merge', () => { const testDir = path.join( os.tmpdir(), @@ -93,7 +43,7 @@ describe('OpenClaw config merge', () => { fs.rmSync(testDir, { recursive: true, force: true }); }); - it('creates mcp.servers.bitrefill with env-var reference URL', () => { + it('creates mcp.servers.bitrefill with auth header reference', () => { const config = { gateway: { port: 3000 } }; fs.writeFileSync(testConfig, JSON.stringify(config), 'utf-8'); @@ -103,7 +53,10 @@ describe('OpenClaw config merge', () => { const mcp = (parsed.mcp ?? {}) as Record; const servers = (mcp.servers ?? {}) as Record; servers['bitrefill'] = { - url: 'https://api.bitrefill.com/mcp/${BITREFILL_API_KEY}', + url: 'https://api.bitrefill.com/mcp', + headers: { + Authorization: 'Bearer ${BITREFILL_API_KEY}', + }, name: 'Bitrefill', description: 'Gift cards, mobile top-ups, and eSIMs', }; @@ -120,9 +73,10 @@ describe('OpenClaw config merge', () => { const resultServers = resultMcp.servers as Record; const bitrefill = resultServers.bitrefill as Record; - expect(bitrefill.url).toBe( - 'https://api.bitrefill.com/mcp/${BITREFILL_API_KEY}' - ); + expect(bitrefill.url).toBe('https://api.bitrefill.com/mcp'); + expect( + (bitrefill.headers as Record).Authorization + ).toBe('Bearer ${BITREFILL_API_KEY}'); expect(bitrefill.url).not.toMatch(/br_live_|br_test_/); expect(bitrefill.name).toBe('Bitrefill'); }); @@ -146,7 +100,10 @@ describe('OpenClaw config merge', () => { const mcp = parsed.mcp as Record; const servers = mcp.servers as Record; servers['bitrefill'] = { - url: 'https://api.bitrefill.com/mcp/${BITREFILL_API_KEY}', + url: 'https://api.bitrefill.com/mcp', + headers: { + Authorization: 'Bearer ${BITREFILL_API_KEY}', + }, name: 'Bitrefill', description: 'Gift cards, mobile top-ups, and eSIMs', }; diff --git a/src/init.ts b/src/init.ts index 59ef896..ab13f2c 100644 --- a/src/init.ts +++ b/src/init.ts @@ -1,134 +1,25 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; -import readline from 'node:readline'; -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; -import { ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js'; import type { Tool } from '@modelcontextprotocol/sdk/types.js'; -import { writeCredentials, redactKey } from './credentials.js'; import { generateLlmContextMarkdown } from './llm-context.js'; -import { VERSION } from './version.js'; - -const BASE_MCP_URL = 'https://api.bitrefill.com/mcp'; -const DEVELOPER_PORTAL_URL = 'https://www.bitrefill.com/account/developers'; +import { BASE_MCP_URL } from './config.js'; const OPENCLAW_DIR = path.join(os.homedir(), '.openclaw'); const OPENCLAW_CONFIG = path.join(OPENCLAW_DIR, 'openclaw.json'); -const OPENCLAW_ENV = path.join(OPENCLAW_DIR, '.env'); const OPENCLAW_SKILL_DIR = path.join(OPENCLAW_DIR, 'skills', 'bitrefill'); const OPENCLAW_SKILL_FILE = path.join(OPENCLAW_SKILL_DIR, 'SKILL.md'); export interface InitOptions { - apiKey?: string; openclaw?: boolean; - nonInteractive?: boolean; } export interface InitResult { - apiKey: string; toolCount: number; openclawConfigured: boolean; skillPath?: string; } -// --- Key input --- - -async function promptForApiKey(): Promise { - process.stderr.write(`\nGet your API key at: ${DEVELOPER_PORTAL_URL}\n\n`); - - return new Promise((resolve, reject) => { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stderr, - }); - - if (process.stdin.isTTY) { - process.stdin.setRawMode(true); - } - - let input = ''; - process.stderr.write('API key: '); - - if (process.stdin.isTTY) { - process.stdin.setRawMode(true); - process.stdin.resume(); - process.stdin.setEncoding('utf-8'); - - const onData = (ch: string) => { - const c = ch.toString(); - if (c === '\n' || c === '\r') { - process.stdin.setRawMode(false); - process.stdin.removeListener('data', onData); - process.stdin.pause(); - rl.close(); - process.stderr.write('\n'); - const trimmed = input.trim(); - if (!trimmed) { - reject(new Error('No API key provided.')); - } else { - resolve(trimmed); - } - } else if (c === '\u0003') { - process.stdin.setRawMode(false); - rl.close(); - reject(new Error('Aborted.')); - } else if (c === '\u007f' || c === '\b') { - if (input.length > 0) { - input = input.slice(0, -1); - process.stderr.write('\b \b'); - } - } else { - input += c; - process.stderr.write('*'); - } - }; - - process.stdin.on('data', onData); - } else { - rl.question('', (answer) => { - rl.close(); - const trimmed = answer.trim(); - if (!trimmed) { - reject(new Error('No API key provided.')); - } else { - resolve(trimmed); - } - }); - } - }); -} - -function resolveInitApiKey(opts: InitOptions): string | undefined { - return opts.apiKey || process.env.BITREFILL_API_KEY || undefined; -} - -// --- MCP validation --- - -async function validateApiKey(apiKey: string): Promise<{ tools: Tool[] }> { - const url = `${BASE_MCP_URL}/${apiKey}`; - const client = new Client({ - name: 'bitrefill-cli', - version: VERSION, - }); - const transport = new StreamableHTTPClientTransport(new URL(url)); - - try { - await client.connect(transport); - const result = await client.request( - { method: 'tools/list', params: {} }, - ListToolsResultSchema - ); - return { tools: result.tools }; - } finally { - try { - await transport.close(); - } catch { - /* best-effort cleanup */ - } - } -} - // --- OpenClaw detection --- export function detectOpenClaw(forceFlag: boolean): boolean { @@ -141,36 +32,6 @@ export function detectOpenClaw(forceFlag: boolean): boolean { } } -// --- OpenClaw .env --- - -function writeOpenClawEnv(apiKey: string): void { - fs.mkdirSync(OPENCLAW_DIR, { recursive: true }); - - const varLine = `BITREFILL_API_KEY=${apiKey}`; - let content = ''; - - try { - content = fs.readFileSync(OPENCLAW_ENV, 'utf-8'); - } catch { - /* file may not exist */ - } - - const lines = content.split('\n'); - const idx = lines.findIndex((l) => l.startsWith('BITREFILL_API_KEY=')); - - if (idx !== -1) { - lines[idx] = varLine; - } else { - if (content.length > 0 && !content.endsWith('\n')) { - lines.push(''); - } - lines.push(varLine); - } - - const result = lines.join('\n').replace(/\n{3,}/g, '\n\n'); - fs.writeFileSync(OPENCLAW_ENV, result, { mode: 0o600 }); -} - // --- OpenClaw config merge --- interface OpenClawConfig { @@ -196,7 +57,10 @@ function mergeOpenClawConfig(): void { if (!config.mcp.servers) config.mcp.servers = {}; config.mcp.servers['bitrefill'] = { - url: `${BASE_MCP_URL}/\${BITREFILL_API_KEY}`, + url: BASE_MCP_URL, + headers: { + Authorization: 'Bearer ${BITREFILL_API_KEY}', + }, name: 'Bitrefill', description: 'Gift cards, mobile top-ups, and eSIMs', }; @@ -225,38 +89,12 @@ function writeSkillFile(tools: Tool[], mcpUrl: string): string { // --- Orchestrator --- export async function runInit(opts: InitOptions): Promise { - let apiKey = resolveInitApiKey(opts); - - if (!apiKey) { - if (opts.nonInteractive) { - throw new Error( - 'No API key provided.\n' + - 'Pass --api-key or set BITREFILL_API_KEY.\n' + - `Get a key at: ${DEVELOPER_PORTAL_URL}` - ); - } - apiKey = await promptForApiKey(); - } - - process.stderr.write('Validating API key...\n'); - - let tools: Tool[]; - try { - const result = await validateApiKey(apiKey); - tools = result.tools; - } catch { - throw new Error( - `Invalid API key or connection failed.\nGet a key at: ${DEVELOPER_PORTAL_URL}` - ); - } - - writeCredentials(apiKey); + const tools: Tool[] = []; const openclawDetected = detectOpenClaw(opts.openclaw ?? false); let skillPath: string | undefined; if (openclawDetected) { - writeOpenClawEnv(apiKey); mergeOpenClawConfig(); skillPath = writeSkillFile(tools, BASE_MCP_URL); } @@ -265,7 +103,6 @@ export async function runInit(opts: InitOptions): Promise { '', 'Bitrefill initialized.', '', - ` Key: ${redactKey(apiKey)} (stored in ~/.config/bitrefill-cli/)`, ` Tools: ${tools.length} available`, ]; @@ -291,7 +128,6 @@ export async function runInit(opts: InitOptions): Promise { process.stderr.write(summary.join('\n')); return { - apiKey, toolCount: tools.length, openclawConfigured: openclawDetected, skillPath, diff --git a/src/llm-context.test.ts b/src/llm-context.test.ts index 21a7912..0d37efd 100644 --- a/src/llm-context.test.ts +++ b/src/llm-context.test.ts @@ -62,6 +62,27 @@ describe('generateLlmContextMarkdown', () => { expect(md.indexOf('`alpha`')).toBeLessThan(md.indexOf('`zebra`')); }); + it('embeds the manifest as a fenced JSON block between markers when provided', () => { + const manifest = { manifest_version: 1, commands: [] }; + const md = generateLlmContextMarkdown([], { manifest }); + + expect(md).toContain('## Manifest'); + expect(md).toContain(''); + expect(md).toContain(''); + expect(md).toContain('"manifest_version": 1'); + + const begin = md.indexOf(''); + const end = md.indexOf(''); + const block = md.slice(begin, end); + expect(block).toContain('```json'); + }); + + it('omits the manifest section when no manifest is supplied', () => { + const md = generateLlmContextMarkdown([]); + expect(md).not.toContain('## Manifest'); + expect(md).not.toContain(''); + }); + it('includes output schema when present', () => { const tools: Tool[] = [ { diff --git a/src/llm-context.ts b/src/llm-context.ts index a9dcc89..491e5a9 100644 --- a/src/llm-context.ts +++ b/src/llm-context.ts @@ -9,6 +9,8 @@ export interface GenerateLlmContextOptions { mcpUrl?: string; /** CLI program name (default `bitrefill`). */ programName?: string; + /** Optional structured manifest; embedded as a fenced JSON appendix when present. */ + manifest?: unknown; } function sanitizeMcpUrlForDocs(url: string): string { @@ -182,7 +184,7 @@ export function generateLlmContextMarkdown( lines.push('## Connection'); lines.push(''); lines.push( - `- MCP URL used for this run: \`${sanitizeMcpUrlForDocs(options.mcpUrl)}\` (override with \`MCP_URL\` or \`--api-key\` / \`BITREFILL_API_KEY\`).` + `- MCP URL used for this run: \`${sanitizeMcpUrlForDocs(options.mcpUrl)}\` (override with \`MCP_URL\` or \`BITREFILL_API_KEY\`).` ); lines.push(''); } @@ -231,5 +233,20 @@ export function generateLlmContextMarkdown( lines.push(''); } + if (options?.manifest !== undefined) { + lines.push('## Manifest'); + lines.push(''); + lines.push( + 'Programmatic agents can extract the JSON block between the markers below.' + ); + lines.push(''); + lines.push(''); + lines.push('```json'); + lines.push(JSON.stringify(options.manifest, null, 2)); + lines.push('```'); + lines.push(''); + lines.push(''); + } + return lines.join('\n'); } diff --git a/src/manifest.test.ts b/src/manifest.test.ts new file mode 100644 index 0000000..17f7896 --- /dev/null +++ b/src/manifest.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest'; +import type { Tool } from '@modelcontextprotocol/sdk/types.js'; +import { buildManifest } from './manifest.js'; + +const sampleTool: Tool = { + name: 'search-products', + description: 'Search products.', + inputSchema: { + type: 'object', + properties: { query: { type: 'string' } }, + required: ['query'], + }, +}; + +describe('buildManifest', () => { + it('emits the envelope with manifest_version 1', () => { + const m = buildManifest({ + tools: [], + identity: { status: 'unregistered' }, + mcpUrl: 'https://api.bitrefill.com/mcp', + cliVersion: '1.2.3', + }); + expect(m.manifest_version).toBe(1); + expect(m.cli_version).toBe('1.2.3'); + expect(m.mcp_url).toBe('https://api.bitrefill.com/mcp'); + expect(m.globals.map((g) => g.name)).toContain('--json'); + }); + + it('passes MCP tool input schemas through verbatim', () => { + const m = buildManifest({ + tools: [sampleTool], + identity: { status: 'unregistered' }, + mcpUrl: 'x', + cliVersion: 'x', + }); + const tool = m.commands.find((c) => c.name === 'search-products'); + expect(tool?.kind).toBe('mcp-tool'); + expect(tool?.input_schema).toEqual(sampleTool.inputSchema); + expect(tool?.output.kind).toBe('tool-result'); + }); + + it('shows login/verify when unregistered and logout when registered', () => { + const names = ( + identity: Parameters[0]['identity'] + ) => + buildManifest({ + tools: [], + identity, + mcpUrl: 'x', + cliVersion: 'x', + }).commands.map((c) => c.name); + + const unreg = names({ status: 'unregistered', client_id: 'c1' }); + expect(unreg).toContain('login'); + expect(unreg).toContain('verify'); + expect(unreg).not.toContain('logout'); + + const reg = names({ + status: 'registered', + client_id: 'c1', + email: 'a@b.com', + }); + expect(reg).toContain('logout'); + expect(reg).not.toContain('login'); + expect(reg).not.toContain('verify'); + }); + + it('sorts commands alphabetically by name', () => { + const m = buildManifest({ + tools: [sampleTool], + identity: { status: 'unregistered' }, + mcpUrl: 'x', + cliVersion: 'x', + }); + const names = m.commands.map((c) => c.name); + const sorted = [...names].sort((a, b) => a.localeCompare(b)); + expect(names).toEqual(sorted); + }); + + it('requires verify code and keeps otp optional', () => { + const m = buildManifest({ + tools: [], + identity: { status: 'unregistered' }, + mcpUrl: 'x', + cliVersion: 'x', + }); + const verify = m.commands.find((c) => c.name === 'verify'); + expect(verify?.input_schema).toMatchObject({ + required: ['code'], + properties: { + code: { type: 'string' }, + otp: { type: 'string' }, + }, + }); + }); + + it('describes whoami output with a JSON Schema', () => { + const m = buildManifest({ + tools: [], + identity: { status: 'unregistered' }, + mcpUrl: 'x', + cliVersion: 'x', + }); + const whoami = m.commands.find((c) => c.name === 'whoami'); + expect(whoami?.output.kind).toBe('json'); + expect(whoami?.output.schema).toMatchObject({ + type: 'object', + properties: { identity: { enum: ['unregistered', 'registered'] } }, + }); + }); +}); diff --git a/src/manifest.ts b/src/manifest.ts new file mode 100644 index 0000000..ba3f85b --- /dev/null +++ b/src/manifest.ts @@ -0,0 +1,244 @@ +import type { Tool } from '@modelcontextprotocol/sdk/types.js'; + +export type ManifestIdentityStatus = 'unregistered' | 'registered'; + +export interface ManifestIdentity { + status: ManifestIdentityStatus; + client_id?: string; + email?: string; +} + +export type ManifestOutputKind = + | 'tool-result' + | 'json' + | 'text' + | 'info' + | 'void'; + +export interface ManifestCommandOutput { + kind: ManifestOutputKind; + schema?: Record; +} + +export type ManifestCommandKind = 'mcp-tool' | 'cli-builtin'; + +export interface ManifestCommand { + name: string; + kind: ManifestCommandKind; + description: string; + input_schema: Record; + output: ManifestCommandOutput; +} + +export interface ManifestGlobal { + name: string; + type: 'boolean' | 'string'; + description: string; +} + +export interface Manifest { + manifest_version: 1; + cli_version: string; + mcp_url: string; + identity: ManifestIdentity; + globals: ManifestGlobal[]; + commands: ManifestCommand[]; +} + +export interface BuildManifestArgs { + tools: Tool[]; + identity: ManifestIdentity; + mcpUrl: string; + cliVersion: string; +} + +const EMPTY_INPUT_SCHEMA = { + type: 'object', + properties: {}, + additionalProperties: false, +} as const; + +const GLOBALS: ManifestGlobal[] = [ + { + name: '--json', + type: 'boolean', + description: + 'Emit machine-readable JSON; non-result messages go to stderr.', + }, +]; + +const WHOAMI_OUTPUT_SCHEMA = { + type: 'object', + properties: { + identity: { type: 'string', enum: ['unregistered', 'registered'] }, + email: { type: 'string' }, + client_id: { type: 'string' }, + }, + required: ['identity'], + additionalProperties: false, +} as const; + +function buildBuiltinCommands(identity: ManifestIdentity): ManifestCommand[] { + const registered = identity.status === 'registered'; + + const commands: ManifestCommand[] = [ + { + name: 'init', + kind: 'cli-builtin', + description: 'Set up the CLI: register with OpenClaw.', + input_schema: { + type: 'object', + properties: { + openclaw: { + type: 'boolean', + description: + 'Force OpenClaw integration even if not auto-detected', + }, + }, + additionalProperties: false, + }, + output: { kind: 'info' }, + }, + { + name: 'llm-context', + kind: 'cli-builtin', + description: + 'Emit MCP tools reference as Markdown (for CLAUDE.md, Cursor rules, Copilot instructions).', + input_schema: { + type: 'object', + properties: { + output: { + type: 'string', + description: + 'Write Markdown to a file instead of stdout', + }, + }, + additionalProperties: false, + }, + output: { kind: 'text' }, + }, + { + name: 'manifest', + kind: 'cli-builtin', + description: + 'Emit a JSON manifest describing all commands and their schemas.', + input_schema: { + type: 'object', + properties: { + output: { + type: 'string', + description: 'Write JSON to a file instead of stdout', + }, + }, + additionalProperties: false, + }, + output: { kind: 'json', schema: { $ref: '#' } }, + }, + { + name: 'whoami', + kind: 'cli-builtin', + description: 'Show the current CLI identity.', + input_schema: { ...EMPTY_INPUT_SCHEMA }, + output: { kind: 'json', schema: { ...WHOAMI_OUTPUT_SCHEMA } }, + }, + { + name: 'reset', + kind: 'cli-builtin', + description: + 'Clear all local state (rotates client_id and revokes any active session).', + input_schema: { ...EMPTY_INPUT_SCHEMA }, + output: { kind: 'info' }, + }, + ]; + + if (!registered) { + commands.push( + { + name: 'login', + kind: 'cli-builtin', + description: 'Send a verification code to your email.', + input_schema: { + type: 'object', + properties: { + email: { + type: 'string', + format: 'email', + description: + 'Email to send the verification code to', + }, + }, + required: ['email'], + additionalProperties: false, + }, + output: { kind: 'info' }, + }, + { + name: 'verify', + kind: 'cli-builtin', + description: + 'Verify your email code and optionally provide OTP.', + input_schema: { + type: 'object', + properties: { + email: { + type: 'string', + format: 'email', + description: + 'Email used during login (optional; uses pending login if omitted)', + }, + code: { + type: 'string', + description: + 'Email verification code sent during login.', + }, + otp: { + type: 'string', + description: + 'Optional OTP/TOTP code if your account requires a second factor.', + }, + }, + required: ['code'], + additionalProperties: false, + }, + output: { kind: 'info' }, + } + ); + } else { + commands.push({ + name: 'logout', + kind: 'cli-builtin', + description: 'Revoke the session and sign out (keeps client_id).', + input_schema: { ...EMPTY_INPUT_SCHEMA }, + output: { kind: 'info' }, + }); + } + + return commands; +} + +function toolToCommand(tool: Tool): ManifestCommand { + return { + name: tool.name, + kind: 'mcp-tool', + description: tool.description?.trim() ?? '', + input_schema: + (tool.inputSchema as Record) ?? EMPTY_INPUT_SCHEMA, + output: { kind: 'tool-result' }, + }; +} + +export function buildManifest(args: BuildManifestArgs): Manifest { + const commands = [ + ...buildBuiltinCommands(args.identity), + ...args.tools.map(toolToCommand), + ].sort((a, b) => a.name.localeCompare(b.name)); + + return { + manifest_version: 1, + cli_version: args.cliVersion, + mcp_url: args.mcpUrl, + identity: { ...args.identity }, + globals: GLOBALS, + commands, + }; +}