diff --git a/.cursorignore b/.cursorignore new file mode 100644 index 0000000..e3d34d2 --- /dev/null +++ b/.cursorignore @@ -0,0 +1,2 @@ +.env +.env.local \ No newline at end of file diff --git a/.gitignore b/.gitignore index 2e876db..9772827 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ dist/ .projects/cache .projects/vault .env +.env.local diff --git a/README.md b/README.md index 3c34390..a994419 100644 --- a/README.md +++ b/README.md @@ -12,32 +12,74 @@ The CLI connects to the [Bitrefill MCP server](https://api.bitrefill.com/mcp) an npm install -g @bitrefill/cli ``` -## Authentication +## Quick start (`init`) -### OAuth (default) +The fastest way to set up the CLI: -On first run, the CLI opens your browser for OAuth authorization. Credentials are stored in `~/.config/bitrefill-cli/`. +```bash +bitrefill init +``` -### API Key +This walks you through a one-time setup: -Generate an API key at [bitrefill.com/account/developers](https://www.bitrefill.com/account/developers) and pass it via the `--api-key` option or the `BITREFILL_API_KEY` environment variable. This skips the OAuth flow entirely. +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 / CI +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 + +# 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. + +### 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 +- Generates `~/.openclaw/skills/bitrefill/SKILL.md` so the agent knows about all available tools -In environments without a TTY (e.g. CI, Docker, scripts), or when `CI=true`, the CLI cannot complete browser-based OAuth. Pass `--no-interactive` to fail fast with a clear message, or use `--api-key` / `BITREFILL_API_KEY` instead. +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. + +You can also pass it explicitly: ```bash -# Option +# 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" - -# Or copy .env.example to .env and fill in your key -cp .env.example .env ``` +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. ## Usage @@ -80,6 +122,9 @@ bitrefill llm-context -o BITREFILL-MCP.md ### Examples ```bash +# First-time setup +bitrefill init + # Search for products bitrefill search-products --query "Netflix" diff --git a/src/credentials.test.ts b/src/credentials.test.ts new file mode 100644 index 0000000..33b8572 --- /dev/null +++ b/src/credentials.test.ts @@ -0,0 +1,88 @@ +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 new file mode 100644 index 0000000..351ff7a --- /dev/null +++ b/src/credentials.ts @@ -0,0 +1,63 @@ +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 fd64f17..260fdbd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,9 +29,11 @@ import { } from './output.js'; import { buildOptionsForTool, parseToolArgs } from './tools.js'; import { generateLlmContextMarkdown } from './llm-context.js'; +import { resolveApiKeyWithStore } from './credentials.js'; +import { runInit } from './init.js'; /** Subcommands defined by the CLI; MCP tools with the same name are skipped. */ -const RESERVED_TOOL_NAMES = new Set(['logout', 'llm-context']); +const RESERVED_TOOL_NAMES = new Set(['logout', 'llm-context', 'init']); const BASE_MCP_URL = 'https://api.bitrefill.com/mcp'; const CALLBACK_PORT = 8098; @@ -39,11 +41,7 @@ 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 { - const idx = process.argv.indexOf('--api-key'); - if (idx !== -1 && idx + 1 < process.argv.length) { - return process.argv[idx + 1]; - } - return process.env.BITREFILL_API_KEY; + return resolveApiKeyWithStore(); } function resolveMcpUrl(apiKey?: string): string { @@ -265,9 +263,67 @@ async function createMcpClient( } } +// --- 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. + +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`; + +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(), + }); + } 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; +} + // --- Main --- async function main(): Promise { + if (isInitCommand()) { + await handleInit(); + return; + } + + if (isInitHelpCommand()) { + console.log(INIT_HELP); + return; + } + const apiKey = resolveApiKey(); const formatter = createOutputFormatter(resolveJsonMode()); const mcpUrl = resolveMcpUrl(apiKey); @@ -277,7 +333,8 @@ async function main(): Promise { 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.' + 'Use --api-key or set BITREFILL_API_KEY to authenticate without a browser.\n' + + 'Or run: bitrefill init' ) ); process.exit(1); @@ -316,6 +373,19 @@ async function main(): Promise { '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' + ) + .option( + '--openclaw', + 'Force OpenClaw integration even if not auto-detected' + ) + .action(() => { + formatter.info('init has already been handled.'); + }); + program .command('logout') .description('Clear stored OAuth credentials') diff --git a/src/init.test.ts b/src/init.test.ts new file mode 100644 index 0000000..ce3571f --- /dev/null +++ b/src/init.test.ts @@ -0,0 +1,167 @@ +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 { detectOpenClaw } from './init.js'; + +const OPENCLAW_DIR = path.join(os.homedir(), '.openclaw'); +const OPENCLAW_CONFIG = path.join(OPENCLAW_DIR, 'openclaw.json'); + +describe('detectOpenClaw', () => { + it('returns true when --openclaw flag is set', () => { + expect(detectOpenClaw(true)).toBe(true); + }); + + it('returns true when ~/.openclaw/openclaw.json exists', () => { + const exists = fs.existsSync(OPENCLAW_CONFIG); + if (exists) { + expect(detectOpenClaw(false)).toBe(true); + } + }); + + it('returns false when config does not exist and flag is false', () => { + const spy = vi.spyOn(fs, 'accessSync').mockImplementation(() => { + throw new Error('ENOENT'); + }); + expect(detectOpenClaw(false)).toBe(false); + spy.mockRestore(); + }); +}); + +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(), + `bitrefill-oc-config-test-${Date.now()}` + ); + const testConfig = path.join(testDir, 'openclaw.json'); + + beforeEach(() => { + fs.mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(testDir, { recursive: true, force: true }); + }); + + it('creates mcp.servers.bitrefill with env-var reference URL', () => { + const config = { gateway: { port: 3000 } }; + fs.writeFileSync(testConfig, JSON.stringify(config), 'utf-8'); + + const parsed = JSON.parse( + fs.readFileSync(testConfig, 'utf-8') + ) as Record; + const mcp = (parsed.mcp ?? {}) as Record; + const servers = (mcp.servers ?? {}) as Record; + servers['bitrefill'] = { + url: 'https://api.bitrefill.com/mcp/${BITREFILL_API_KEY}', + name: 'Bitrefill', + description: 'Gift cards, mobile top-ups, and eSIMs', + }; + parsed.mcp = { ...mcp, servers }; + fs.writeFileSync(testConfig, JSON.stringify(parsed, null, 2), 'utf-8'); + + const result = JSON.parse( + fs.readFileSync(testConfig, 'utf-8') + ) as Record; + + expect(result.gateway).toEqual({ port: 3000 }); + + const resultMcp = result.mcp as Record; + 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).not.toMatch(/br_live_|br_test_/); + expect(bitrefill.name).toBe('Bitrefill'); + }); + + it('preserves existing MCP servers when adding bitrefill', () => { + const config = { + mcp: { + servers: { + 'other-tool': { + command: '/usr/bin/other', + args: ['serve'], + }, + }, + }, + }; + fs.writeFileSync(testConfig, JSON.stringify(config), 'utf-8'); + + const parsed = JSON.parse( + fs.readFileSync(testConfig, 'utf-8') + ) as Record; + const mcp = parsed.mcp as Record; + const servers = mcp.servers as Record; + servers['bitrefill'] = { + url: 'https://api.bitrefill.com/mcp/${BITREFILL_API_KEY}', + name: 'Bitrefill', + description: 'Gift cards, mobile top-ups, and eSIMs', + }; + fs.writeFileSync(testConfig, JSON.stringify(parsed, null, 2), 'utf-8'); + + const result = JSON.parse( + fs.readFileSync(testConfig, 'utf-8') + ) as Record; + const resultMcp = result.mcp as Record; + const resultServers = resultMcp.servers as Record; + + expect(resultServers['other-tool']).toEqual({ + command: '/usr/bin/other', + args: ['serve'], + }); + expect(resultServers['bitrefill']).toBeDefined(); + }); +}); diff --git a/src/init.ts b/src/init.ts new file mode 100644 index 0000000..2531e01 --- /dev/null +++ b/src/init.ts @@ -0,0 +1,295 @@ +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'; + +const BASE_MCP_URL = 'https://api.bitrefill.com/mcp'; +const DEVELOPER_PORTAL_URL = 'https://www.bitrefill.com/account/developers'; + +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: '0.1.1' }); + 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 { + if (forceFlag) return true; + try { + fs.accessSync(OPENCLAW_CONFIG, fs.constants.R_OK); + return true; + } catch { + return false; + } +} + +// --- 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 { + mcp?: { + servers?: Record; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +function mergeOpenClawConfig(): void { + let config: OpenClawConfig = {}; + + try { + config = JSON.parse( + fs.readFileSync(OPENCLAW_CONFIG, 'utf-8') + ) as OpenClawConfig; + } catch { + /* start fresh if missing or malformed */ + } + + if (!config.mcp) config.mcp = {}; + if (!config.mcp.servers) config.mcp.servers = {}; + + config.mcp.servers['bitrefill'] = { + url: `${BASE_MCP_URL}/\${BITREFILL_API_KEY}`, + name: 'Bitrefill', + description: 'Gift cards, mobile top-ups, and eSIMs', + }; + + fs.mkdirSync(OPENCLAW_DIR, { recursive: true }); + fs.writeFileSync( + OPENCLAW_CONFIG, + JSON.stringify(config, null, 2) + '\n', + 'utf-8' + ); +} + +// --- SKILL.md --- + +function writeSkillFile(tools: Tool[], mcpUrl: string): string { + fs.mkdirSync(OPENCLAW_SKILL_DIR, { recursive: true }); + + const md = generateLlmContextMarkdown(tools, { + mcpUrl, + programName: 'bitrefill', + }); + fs.writeFileSync(OPENCLAW_SKILL_FILE, md, 'utf-8'); + return OPENCLAW_SKILL_FILE; +} + +// --- 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 openclawDetected = detectOpenClaw(opts.openclaw ?? false); + let skillPath: string | undefined; + + if (openclawDetected) { + writeOpenClawEnv(apiKey); + mergeOpenClawConfig(); + skillPath = writeSkillFile(tools, BASE_MCP_URL); + } + + const summary = [ + '', + 'Bitrefill initialized.', + '', + ` Key: ${redactKey(apiKey)} (stored in ~/.config/bitrefill-cli/)`, + ` Tools: ${tools.length} available`, + ]; + + if (openclawDetected) { + summary.push( + ' OpenClaw: registered (env-var ref, no plaintext key in config)' + ); + summary.push(` SKILL.md: ${skillPath}`); + } + + summary.push(''); + summary.push('Try it:'); + + if (openclawDetected) { + summary.push( + ' Telegram: "Search for Netflix gift cards on Bitrefill"' + ); + } + + summary.push(' CLI: bitrefill search-products --query "Netflix"'); + summary.push(''); + + process.stderr.write(summary.join('\n')); + + return { + apiKey, + toolCount: tools.length, + openclawConfigured: openclawDetected, + skillPath, + }; +}