@profullstack/r4ck 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,20 @@
1
+ # @profullstack/r4ck
2
+
3
+ [r4ck.dev](https://r4ck.dev) from the terminal, and for agents: search every VPS, cloud, bare metal, GPU and PaaS offer for sale, drill down by spec, price and place, and bridge the same tools into any MCP client.
4
+
5
+ ```
6
+ npm i -g @profullstack/r4ck
7
+ r4ck search "2 vcpu 4gb under $10 in eu"
8
+ r4ck search --kind bare-metal --min-ram 64 --country US --sort price --json
9
+ r4ck cheapest --min-vcpu 4 --min-ram 8 --has api,cli
10
+ r4ck compare 120 344 902
11
+ r4ck providers --has cli --country DE
12
+ r4ck login # paste a key from https://r4ck.dev/settings
13
+ claude mcp add r4ck -- r4ck mcp # every tool in Claude Code
14
+ ```
15
+
16
+ Zero dependencies, Node 22 or later. `--json` prints the API reply verbatim. Config lives at `~/.config/r4ck/config.json`; `R4CK_KEY` and `R4CK_API` override it.
17
+
18
+ Reads need no key. A free key gives 6,000 requests an hour, 200 rows a page and CSV export. An x402 pass (`coinpay x402 pay https://r4ck.dev/crawl`) removes the allowance.
19
+
20
+ MIT. Source: https://github.com/profullstack/r4ck.dev
package/bin/r4ck.js ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ import { run } from '../src/index.js';
3
+
4
+ run(process.argv.slice(2)).then(
5
+ (code) => process.exit(code ?? 0),
6
+ (err) => {
7
+ console.error(`r4ck: ${err?.message ?? err}`);
8
+ process.exit(1);
9
+ },
10
+ );
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@profullstack/r4ck",
3
+ "version": "0.1.0",
4
+ "description": "r4ck.dev from the terminal and for agents: search every VPS, bare metal, GPU and PaaS offer for sale, drill down by spec, price and place, and bridge the same tools into any MCP client.",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "bin": {
8
+ "r4ck": "./bin/r4ck.js"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "src",
13
+ "README.md"
14
+ ],
15
+ "engines": {
16
+ "node": ">=22"
17
+ },
18
+ "license": "MIT",
19
+ "homepage": "https://r4ck.dev",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "https://github.com/profullstack/r4ck.dev.git",
23
+ "directory": "apps/cli"
24
+ },
25
+ "keywords": [
26
+ "vps",
27
+ "hosting",
28
+ "servers",
29
+ "bare-metal",
30
+ "gpu",
31
+ "mcp",
32
+ "cli",
33
+ "agents",
34
+ "x402"
35
+ ],
36
+ "mcpName": "dev.r4ck/r4ck",
37
+ "scripts": {
38
+ "test": "bun test"
39
+ }
40
+ }
package/src/index.js ADDED
@@ -0,0 +1,500 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { createInterface } from 'node:readline';
5
+
6
+ /**
7
+ * r4ck from the terminal, and for agents. Zero dependencies, Node 22+.
8
+ * Every command is a thin call to the site's API; `--json` prints the
9
+ * reply verbatim, otherwise a table a person can read. `r4ck mcp` bridges
10
+ * stdio to the site's MCP endpoint so any client gets every tool.
11
+ */
12
+ export const VERSION = '0.1.0';
13
+ const DEFAULT_API = process.env.R4CK_API ?? 'https://r4ck.dev';
14
+ const CONFIG_DIR = join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'r4ck');
15
+ const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
16
+
17
+ export const COMMANDS = [
18
+ {
19
+ name: 'search',
20
+ usage:
21
+ 'search [sentence] [--kind vps,gpu] [--min-vcpu N] [--min-ram GB] [--max-price USD] [--country DE,NL] [--has api,cli] [--sort price|value|ram] [--limit N] [--facets]',
22
+ help: 'Search every offer. A sentence is parsed; flags win over it.',
23
+ },
24
+ {
25
+ name: 'cheapest',
26
+ usage: 'cheapest [sentence] [--min-vcpu N] [--min-ram GB] [--kind K] [--country C]',
27
+ help: 'The cheapest rows meeting a spec.',
28
+ },
29
+ {
30
+ name: 'get',
31
+ usage: 'get <id>',
32
+ help: 'One offer with provider, price history and similar rows.',
33
+ },
34
+ { name: 'compare', usage: 'compare <id> <id> [...]', help: 'Up to 20 offers side by side.' },
35
+ { name: 'parse', usage: 'parse <sentence>', help: 'How a sentence is read, without running it.' },
36
+ { name: 'facets', usage: 'facets [filters]', help: 'Every facet value with its count.' },
37
+ {
38
+ name: 'providers',
39
+ usage: 'providers [--has api,cli] [--country DE] [--category vps] [--q name]',
40
+ help: 'Providers and their automation.',
41
+ },
42
+ {
43
+ name: 'provider',
44
+ usage: 'provider <slug|domain>',
45
+ help: 'One provider with every offer indexed for it.',
46
+ },
47
+ { name: 'deals', usage: 'deals', help: 'Hosting deals and stories.' },
48
+ { name: 'stats', usage: 'stats', help: 'What is indexed and when it last synced.' },
49
+ { name: 'login', usage: 'login [--key r4k_…]', help: 'Store an API key from r4ck.dev/settings.' },
50
+ { name: 'logout', usage: 'logout', help: 'Forget the stored key.' },
51
+ { name: 'whoami', usage: 'whoami', help: 'The account behind the stored key.' },
52
+ {
53
+ name: 'saved',
54
+ usage: 'saved [list|add <name> [filters]|rm <id>]',
55
+ help: 'Saved searches on the account.',
56
+ },
57
+ {
58
+ name: 'open',
59
+ usage: 'open <id|sentence>',
60
+ help: 'Print the page URL for an offer or a search.',
61
+ },
62
+ {
63
+ name: 'mcp',
64
+ usage: 'mcp',
65
+ help: 'Serve MCP over stdio, bridged to the site (for Claude Code and friends).',
66
+ },
67
+ ];
68
+
69
+ const LIST_FLAGS = ['kind', 'provider', 'country', 'has', 'ids', 'category', 'runtime'];
70
+
71
+ /* -------------------------------------------------------------- config -- */
72
+ async function readConfig() {
73
+ try {
74
+ return JSON.parse(await readFile(CONFIG_FILE, 'utf8'));
75
+ } catch {
76
+ return { api: DEFAULT_API, keys: {} };
77
+ }
78
+ }
79
+ async function writeConfig(cfg) {
80
+ await mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
81
+ await writeFile(CONFIG_FILE, `${JSON.stringify(cfg, null, 2)}\n`, { mode: 0o600 });
82
+ }
83
+
84
+ /* ------------------------------------------------------------- parsing -- */
85
+ export function parseArgs(argv) {
86
+ const flags = {};
87
+ const positional = [];
88
+ for (let i = 0; i < argv.length; i++) {
89
+ const a = argv[i];
90
+ if (a === '--') {
91
+ positional.push(...argv.slice(i + 1));
92
+ break;
93
+ }
94
+ if (a.startsWith('--')) {
95
+ const eq = a.indexOf('=');
96
+ const rawKey = eq > 0 ? a.slice(2, eq) : a.slice(2);
97
+ const key = rawKey.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
98
+ let value = eq > 0 ? a.slice(eq + 1) : undefined;
99
+ if (value === undefined) {
100
+ const next = argv[i + 1];
101
+ if (next !== undefined && !next.startsWith('--')) {
102
+ value = next;
103
+ i++;
104
+ } else value = true;
105
+ }
106
+ if (key.startsWith('no') && key.length > 2 && value === true)
107
+ flags[key[2].toLowerCase() + key.slice(3)] = false;
108
+ else flags[key] = value;
109
+ } else positional.push(a);
110
+ }
111
+ return { flags, positional };
112
+ }
113
+
114
+ const snake = (k) => k.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
115
+
116
+ /** Flags → API query parameters. Lists stay comma separated. */
117
+ export function queryFrom(flags, sentence) {
118
+ const p = new URLSearchParams();
119
+ if (sentence) p.set('q', sentence);
120
+ for (const [k, v] of Object.entries(flags)) {
121
+ if (
122
+ ['json', 'api', 'key', 'help', 'version', 'facets', 'urls'].includes(k) ||
123
+ v === undefined ||
124
+ v === false
125
+ )
126
+ continue;
127
+ const name = snake(k);
128
+ if (v === true) p.set(name, '1');
129
+ else
130
+ p.set(
131
+ name,
132
+ LIST_FLAGS.includes(name)
133
+ ? String(v)
134
+ .split(',')
135
+ .map((s) => s.trim())
136
+ .filter(Boolean)
137
+ .join(',')
138
+ : String(v),
139
+ );
140
+ }
141
+ return p;
142
+ }
143
+
144
+ /* -------------------------------------------------------------- client -- */
145
+ export function makeClient({ api, key, fetchImpl = fetch }) {
146
+ const base = String(api ?? DEFAULT_API).replace(/\/$/, '');
147
+ const headers = () => ({
148
+ accept: 'application/json',
149
+ 'user-agent': `r4ck-cli/${VERSION}`,
150
+ ...(key ? { authorization: `Bearer ${key}` } : {}),
151
+ });
152
+ async function send(method, path, body) {
153
+ const r = await fetchImpl(`${base}${path}`, {
154
+ method,
155
+ headers: { ...headers(), ...(body ? { 'content-type': 'application/json' } : {}) },
156
+ body: body ? JSON.stringify(body) : undefined,
157
+ });
158
+ const text = await r.text();
159
+ let data;
160
+ try {
161
+ data = JSON.parse(text);
162
+ } catch {
163
+ data = { raw: text };
164
+ }
165
+ if (!r.ok) {
166
+ const err = new Error(data?.error ?? `${r.status} from ${path}`);
167
+ err.status = r.status;
168
+ err.body = data;
169
+ throw err;
170
+ }
171
+ return data;
172
+ }
173
+ return {
174
+ base,
175
+ key,
176
+ get: (p) => send('GET', p),
177
+ post: (p, b) => send('POST', p, b),
178
+ del: (p) => send('DELETE', p),
179
+ send,
180
+ };
181
+ }
182
+
183
+ /* --------------------------------------------------------------- output -- */
184
+ const money = (n) =>
185
+ n === null || n === undefined
186
+ ? ''
187
+ : `$${Number(n) < 10 ? Number(n).toFixed(2) : Math.round(Number(n)).toLocaleString()}`;
188
+ const pad = (s, n, right = false) => {
189
+ const t = String(s ?? '');
190
+ const w = [...t].length;
191
+ return w >= n ? t : right ? ' '.repeat(n - w) + t : t + ' '.repeat(n - w);
192
+ };
193
+ export function table(rows, cols) {
194
+ const widths = cols.map((c) =>
195
+ Math.max([...c.label].length, ...rows.map((r) => [...String(c.get(r) ?? '')].length)),
196
+ );
197
+ const line = (cells) => cells.map((v, i) => pad(v, widths[i], cols[i].right)).join(' ');
198
+ return [
199
+ line(cols.map((c) => c.label)),
200
+ line(widths.map((w) => '─'.repeat(w))),
201
+ ...rows.map((r) => line(cols.map((c) => c.get(r)))),
202
+ ].join('\n');
203
+ }
204
+ const SERVER_COLS = [
205
+ { label: 'id', get: (s) => s.id, right: true },
206
+ { label: 'provider', get: (s) => s.provider.name },
207
+ { label: 'name', get: (s) => s.name.slice(0, 34) },
208
+ { label: 'kind', get: (s) => s.kind },
209
+ { label: 'vcpu', get: (s) => s.compute.vcpu ?? '', right: true },
210
+ {
211
+ label: 'ram',
212
+ get: (s) => (s.compute.ram_gb === null ? '' : `${s.compute.ram_gb}G`),
213
+ right: true,
214
+ },
215
+ {
216
+ label: 'disk',
217
+ get: (s) => (s.storage.disk_gb === null ? '' : `${Math.round(s.storage.disk_gb)}G`),
218
+ right: true,
219
+ },
220
+ { label: 'gpu', get: (s) => s.compute.gpu?.model ?? '' },
221
+ {
222
+ label: 'where',
223
+ get: (s) =>
224
+ s.location.countries
225
+ .slice(0, 4)
226
+ .map((c) => c.code)
227
+ .join(' '),
228
+ },
229
+ { label: 'usd/mo', get: (s) => money(s.price.monthly_usd), right: true },
230
+ {
231
+ label: 'billed',
232
+ get: (s) =>
233
+ s.price.amount === null
234
+ ? ''
235
+ : `${s.price.amount} ${s.price.currency}/${s.price.interval ?? '?'}`,
236
+ },
237
+ ];
238
+ const PROVIDER_COLS = [
239
+ { label: 'slug', get: (p) => p.slug },
240
+ { label: 'name', get: (p) => p.name.slice(0, 28) },
241
+ { label: 'hq', get: (p) => p.country?.code ?? '' },
242
+ { label: 'automation', get: (p) => p.automation.join(',') },
243
+ { label: 'offers', get: (p) => p.servers ?? '', right: true },
244
+ { label: 'from', get: (p) => money(p.from_usd), right: true },
245
+ { label: 'site', get: (p) => p.domain ?? '' },
246
+ ];
247
+
248
+ function printSearch(r, { facets = false } = {}) {
249
+ const out = [];
250
+ if (r.understood?.length) out.push(`understood: ${r.understood.join(', ')}`);
251
+ if (r.chips?.length) out.push(`filters: ${r.chips.map((c) => c.label).join(' · ')}`);
252
+ out.push(
253
+ `${r.total.toLocaleString()} offers${r.total > r.servers.length ? `, showing ${r.offset + 1}-${r.offset + r.servers.length}` : ''}\n`,
254
+ );
255
+ out.push(r.servers.length ? table(r.servers, SERVER_COLS) : '(nothing matched)');
256
+ if (facets && r.facets) {
257
+ out.push('');
258
+ for (const [name, values] of Object.entries(r.facets))
259
+ out.push(
260
+ `${name}: ${values
261
+ .slice(0, 8)
262
+ .map((v) => `${v.label ?? v.value} (${v.count})`)
263
+ .join(', ')}`,
264
+ );
265
+ }
266
+ if (r.agent?.url) out.push(`\napi: ${r.agent.url}`);
267
+ return out.join('\n');
268
+ }
269
+
270
+ const help = () =>
271
+ [
272
+ `r4ck ${VERSION}: the server search engine built for agents`,
273
+ '',
274
+ 'Usage: r4ck <command> [options]',
275
+ '',
276
+ ...COMMANDS.map((c) => ` ${pad(c.usage, 60)} ${c.help}`),
277
+ '',
278
+ 'Global: --json --api <url> --key <r4k_…> --help --version',
279
+ `Config: ${CONFIG_FILE} (R4CK_KEY / R4CK_API override)`,
280
+ ].join('\n');
281
+
282
+ /* ----------------------------------------------------------------- run -- */
283
+ export async function run(
284
+ argv,
285
+ {
286
+ stdout = process.stdout,
287
+ stderr = process.stderr,
288
+ stdin = process.stdin,
289
+ fetchImpl = fetch,
290
+ } = {},
291
+ ) {
292
+ const { flags, positional } = parseArgs(argv);
293
+ const [cmd, ...rest] = positional;
294
+ const cfg = await readConfig();
295
+ const api = flags.api ?? process.env.R4CK_API ?? cfg.api ?? DEFAULT_API;
296
+ const key = flags.key ?? cfg.keys?.[api] ?? process.env.R4CK_KEY ?? null;
297
+ const client = makeClient({ api, key, fetchImpl });
298
+ const json = (v) => {
299
+ stdout.write(`${JSON.stringify(v, null, 2)}\n`);
300
+ };
301
+ const say = (s) => {
302
+ stdout.write(`${s}\n`);
303
+ };
304
+ if (flags.version) return say(VERSION);
305
+ if (!cmd || flags.help || cmd === 'help') return say(help());
306
+
307
+ switch (cmd) {
308
+ case 'search':
309
+ case 'cheapest': {
310
+ const q = queryFrom(flags, rest.join(' ').trim() || undefined);
311
+ const r = await client.get(`/api/v1/${cmd}?${q}`);
312
+ return flags.json ? json(r) : say(printSearch(r, { facets: Boolean(flags.facets) }));
313
+ }
314
+ case 'parse': {
315
+ const r = await client.get(`/api/v1/parse?q=${encodeURIComponent(rest.join(' '))}`);
316
+ return flags.json
317
+ ? json(r)
318
+ : say(
319
+ `${r.chips.map((c) => c.label).join(' · ') || '(nothing understood)'}${r.rest ? `\ntext search: ${r.rest}` : ''}\n${r.agent.url}`,
320
+ );
321
+ }
322
+ case 'get': {
323
+ if (!rest[0]) throw new Error('usage: r4ck get <id>');
324
+ const r = await client.get(`/api/v1/servers/${encodeURIComponent(rest[0])}`);
325
+ if (flags.json) return json(r);
326
+ const s = r.server;
327
+ say(`${s.provider.name} ${s.name} [${s.kind}]`);
328
+ say(table([s], SERVER_COLS.slice(4)));
329
+ say(
330
+ `price: ${s.price.amount ?? '?'} ${s.price.currency ?? ''}/${s.price.interval ?? '?'}${s.price.monthly_usd !== null ? ` ≈ ${money(s.price.monthly_usd)}/mo` : ''}`,
331
+ );
332
+ if (s.url) say(`order: ${s.url}`);
333
+ say(`page: ${s.page}`);
334
+ if (r.similar?.length) say(`\nsimilar:\n${table(r.similar, SERVER_COLS)}`);
335
+ return;
336
+ }
337
+ case 'compare': {
338
+ const ids = rest.flatMap((x) => x.split(',')).filter(Boolean);
339
+ if (!ids.length) throw new Error('usage: r4ck compare <id> <id> ...');
340
+ const r = await client.get(`/api/v1/compare?ids=${ids.join(',')}`);
341
+ return flags.json ? json(r) : say(`${table(r.servers, SERVER_COLS)}\n\n${r.agent.html}`);
342
+ }
343
+ case 'facets': {
344
+ const r = await client.get(`/api/v1/facets?${queryFrom(flags, rest.join(' ') || undefined)}`);
345
+ if (flags.json) return json(r);
346
+ for (const [name, values] of Object.entries(r.facets))
347
+ say(
348
+ `${name}: ${values
349
+ .slice(0, 12)
350
+ .map((v) => `${v.label ?? v.value} (${v.count})`)
351
+ .join(', ')}`,
352
+ );
353
+ return;
354
+ }
355
+ case 'providers': {
356
+ const r = await client.get(
357
+ `/api/v1/providers?${queryFrom(flags, undefined)}${rest.length ? `&q=${encodeURIComponent(rest.join(' '))}` : ''}`,
358
+ );
359
+ return flags.json
360
+ ? json(r)
361
+ : say(`${r.total} providers\n\n${table(r.providers, PROVIDER_COLS)}`);
362
+ }
363
+ case 'provider': {
364
+ if (!rest[0]) throw new Error('usage: r4ck provider <slug>');
365
+ const r = await client.get(`/api/v1/providers/${encodeURIComponent(rest[0])}`);
366
+ if (flags.json) return json(r);
367
+ const p = r.provider;
368
+ say(
369
+ `${p.name} ${p.domain ?? ''} ${p.country?.code ?? ''} automation: ${p.automation.join(',') || 'none'}`,
370
+ );
371
+ if (p.summary) say(p.summary);
372
+ say(`\n${r.servers.length} offers\n${table(r.servers, SERVER_COLS)}`);
373
+ return;
374
+ }
375
+ case 'deals': {
376
+ const r = await client.get('/api/v1/deals');
377
+ return flags.json
378
+ ? json(r)
379
+ : say(r.deals.map((d) => `[${d.kind}] ${d.title}\n ${d.url}`).join('\n'));
380
+ }
381
+ case 'stats': {
382
+ const r = await client.get('/api/v1/stats');
383
+ return flags.json
384
+ ? json(r)
385
+ : say(
386
+ Object.entries(r)
387
+ .map(([k, v]) => `${pad(k, 18)} ${v}`)
388
+ .join('\n'),
389
+ );
390
+ }
391
+ case 'login': {
392
+ let k = flags.key;
393
+ if (!k) {
394
+ const rl = createInterface({ input: stdin, output: stderr });
395
+ k = await new Promise((res) =>
396
+ rl.question(`Paste an API key from ${api}/settings: `, (a) => {
397
+ rl.close();
398
+ res(a.trim());
399
+ }),
400
+ );
401
+ }
402
+ if (!/^r4k_[0-9a-f]{16,}$/i.test(k)) throw new Error('that does not look like an r4k_ key');
403
+ const me = await makeClient({ api, key: k, fetchImpl }).get('/api/v1/me');
404
+ cfg.api = api;
405
+ cfg.keys = { ...(cfg.keys ?? {}), [api]: k };
406
+ await writeConfig(cfg);
407
+ return say(`Signed in as ${me.email}. Key stored in ${CONFIG_FILE}.`);
408
+ }
409
+ case 'logout': {
410
+ delete cfg.keys?.[api];
411
+ await writeConfig(cfg);
412
+ return say('Key forgotten.');
413
+ }
414
+ case 'whoami': {
415
+ const r = await client.get('/api/v1/me');
416
+ return flags.json
417
+ ? json(r)
418
+ : say(`${r.email} (${r.plan}) · ${r.keys.length} keys · ${r.saved.length} saved searches`);
419
+ }
420
+ case 'saved': {
421
+ const [sub = 'list', ...more] = rest;
422
+ if (sub === 'list') {
423
+ const r = await client.get('/api/v1/saved');
424
+ return flags.json
425
+ ? json(r)
426
+ : say(
427
+ r.saved.length
428
+ ? r.saved.map((s) => `${s.id} ${s.name}\n ${s.cli}`).join('\n')
429
+ : 'nothing saved',
430
+ );
431
+ }
432
+ if (sub === 'add') {
433
+ const name = more[0];
434
+ const q = Object.fromEntries(queryFrom(flags, more.slice(1).join(' ') || undefined));
435
+ const r = await client.post('/api/v1/saved', { name, query: q });
436
+ return flags.json ? json(r) : say(`saved ${r.saved.id}: ${r.saved.name}`);
437
+ }
438
+ if (sub === 'rm') {
439
+ await client.del(`/api/v1/saved/${encodeURIComponent(more[0] ?? '')}`);
440
+ return say('removed');
441
+ }
442
+ throw new Error('usage: r4ck saved [list|add <name> [filters]|rm <id>]');
443
+ }
444
+ case 'open': {
445
+ if (/^\d+$/.test(rest[0] ?? '')) return say(`${client.base}/servers/${rest[0]}`);
446
+ return say(`${client.base}/servers?${queryFrom(flags, rest.join(' ') || undefined)}`);
447
+ }
448
+ case 'mcp':
449
+ return serveMcp({ client, stdin, stdout, fetchImpl });
450
+ default:
451
+ stderr.write(`Unknown command: ${cmd}\n\n${help()}\n`);
452
+ return 2;
453
+ }
454
+ }
455
+
456
+ /**
457
+ * stdio ↔ HTTP bridge: each newline-delimited JSON-RPC message is POSTed to
458
+ * the site's /mcp with the stored key; the answer is written back. Nothing
459
+ * about the tools is duplicated here, so the CLI never lags the server.
460
+ */
461
+ export async function serveMcp({ client, stdin, stdout, fetchImpl = fetch }) {
462
+ const rl = createInterface({ input: stdin, crlfDelay: Number.POSITIVE_INFINITY });
463
+ for await (const line of rl) {
464
+ const text = line.trim();
465
+ if (!text) continue;
466
+ let msg;
467
+ try {
468
+ msg = JSON.parse(text);
469
+ } catch {
470
+ stdout.write(
471
+ `${JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } })}\n`,
472
+ );
473
+ continue;
474
+ }
475
+ const headers = {
476
+ 'content-type': 'application/json',
477
+ accept: 'application/json',
478
+ 'user-agent': `r4ck-cli/${VERSION}`,
479
+ };
480
+ if (client.key) headers.authorization = `Bearer ${client.key}`;
481
+ const version = msg?.params?._meta?.['io.modelcontextprotocol/protocolVersion'];
482
+ if (version) {
483
+ headers['mcp-protocol-version'] = version;
484
+ headers['mcp-method'] = msg.method;
485
+ const name = msg.params?.name ?? msg.params?.uri;
486
+ if (name) headers['mcp-name'] = name;
487
+ }
488
+ try {
489
+ const r = await fetchImpl(`${client.base}/mcp`, { method: 'POST', headers, body: text });
490
+ if (r.status === 202) continue;
491
+ const body = await r.text();
492
+ if (body.trim()) stdout.write(`${body.trim()}\n`);
493
+ } catch (err) {
494
+ if ('id' in msg)
495
+ stdout.write(
496
+ `${JSON.stringify({ jsonrpc: '2.0', id: msg.id, error: { code: -32603, message: `bridge: ${err.message}` } })}\n`,
497
+ );
498
+ }
499
+ }
500
+ }