@profullstack/nichedb 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,22 @@
1
+ # @profullstack/nichedb
2
+
3
+ The CLI for a [NicheDB](https://github.com/profullstack/niche-db) deployment, and an MCP server over stdio.
4
+
5
+ ```sh
6
+ npm install -g @profullstack/nichedb
7
+ nichedb login --api https://nichedb.dev # paste a key from /settings
8
+ nichedb collections
9
+ nichedb feeds --collection packages
10
+ nichedb items npm-latest --limit 20
11
+ nichedb search "mcp server" --collection packages --json
12
+ nichedb source add github-releases --name "My repos" --config repos=oven-sh/bun,honojs/hono
13
+ nichedb feed create --collection games --name "Free this week" --tags free --upcoming
14
+ ```
15
+
16
+ As an MCP server for Claude Code:
17
+
18
+ ```sh
19
+ claude mcp add nichedb -- nichedb mcp --api https://nichedb.dev
20
+ ```
21
+
22
+ Zero dependencies. Node 22+. `nichedb help` lists every command.
package/bin/nichedb.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(err?.message ?? err);
8
+ process.exit(1);
9
+ },
10
+ );
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@profullstack/nichedb",
3
+ "version": "0.1.0",
4
+ "description": "CLI and MCP bridge for NicheDB: browse collections, manage sources and feeds, search items, from any deployment",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "bin": {
8
+ "nichedb": "./bin/nichedb.js"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "src",
13
+ "README.md"
14
+ ],
15
+ "scripts": {
16
+ "test": "bun test"
17
+ },
18
+ "engines": {
19
+ "node": ">=22"
20
+ },
21
+ "keywords": [
22
+ "nichedb",
23
+ "feeds",
24
+ "data",
25
+ "cli",
26
+ "mcp",
27
+ "agents"
28
+ ],
29
+ "license": "MIT",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/profullstack/niche-db.git",
33
+ "directory": "apps/cli"
34
+ },
35
+ "mcpName": "dev.nichedb/nichedb",
36
+ "homepage": "https://nichedb.dev"
37
+ }
package/src/index.js ADDED
@@ -0,0 +1,634 @@
1
+ /**
2
+ * nichedb: the CLI, and an MCP server over stdio.
3
+ *
4
+ * Talks to a deployment's HTTP API, so it works against any NicheDB -- the
5
+ * public one, or your own via `--api http://localhost:3000`. Zero dependencies
6
+ * and one file, so it installs in a second and can be vendored anywhere.
7
+ *
8
+ * It is both a module and a program: the tests import {@link run} and the
9
+ * `bin/` shim executes it.
10
+ */
11
+
12
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
13
+ import { homedir } from 'node:os';
14
+ import { join } from 'node:path';
15
+ import { createInterface } from 'node:readline';
16
+
17
+ export const VERSION = '0.1.0';
18
+ const DEFAULT_API = process.env.NICHEDB_API ?? 'https://nichedb.dev';
19
+ const CONFIG_DIR = join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'nichedb');
20
+ const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
21
+
22
+ /**
23
+ * Every command, in the order they are worth learning. Rendered into --help
24
+ * and into the /docs/cli page, so the two cannot drift.
25
+ */
26
+ export const COMMANDS = [
27
+ {
28
+ name: 'collections',
29
+ usage: 'collections',
30
+ summary: 'The collections (niches) and their counts.',
31
+ options: ['--json'],
32
+ },
33
+ {
34
+ name: 'sources',
35
+ usage: 'sources [--collection <slug>]',
36
+ summary: 'Sources with status, last run and item counts.',
37
+ options: ['--collection <slug>', '--json'],
38
+ },
39
+ {
40
+ name: 'source',
41
+ usage: 'source <slug>',
42
+ summary: 'One source, its recent runs and config.',
43
+ options: ['--json'],
44
+ },
45
+ {
46
+ name: 'source add',
47
+ usage: 'source add <adapter> [--name …] [--collection …] [--config k=v …] [--every <min>]',
48
+ summary: 'Add a source (key required; admin or Pro).',
49
+ options: ['--name', '--collection', '--config k=v (repeatable)', '--every <minutes>', '--json'],
50
+ },
51
+ {
52
+ name: 'source run',
53
+ usage: 'source run <slug>',
54
+ summary: 'Fetch a source now (key required).',
55
+ options: [],
56
+ },
57
+ {
58
+ name: 'source pause',
59
+ usage: 'source pause|resume <slug>',
60
+ summary: 'Pause or resume a source (key required).',
61
+ options: [],
62
+ },
63
+ {
64
+ name: 'source rm',
65
+ usage: 'source rm <slug>',
66
+ summary: 'Delete a source and its items (key required).',
67
+ options: [],
68
+ },
69
+ {
70
+ name: 'adapters',
71
+ usage: 'adapters',
72
+ summary: 'Every adapter and the config fields it takes.',
73
+ options: ['--json'],
74
+ },
75
+ {
76
+ name: 'feeds',
77
+ usage: 'feeds [--collection <slug>]',
78
+ summary: 'Public feeds, most followed first.',
79
+ options: ['--collection <slug>', '--json'],
80
+ },
81
+ {
82
+ name: 'items',
83
+ usage: 'items <feed> [--limit n] [--before id]',
84
+ summary: 'What a feed selects, newest first.',
85
+ options: ['--limit <n>', '--before <id>', '--json', '--urls'],
86
+ },
87
+ {
88
+ name: 'recent',
89
+ usage: 'recent [--collection …] [--source …] [--kind …]',
90
+ summary: 'Newest items across a collection or source.',
91
+ options: ['--collection', '--source', '--kind', '--limit', '--json', '--urls'],
92
+ },
93
+ {
94
+ name: 'upcoming',
95
+ usage: 'upcoming [--collection <slug>] [--days n]',
96
+ summary: 'Items dated in the future, soonest first.',
97
+ options: ['--collection', '--days', '--limit', '--json'],
98
+ },
99
+ {
100
+ name: 'search',
101
+ usage: 'search <query> [--collection …] [--kind …]',
102
+ summary: 'Full-text search.',
103
+ options: ['--collection', '--kind', '--limit', '--json', '--urls'],
104
+ },
105
+ {
106
+ name: 'item',
107
+ usage: 'item <id>',
108
+ summary: 'One item with its full data payload.',
109
+ options: ['--json'],
110
+ },
111
+ {
112
+ name: 'feed create',
113
+ usage:
114
+ 'feed create --collection <slug> --name <name> [--sources a,b] [--kinds …] [--tags …] [--q …] [--upcoming] [--private]',
115
+ summary: 'Save a query as a feed (key required).',
116
+ options: [],
117
+ },
118
+ {
119
+ name: 'feed rm',
120
+ usage: 'feed rm <slug>',
121
+ summary: 'Delete your feed (key required).',
122
+ options: [],
123
+ },
124
+ {
125
+ name: 'follow',
126
+ usage:
127
+ 'follow <feed> [--channels email,webpush,webhook] [--webhook-url …] [--webhook-secret …]',
128
+ summary: 'Follow a feed as the key owner.',
129
+ options: [],
130
+ },
131
+ { name: 'unfollow', usage: 'unfollow <feed>', summary: 'Stop following.', options: [] },
132
+ {
133
+ name: 'following',
134
+ usage: 'following',
135
+ summary: 'The feeds your key follows.',
136
+ options: ['--json'],
137
+ },
138
+ {
139
+ name: 'rss',
140
+ usage: 'rss <feed>',
141
+ summary: 'Print the RSS URL (or the feed itself with --fetch).',
142
+ options: ['--fetch'],
143
+ },
144
+ {
145
+ name: 'login',
146
+ usage: 'login [--api <url>] [--key <ndb_…>]',
147
+ summary: 'Store an API key for a deployment.',
148
+ options: [],
149
+ },
150
+ { name: 'whoami', usage: 'whoami', summary: 'Who the stored key belongs to.', options: [] },
151
+ {
152
+ name: 'mcp',
153
+ usage: 'mcp [--api <url>]',
154
+ summary: 'Run as an MCP server over stdio (for Claude Code, Cursor, …).',
155
+ options: [],
156
+ },
157
+ ];
158
+
159
+ /* --------------------------------------------------------------- args -- */
160
+
161
+ export function parseArgs(argv) {
162
+ const flags = {};
163
+ const positional = [];
164
+ for (let i = 0; i < argv.length; i++) {
165
+ const a = argv[i];
166
+ if (a === '--') {
167
+ positional.push(...argv.slice(i + 1));
168
+ break;
169
+ }
170
+ if (a.startsWith('--')) {
171
+ const eq = a.indexOf('=');
172
+ const key = (eq > 0 ? a.slice(2, eq) : a.slice(2)).replace(/-([a-z])/g, (_, c) =>
173
+ c.toUpperCase(),
174
+ );
175
+ let val = eq > 0 ? a.slice(eq + 1) : undefined;
176
+ if (val === undefined) {
177
+ const next = argv[i + 1];
178
+ if (next !== undefined && !next.startsWith('--')) {
179
+ val = next;
180
+ i++;
181
+ } else val = true;
182
+ }
183
+ if (key === 'config') {
184
+ if (!flags.config) flags.config = [];
185
+ flags.config.push(val);
186
+ } else flags[key] = val;
187
+ } else positional.push(a);
188
+ }
189
+ return { flags, positional };
190
+ }
191
+
192
+ /* ------------------------------------------------------------- config -- */
193
+
194
+ async function loadConfig() {
195
+ try {
196
+ return JSON.parse(await readFile(CONFIG_FILE, 'utf8'));
197
+ } catch {
198
+ return {};
199
+ }
200
+ }
201
+
202
+ async function saveConfig(cfg) {
203
+ await mkdir(CONFIG_DIR, { recursive: true });
204
+ await writeFile(CONFIG_FILE, `${JSON.stringify(cfg, null, 2)}\n`, { mode: 0o600 });
205
+ }
206
+
207
+ /* ---------------------------------------------------------------- api -- */
208
+
209
+ export function makeClient({ api, key, fetchImpl = fetch }) {
210
+ const base = String(api).replace(/\/$/, '');
211
+ async function call(method, path, body) {
212
+ const res = await fetchImpl(`${base}${path}`, {
213
+ method,
214
+ headers: {
215
+ accept: 'application/json',
216
+ 'user-agent': `nichedb-cli/${VERSION}`,
217
+ ...(body ? { 'content-type': 'application/json' } : {}),
218
+ ...(key ? { authorization: `Bearer ${key}` } : {}),
219
+ },
220
+ body: body ? JSON.stringify(body) : undefined,
221
+ });
222
+ const text = await res.text();
223
+ let data;
224
+ try {
225
+ data = JSON.parse(text);
226
+ } catch {
227
+ data = { raw: text };
228
+ }
229
+ if (!res.ok) throw new Error(data?.error ?? `${res.status} from ${path}`);
230
+ return data;
231
+ }
232
+ return {
233
+ base,
234
+ key,
235
+ get: (p) => call('GET', p),
236
+ post: (p, b) => call('POST', p, b ?? {}),
237
+ patch: (p, b) => call('PATCH', p, b),
238
+ del: (p) => call('DELETE', p),
239
+ };
240
+ }
241
+
242
+ /* ------------------------------------------------------------- output -- */
243
+
244
+ const out = (s) => process.stdout.write(`${s}\n`);
245
+ const pad = (s, n) =>
246
+ String(s ?? '')
247
+ .padEnd(n)
248
+ .slice(0, n);
249
+ const when = (d) => (d ? new Date(d).toISOString().slice(0, 16).replace('T', ' ') : '-');
250
+
251
+ function printItems(items, { json, urls }) {
252
+ if (json) return out(JSON.stringify(items, null, 2));
253
+ if (urls) {
254
+ for (const i of items) out(i.url ?? i.page);
255
+ return;
256
+ }
257
+ for (const i of items) {
258
+ out(
259
+ `${i.id} ${when(i.published_at ?? i.first_seen_at)}${i.time_known === false ? '~' : ' '} ${pad(i.kind, 8)} ${i.title}`,
260
+ );
261
+ if (i.url) out(` ${i.url}`);
262
+ }
263
+ if (items.length === 0) out('(nothing)');
264
+ }
265
+
266
+ function help() {
267
+ out(`nichedb ${VERSION} — sources in, feeds out.\n`);
268
+ out('Usage: nichedb <command> [options]\n');
269
+ for (const c of COMMANDS) out(` ${pad(c.usage, 68)} ${c.summary}`);
270
+ out(
271
+ '\nGlobal: --api <url> (default from `nichedb login`, else NICHEDB_API), --key <ndb_…>, --json',
272
+ );
273
+ }
274
+
275
+ const kv = (list) =>
276
+ Object.fromEntries(
277
+ (list ?? []).map((s) => {
278
+ const i = String(s).indexOf('=');
279
+ return i < 0 ? [s, true] : [s.slice(0, i), s.slice(i + 1)];
280
+ }),
281
+ );
282
+ const csv = (v) =>
283
+ v === undefined || v === true
284
+ ? undefined
285
+ : String(v)
286
+ .split(',')
287
+ .map((s) => s.trim())
288
+ .filter(Boolean);
289
+
290
+ /* ---------------------------------------------------------------- run -- */
291
+
292
+ export async function run(
293
+ argv,
294
+ { fetchImpl = fetch, stdin = process.stdin, stdout = process.stdout } = {},
295
+ ) {
296
+ const { flags, positional } = parseArgs(argv);
297
+ const [cmd, ...rest] = positional;
298
+ const cfg = await loadConfig();
299
+ const api = flags.api ?? cfg.api ?? DEFAULT_API;
300
+ const key = flags.key ?? cfg.keys?.[api] ?? process.env.NICHEDB_KEY ?? null;
301
+ const client = makeClient({ api, key, fetchImpl });
302
+ const json = Boolean(flags.json);
303
+
304
+ if (!cmd || cmd === 'help' || flags.help) {
305
+ help();
306
+ return 0;
307
+ }
308
+ if (flags.version || cmd === 'version') {
309
+ out(VERSION);
310
+ return 0;
311
+ }
312
+
313
+ switch (cmd) {
314
+ case 'login': {
315
+ let k = flags.key;
316
+ if (!k) {
317
+ const rl = createInterface({ input: stdin, output: stdout });
318
+ k = await new Promise((r) =>
319
+ rl.question(`API key for ${api} (from ${api}/settings): `, (a) => {
320
+ rl.close();
321
+ r(a.trim());
322
+ }),
323
+ );
324
+ }
325
+ const me = await makeClient({ api, key: k, fetchImpl }).get('/api/v1/me');
326
+ cfg.api = api;
327
+ cfg.keys = { ...(cfg.keys ?? {}), [api]: k };
328
+ await saveConfig(cfg);
329
+ out(`Signed in to ${api} as ${me.email}${me.pro ? ' (Pro)' : ''}. Saved to ${CONFIG_FILE}.`);
330
+
331
+ return 0;
332
+ }
333
+ case 'whoami': {
334
+ const me = await client.get('/api/v1/me');
335
+ out(
336
+ json
337
+ ? JSON.stringify(me, null, 2)
338
+ : `${me.email} · ${me.role}${me.pro ? ' · Pro' : ''} · ${me.feeds} feeds · ${api}`,
339
+ );
340
+
341
+ return 0;
342
+ }
343
+ case 'collections': {
344
+ const { collections } = await client.get('/api/v1/collections');
345
+ if (json) {
346
+ out(JSON.stringify(collections, null, 2));
347
+ return 0;
348
+ }
349
+ for (const c of collections)
350
+ out(
351
+ `${pad(c.slug, 12)} ${pad(String(c.items), 9)} items ${c.sources} sources ${c.feeds} feeds ${c.name}`,
352
+ );
353
+ return 0;
354
+ }
355
+ case 'adapters': {
356
+ const { adapters } = await client.get('/api/v1/adapters');
357
+ if (json) {
358
+ out(JSON.stringify(adapters, null, 2));
359
+ return 0;
360
+ }
361
+ for (const a of adapters) {
362
+ out(`${pad(a.name, 18)} ${pad(a.collection, 10)} every ${a.cadenceMinutes}m ${a.title}`);
363
+ for (const f of a.configFields)
364
+ out(
365
+ ` --config ${f.key}=… ${f.label}${f.required ? ' (required)' : ''}${f.options ? ` [${f.options.join('|')}]` : ''}`,
366
+ );
367
+ if (a.needsEnv?.length) out(` needs on the server: ${a.needsEnv.join(', ')}`);
368
+ }
369
+ return 0;
370
+ }
371
+ case 'sources': {
372
+ const qs = flags.collection ? `?collection=${encodeURIComponent(flags.collection)}` : '';
373
+ const { sources } = await client.get(`/api/v1/sources${qs}`);
374
+ if (json) {
375
+ out(JSON.stringify(sources, null, 2));
376
+ return 0;
377
+ }
378
+ for (const s of sources) {
379
+ const st = !s.enabled
380
+ ? 'paused'
381
+ : s.last_error
382
+ ? 'ERROR '
383
+ : s.last_ok_at
384
+ ? 'ok '
385
+ : 'wait ';
386
+ out(
387
+ `${st} ${pad(s.slug, 28)} ${pad(String(s.item_count), 8)} every ${pad(`${s.cadence_minutes}m`, 5)} last ${when(s.last_ok_at)} ${s.name}`,
388
+ );
389
+ if (s.last_error) out(` ${s.last_error.slice(0, 100)}`);
390
+ }
391
+ return 0;
392
+ }
393
+ case 'source': {
394
+ const [sub, arg] = rest;
395
+ if (sub === 'add') {
396
+ const body = {
397
+ adapter: arg,
398
+ name: flags.name,
399
+ collection: flags.collection,
400
+ config: kv(flags.config),
401
+ cadence_minutes: flags.every ? Number(flags.every) : undefined,
402
+ };
403
+ for (const [k, v] of Object.entries(body.config))
404
+ if (typeof v === 'string' && v.includes(','))
405
+ body.config[k] = v.split(',').map((s) => s.trim());
406
+ const { source } = await client.post('/api/v1/sources', body);
407
+ out(
408
+ json
409
+ ? JSON.stringify(source, null, 2)
410
+ : `Added ${source.slug} (${source.adapter}). First fetch is queued. ${api}/s/${source.slug}`,
411
+ );
412
+
413
+ return 0;
414
+ }
415
+ if (sub === 'run') {
416
+ await client.post(`/api/v1/sources/${arg}/run`);
417
+ out(`Queued ${arg}.`);
418
+ return 0;
419
+ }
420
+ if (sub === 'pause' || sub === 'resume') {
421
+ await client.patch(`/api/v1/sources/${arg}`, { enabled: sub === 'resume' });
422
+ out(`${sub === 'pause' ? 'Paused' : 'Resumed'} ${arg}.`);
423
+ return 0;
424
+ }
425
+ if (sub === 'rm') {
426
+ await client.del(`/api/v1/sources/${arg}`);
427
+ out(`Deleted ${arg}.`);
428
+ return 0;
429
+ }
430
+ if (!sub) throw new Error('source <slug>, or source add|run|pause|resume|rm');
431
+ const { source, runs } = await client.get(`/api/v1/sources/${sub}`);
432
+ if (json) {
433
+ out(JSON.stringify({ source, runs }, null, 2));
434
+ return 0;
435
+ }
436
+ out(
437
+ `${source.name} (${source.slug}) · ${source.adapter} · ${source.enabled ? 'enabled' : 'paused'} · every ${source.cadence_minutes}m · ${source.item_count} items`,
438
+ );
439
+ out(`config: ${JSON.stringify(source.config)}`);
440
+ if (source.last_error) out(`last error: ${source.last_error}`);
441
+ for (const r of runs)
442
+ out(
443
+ ` ${when(r.started_at)} ${pad(r.status, 7)} seen ${r.seen} new ${r.added} ${r.error ?? r.note ?? ''}`,
444
+ );
445
+ return 0;
446
+ }
447
+ case 'feeds': {
448
+ const qs = flags.collection ? `?collection=${encodeURIComponent(flags.collection)}` : '';
449
+ const { feeds } = await client.get(`/api/v1/feeds${qs}`);
450
+ if (json) {
451
+ out(JSON.stringify(feeds, null, 2));
452
+ return 0;
453
+ }
454
+ for (const f of feeds)
455
+ out(`${pad(f.slug, 30)} ${pad(f.collection, 10)} ${pad(String(f.followers), 5)} ${f.name}`);
456
+ return 0;
457
+ }
458
+ case 'feed': {
459
+ const [sub, arg] = rest;
460
+ if (sub === 'create') {
461
+ const { feed } = await client.post('/api/v1/feeds', {
462
+ collection: flags.collection,
463
+ name: flags.name,
464
+ description: flags.description,
465
+ sources: csv(flags.sources),
466
+ kinds: csv(flags.kinds),
467
+ tags: csv(flags.tags),
468
+ q: flags.q,
469
+ upcoming: Boolean(flags.upcoming),
470
+ public: !flags.private,
471
+ });
472
+ out(
473
+ json
474
+ ? JSON.stringify(feed, null, 2)
475
+ : `Created ${feed.slug}: ${feed.page}\nRSS ${feed.rss}`,
476
+ );
477
+
478
+ return 0;
479
+ }
480
+ if (sub === 'rm') {
481
+ await client.del(`/api/v1/feeds/${arg}`);
482
+ out(`Deleted ${arg}.`);
483
+ return 0;
484
+ }
485
+ throw new Error('feed create|rm');
486
+ }
487
+ case 'items': {
488
+ const [slug] = rest;
489
+ if (!slug) throw new Error('items <feed>');
490
+ const qs = new URLSearchParams();
491
+ if (flags.limit) qs.set('limit', flags.limit);
492
+ if (flags.before) qs.set('before', flags.before);
493
+ const { items } = await client.get(`/api/v1/feeds/${slug}/items?${qs}`);
494
+ printItems(items, { json, urls: flags.urls });
495
+ return 0;
496
+ }
497
+ case 'recent': {
498
+ const qs = new URLSearchParams();
499
+ for (const k of ['collection', 'source', 'kind', 'limit', 'before'])
500
+ if (flags[k]) qs.set(k, flags[k]);
501
+ const { items } = await client.get(`/api/v1/items?${qs}`);
502
+ printItems(items, { json, urls: flags.urls });
503
+ return 0;
504
+ }
505
+ case 'upcoming': {
506
+ const qs = new URLSearchParams();
507
+ for (const k of ['collection', 'days', 'limit']) if (flags[k]) qs.set(k, flags[k]);
508
+ const { items } = await client.get(`/api/v1/items/upcoming?${qs}`);
509
+ printItems(items, { json, urls: flags.urls });
510
+ return 0;
511
+ }
512
+ case 'search': {
513
+ const term = rest.join(' ');
514
+ if (!term) throw new Error('search <query>');
515
+ const qs = new URLSearchParams({ q: term });
516
+ for (const k of ['collection', 'kind', 'limit']) if (flags[k]) qs.set(k, flags[k]);
517
+ const { items } = await client.get(`/api/v1/search?${qs}`);
518
+ printItems(items, { json, urls: flags.urls });
519
+ return 0;
520
+ }
521
+ case 'item': {
522
+ const { item } = await client.get(`/api/v1/items/${rest[0]}`);
523
+ if (json) {
524
+ out(JSON.stringify(item, null, 2));
525
+ return 0;
526
+ }
527
+ out(
528
+ `${item.title}\n${item.url ?? item.page}\n${item.kind} · ${item.collection}/${item.source} · ${when(item.published_at)}${item.time_known === false ? ' (date only)' : ''}`,
529
+ );
530
+ if (item.summary) out(`\n${item.summary}`);
531
+ out(`\ntags: ${item.tags.join(', ')}\n${JSON.stringify(item.data, null, 2)}`);
532
+ return 0;
533
+ }
534
+ case 'follow': {
535
+ await client.post(`/api/v1/feeds/${rest[0]}/follow`, {
536
+ channels: csv(flags.channels),
537
+ webhook_url: flags.webhookUrl,
538
+ webhook_secret: flags.webhookSecret,
539
+ });
540
+ out(`Following ${rest[0]}.`);
541
+ return 0;
542
+ }
543
+ case 'unfollow': {
544
+ await client.del(`/api/v1/feeds/${rest[0]}/follow`);
545
+ out(`Unfollowed ${rest[0]}.`);
546
+ return 0;
547
+ }
548
+ case 'following': {
549
+ const { feeds } = await client.get('/api/v1/following');
550
+ if (json) {
551
+ out(JSON.stringify(feeds, null, 2));
552
+ return 0;
553
+ }
554
+ for (const f of feeds) out(`${pad(f.slug, 30)} ${f.name}`);
555
+ return 0;
556
+ }
557
+ case 'rss': {
558
+ const url = `${client.base}/f/${rest[0]}.rss`;
559
+ if (!flags.fetch) {
560
+ out(url);
561
+ return 0;
562
+ }
563
+ const res = await fetchImpl(url);
564
+ out(await res.text());
565
+ return 0;
566
+ }
567
+ case 'mcp':
568
+ return serveMcp({ client, stdin, stdout });
569
+ default:
570
+ throw new Error(`Unknown command: ${cmd}. Try nichedb help.`);
571
+ }
572
+ }
573
+
574
+ /* ---------------------------------------------------------------- mcp -- */
575
+
576
+ /**
577
+ * MCP over stdio: newline-delimited JSON-RPC in, out. Every message is
578
+ * forwarded to the deployment's HTTP endpoint with the stored key, so the
579
+ * tool list and behaviour are exactly the server's -- nothing is duplicated
580
+ * here. Notifications get no reply, as the protocol says.
581
+ */
582
+ export async function serveMcp({
583
+ client,
584
+ stdin = process.stdin,
585
+ stdout = process.stdout,
586
+ fetchImpl = fetch,
587
+ }) {
588
+ const rl = createInterface({ input: stdin, crlfDelay: Infinity });
589
+ const send = (obj) => stdout.write(`${JSON.stringify(obj)}\n`);
590
+ for await (const line of rl) {
591
+ const text = line.trim();
592
+ if (!text) continue;
593
+ let message;
594
+ try {
595
+ message = JSON.parse(text);
596
+ } catch {
597
+ send({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } });
598
+ continue;
599
+ }
600
+ const isNotification = message && typeof message === 'object' && !('id' in message);
601
+ try {
602
+ const res = await fetchImpl(`${client.base}/mcp`, {
603
+ method: 'POST',
604
+ headers: {
605
+ 'content-type': 'application/json',
606
+ accept: 'application/json',
607
+ 'user-agent': `nichedb-cli/${VERSION}`,
608
+ ...(client.key ? { authorization: `Bearer ${client.key}` } : {}),
609
+ ...(message?.params?._meta?.['io.modelcontextprotocol/protocolVersion']
610
+ ? {
611
+ 'mcp-protocol-version':
612
+ message.params._meta['io.modelcontextprotocol/protocolVersion'],
613
+ 'mcp-method': message.method,
614
+ ...(message.params?.name ? { 'mcp-name': message.params.name } : {}),
615
+ }
616
+ : {}),
617
+ },
618
+ body: JSON.stringify(message),
619
+ });
620
+ if (isNotification) continue;
621
+ const body = await res.text();
622
+ if (body) send(JSON.parse(body));
623
+ else send({ jsonrpc: '2.0', id: message.id ?? null, result: {} });
624
+ } catch (err) {
625
+ if (!isNotification)
626
+ send({
627
+ jsonrpc: '2.0',
628
+ id: message.id ?? null,
629
+ error: { code: -32603, message: String(err?.message ?? err) },
630
+ });
631
+ }
632
+ }
633
+ return 0;
634
+ }