Skip to content

Commit a1db292

Browse files
authored
Merge pull request #5 from bitrefill/feat/bitrefill-init-openclaw
feat: bitrefill init for OpenClaw and credential store
2 parents 2d2a626 + 9dc2362 commit a1db292

File tree

8 files changed

+749
-18
lines changed

8 files changed

+749
-18
lines changed

.cursorignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
.env
2+
.env.local

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ dist/
44
.projects/cache
55
.projects/vault
66
.env
7+
.env.local

README.md

Lines changed: 56 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,32 +12,74 @@ The CLI connects to the [Bitrefill MCP server](https://api.bitrefill.com/mcp) an
1212
npm install -g @bitrefill/cli
1313
```
1414

15-
## Authentication
15+
## Quick start (`init`)
1616

17-
### OAuth (default)
17+
The fastest way to set up the CLI:
1818

19-
On first run, the CLI opens your browser for OAuth authorization. Credentials are stored in `~/.config/bitrefill-cli/`.
19+
```bash
20+
bitrefill init
21+
```
2022

21-
### API Key
23+
This walks you through a one-time setup:
2224

23-
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.
25+
1. Prompts for your API key (masked input) -- get one at [bitrefill.com/account/developers](https://www.bitrefill.com/account/developers)
26+
2. Validates the key against the Bitrefill MCP server
27+
3. Stores the key in `~/.config/bitrefill-cli/credentials.json` (permissions `0600`)
28+
4. If [OpenClaw](https://github.com/openclaw/openclaw) is detected, registers Bitrefill as an MCP server and generates a `SKILL.md` for agents
2429

25-
### Non-interactive / CI
30+
Non-interactive and agent-driven usage:
31+
32+
```bash
33+
# Pass the key directly (scripts, CI, OpenClaw agents)
34+
bitrefill init --api-key YOUR_API_KEY --non-interactive
35+
36+
# Or via environment variable
37+
export BITREFILL_API_KEY=YOUR_API_KEY
38+
bitrefill init --non-interactive
39+
40+
# Force OpenClaw integration even if not auto-detected
41+
bitrefill init --openclaw
42+
```
43+
44+
After `init`, the stored key is picked up automatically -- no need to pass `--api-key` on every invocation.
45+
46+
### OpenClaw + Telegram
47+
48+
If you use [OpenClaw](https://github.com/openclaw/openclaw) as your AI agent gateway (e.g. via Telegram), `bitrefill init` does extra work:
49+
50+
- Writes `BITREFILL_API_KEY` to `~/.openclaw/.env` (read by the gateway at activation)
51+
- Adds an MCP server entry to `~/.openclaw/openclaw.json` using `${BITREFILL_API_KEY}` -- the config file never contains the actual key
52+
- Generates `~/.openclaw/skills/bitrefill/SKILL.md` so the agent knows about all available tools
2653

27-
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.
54+
After init, tell your Telegram bot: *"Search for Netflix gift cards on Bitrefill"*.
55+
56+
## Authentication
57+
58+
### API Key (recommended)
59+
60+
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.
61+
62+
You can also pass it explicitly:
2863

2964
```bash
30-
# Option
65+
# Flag
3166
bitrefill --api-key YOUR_API_KEY search-products --query "Netflix"
3267

3368
# Environment variable
3469
export BITREFILL_API_KEY=YOUR_API_KEY
3570
bitrefill search-products --query "Netflix"
36-
37-
# Or copy .env.example to .env and fill in your key
38-
cp .env.example .env
3971
```
4072

73+
Key resolution priority: `--api-key` flag > `BITREFILL_API_KEY` env var > stored credentials file.
74+
75+
### OAuth
76+
77+
On first run without an API key, the CLI opens your browser for OAuth authorization. Credentials are stored in `~/.config/bitrefill-cli/`.
78+
79+
### Non-interactive / CI
80+
81+
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`.
82+
4183
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.
4284

4385
## Usage
@@ -80,6 +122,9 @@ bitrefill llm-context -o BITREFILL-MCP.md
80122
### Examples
81123

82124
```bash
125+
# First-time setup
126+
bitrefill init
127+
83128
# Search for products
84129
bitrefill search-products --query "Netflix"
85130

src/credentials.test.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2+
import fs from 'node:fs';
3+
import path from 'node:path';
4+
import os from 'node:os';
5+
import {
6+
writeCredentials,
7+
readCredentials,
8+
deleteCredentials,
9+
redactKey,
10+
} from './credentials.js';
11+
12+
const TEST_DIR = path.join(os.tmpdir(), `bitrefill-cli-test-${Date.now()}`);
13+
const CREDENTIALS_DIR = path.join(os.homedir(), '.config', 'bitrefill-cli');
14+
const CREDENTIALS_FILE = path.join(CREDENTIALS_DIR, 'credentials.json');
15+
16+
describe('redactKey', () => {
17+
it('redacts a long key showing first 4 and last 3 chars', () => {
18+
expect(redactKey('br_live_abcdefghijk')).toBe('br_l...ijk');
19+
});
20+
21+
it('fully masks keys shorter than 10 characters', () => {
22+
expect(redactKey('short')).toBe('***');
23+
expect(redactKey('123456789')).toBe('***');
24+
});
25+
26+
it('handles exactly 10 character keys', () => {
27+
expect(redactKey('1234567890')).toBe('1234...890');
28+
});
29+
});
30+
31+
describe('writeCredentials / readCredentials / deleteCredentials', () => {
32+
let originalFile: string | null = null;
33+
34+
beforeEach(() => {
35+
try {
36+
originalFile = fs.readFileSync(CREDENTIALS_FILE, 'utf-8');
37+
} catch {
38+
originalFile = null;
39+
}
40+
});
41+
42+
afterEach(() => {
43+
if (originalFile !== null) {
44+
fs.writeFileSync(CREDENTIALS_FILE, originalFile);
45+
} else {
46+
try {
47+
fs.unlinkSync(CREDENTIALS_FILE);
48+
} catch {
49+
/* noop */
50+
}
51+
}
52+
});
53+
54+
it('writes and reads back the API key', () => {
55+
writeCredentials('test_key_1234567890');
56+
const key = readCredentials();
57+
expect(key).toBe('test_key_1234567890');
58+
});
59+
60+
it('overwrites an existing key on re-write', () => {
61+
writeCredentials('first_key_xxxxxxxxx');
62+
writeCredentials('second_key_yyyyyyyy');
63+
expect(readCredentials()).toBe('second_key_yyyyyyyy');
64+
});
65+
66+
it('returns undefined when no credential file exists', () => {
67+
deleteCredentials();
68+
expect(readCredentials()).toBeUndefined();
69+
});
70+
71+
it('deleteCredentials removes the file', () => {
72+
writeCredentials('to_be_deleted_12345');
73+
deleteCredentials();
74+
expect(readCredentials()).toBeUndefined();
75+
});
76+
77+
it('deleteCredentials is safe to call when no file exists', () => {
78+
deleteCredentials();
79+
expect(() => deleteCredentials()).not.toThrow();
80+
});
81+
82+
it('sets restrictive file permissions (0600)', () => {
83+
writeCredentials('perm_test_key_12345');
84+
const stat = fs.statSync(CREDENTIALS_FILE);
85+
const mode = stat.mode & 0o777;
86+
expect(mode).toBe(0o600);
87+
});
88+
});

src/credentials.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
import os from 'node:os';
4+
5+
const CREDENTIALS_DIR = path.join(os.homedir(), '.config', 'bitrefill-cli');
6+
const CREDENTIALS_FILE = path.join(CREDENTIALS_DIR, 'credentials.json');
7+
8+
interface StoredCredentials {
9+
apiKey: string;
10+
}
11+
12+
export function writeCredentials(apiKey: string): void {
13+
fs.mkdirSync(CREDENTIALS_DIR, { recursive: true, mode: 0o700 });
14+
const data: StoredCredentials = { apiKey };
15+
fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify(data, null, 2) + '\n', {
16+
mode: 0o600,
17+
});
18+
fs.chmodSync(CREDENTIALS_FILE, 0o600);
19+
}
20+
21+
export function readCredentials(): string | undefined {
22+
try {
23+
const raw = fs.readFileSync(CREDENTIALS_FILE, 'utf-8');
24+
const data = JSON.parse(raw) as StoredCredentials;
25+
return data.apiKey || undefined;
26+
} catch {
27+
return undefined;
28+
}
29+
}
30+
31+
export function deleteCredentials(): void {
32+
try {
33+
fs.unlinkSync(CREDENTIALS_FILE);
34+
} catch {
35+
/* file may not exist */
36+
}
37+
}
38+
39+
/**
40+
* Redact an API key for display: show the first 4 and last 3 characters.
41+
* Keys shorter than 10 chars are fully masked.
42+
*/
43+
export function redactKey(key: string): string {
44+
if (key.length < 10) return '***';
45+
return `${key.slice(0, 4)}...${key.slice(-3)}`;
46+
}
47+
48+
/**
49+
* Resolve the API key from all available sources, in priority order:
50+
* 1. `--api-key` CLI flag
51+
* 2. `BITREFILL_API_KEY` environment variable
52+
* 3. Stored credential file (~/.config/bitrefill-cli/credentials.json)
53+
*/
54+
export function resolveApiKeyWithStore(): string | undefined {
55+
const idx = process.argv.indexOf('--api-key');
56+
if (idx !== -1 && idx + 1 < process.argv.length) {
57+
return process.argv[idx + 1];
58+
}
59+
if (process.env.BITREFILL_API_KEY) {
60+
return process.env.BITREFILL_API_KEY;
61+
}
62+
return readCredentials();
63+
}

src/index.ts

Lines changed: 77 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,21 +29,19 @@ import {
2929
} from './output.js';
3030
import { buildOptionsForTool, parseToolArgs } from './tools.js';
3131
import { generateLlmContextMarkdown } from './llm-context.js';
32+
import { resolveApiKeyWithStore } from './credentials.js';
33+
import { runInit } from './init.js';
3234

3335
/** Subcommands defined by the CLI; MCP tools with the same name are skipped. */
34-
const RESERVED_TOOL_NAMES = new Set(['logout', 'llm-context']);
36+
const RESERVED_TOOL_NAMES = new Set(['logout', 'llm-context', 'init']);
3537

3638
const BASE_MCP_URL = 'https://api.bitrefill.com/mcp';
3739
const CALLBACK_PORT = 8098;
3840
const CALLBACK_URL = `http://127.0.0.1:${CALLBACK_PORT}/callback`;
3941
const STATE_DIR = path.join(os.homedir(), '.config', 'bitrefill-cli');
4042

4143
function resolveApiKey(): string | undefined {
42-
const idx = process.argv.indexOf('--api-key');
43-
if (idx !== -1 && idx + 1 < process.argv.length) {
44-
return process.argv[idx + 1];
45-
}
46-
return process.env.BITREFILL_API_KEY;
44+
return resolveApiKeyWithStore();
4745
}
4846

4947
function resolveMcpUrl(apiKey?: string): string {
@@ -265,9 +263,67 @@ async function createMcpClient(
265263
}
266264
}
267265

266+
// --- Init (pre-connect) ---
267+
268+
function isInitCommand(): boolean {
269+
const hasInit = process.argv.some((arg, i) => arg === 'init' && i >= 2);
270+
if (!hasInit) return false;
271+
const hasHelp =
272+
process.argv.includes('--help') || process.argv.includes('-h');
273+
return !hasHelp;
274+
}
275+
276+
const INIT_HELP = `Usage: bitrefill init [options]
277+
278+
Set up the CLI: validate API key, store credentials, and optionally register with OpenClaw.
279+
280+
Options:
281+
--api-key <key> Bitrefill API key
282+
--openclaw Force OpenClaw integration even if not auto-detected
283+
--non-interactive Disable interactive prompts
284+
-h, --help Display help for command`;
285+
286+
async function handleInit(): Promise<void> {
287+
const formatter = createOutputFormatter(resolveJsonMode());
288+
289+
const apiKeyIdx = process.argv.indexOf('--api-key');
290+
const apiKey =
291+
apiKeyIdx !== -1 && apiKeyIdx + 1 < process.argv.length
292+
? process.argv[apiKeyIdx + 1]
293+
: undefined;
294+
295+
try {
296+
await runInit({
297+
apiKey,
298+
openclaw: process.argv.includes('--openclaw'),
299+
nonInteractive: !resolveInteractive(),
300+
});
301+
} catch (err) {
302+
formatter.error(err);
303+
process.exit(1);
304+
}
305+
}
306+
307+
function isInitHelpCommand(): boolean {
308+
const hasInit = process.argv.some((arg, i) => arg === 'init' && i >= 2);
309+
const hasHelp =
310+
process.argv.includes('--help') || process.argv.includes('-h');
311+
return hasInit && hasHelp;
312+
}
313+
268314
// --- Main ---
269315

270316
async function main(): Promise<void> {
317+
if (isInitCommand()) {
318+
await handleInit();
319+
return;
320+
}
321+
322+
if (isInitHelpCommand()) {
323+
console.log(INIT_HELP);
324+
return;
325+
}
326+
271327
const apiKey = resolveApiKey();
272328
const formatter = createOutputFormatter(resolveJsonMode());
273329
const mcpUrl = resolveMcpUrl(apiKey);
@@ -277,7 +333,8 @@ async function main(): Promise<void> {
277333
formatter.error(
278334
new Error(
279335
'Authorization required but running in non-interactive mode.\n' +
280-
'Use --api-key or set BITREFILL_API_KEY to authenticate without a browser.'
336+
'Use --api-key or set BITREFILL_API_KEY to authenticate without a browser.\n' +
337+
'Or run: bitrefill init'
281338
)
282339
);
283340
process.exit(1);
@@ -316,6 +373,19 @@ async function main(): Promise<void> {
316373
'Disable browser-based auth and interactive prompts (auto-detected in CI / non-TTY)'
317374
);
318375

376+
program
377+
.command('init')
378+
.description(
379+
'Set up the CLI: validate API key, store credentials, and optionally register with OpenClaw'
380+
)
381+
.option(
382+
'--openclaw',
383+
'Force OpenClaw integration even if not auto-detected'
384+
)
385+
.action(() => {
386+
formatter.info('init has already been handled.');
387+
});
388+
319389
program
320390
.command('logout')
321391
.description('Clear stored OAuth credentials')

0 commit comments

Comments
 (0)