Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .cursorignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.env
.env.local
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ dist/
.projects/cache
.projects/vault
.env
.env.local
67 changes: 56 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"

Expand Down
88 changes: 88 additions & 0 deletions src/credentials.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
63 changes: 63 additions & 0 deletions src/credentials.ts
Original file line number Diff line number Diff line change
@@ -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();
}
84 changes: 77 additions & 7 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,21 +29,19 @@ 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;
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 {
Expand Down Expand Up @@ -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 <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<void> {
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<void> {
if (isInitCommand()) {
await handleInit();
return;
}

if (isInitHelpCommand()) {
console.log(INIT_HELP);
return;
}

const apiKey = resolveApiKey();
const formatter = createOutputFormatter(resolveJsonMode());
const mcpUrl = resolveMcpUrl(apiKey);
Expand All @@ -277,7 +333,8 @@ async function main(): Promise<void> {
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);
Expand Down Expand Up @@ -316,6 +373,19 @@ async function main(): Promise<void> {
'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')
Expand Down
Loading
Loading