@neurowire/cli 0.3.0 → 0.5.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.
Files changed (2) hide show
  1. package/dist/index.js +279 -25
  2. package/package.json +4 -4
package/dist/index.js CHANGED
@@ -1,23 +1,103 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { readFileSync, writeFileSync } from "fs";
4
+ import { existsSync, readFileSync, writeFileSync } from "fs";
5
5
  import { parseArgs } from "util";
6
6
  import {
7
7
  FORMATS,
8
8
  MeshSchema,
9
+ entryKey,
10
+ filterEntries,
9
11
  isFormat,
12
+ newEntries,
10
13
  parseDuration,
11
14
  resolveWindow,
12
15
  selectEntries,
13
- serialize,
16
+ serialize as serialize2,
14
17
  validateNwf
15
18
  } from "@neurowire/core";
16
- import { FeedTemplateSchema, fetchFeed, fetchMesh } from "@neurowire/ingest";
19
+ import {
20
+ FeedTemplateSchema,
21
+ fetchDocument,
22
+ fetchFeed,
23
+ fetchMesh,
24
+ proposeTemplate
25
+ } from "@neurowire/ingest";
17
26
  import { registerAllTaps } from "@neurowire/taps";
18
- var VERSION = "0.3.0";
27
+
28
+ // src/sinks.ts
29
+ import { serialize } from "@neurowire/core";
30
+ function sinkKind(url) {
31
+ if (url.includes("slack.com")) return "slack";
32
+ if (url.includes("discord.com") || url.includes("discordapp.com")) return "discord";
33
+ return "webhook";
34
+ }
35
+ function buildText(feedTitle, entries, max = 10) {
36
+ const lines = [`${feedTitle}: ${entries.length} new`];
37
+ for (const entry of entries.slice(0, max)) {
38
+ lines.push(`\u2022 ${entry.title} - ${entry.link}`);
39
+ }
40
+ if (entries.length > max) {
41
+ lines.push(`\u2026and ${entries.length - max} more`);
42
+ }
43
+ return lines.join("\n");
44
+ }
45
+ function buildSlackBody(feedTitle, entries) {
46
+ return { text: buildText(feedTitle, entries) };
47
+ }
48
+ function buildDiscordBody(feedTitle, entries) {
49
+ const content = buildText(feedTitle, entries);
50
+ return { content: content.length > 2e3 ? content.slice(0, 2e3) : content };
51
+ }
52
+ function buildWebhookBody(feed) {
53
+ return serialize(feed, "json");
54
+ }
55
+ var hostOf = (url) => {
56
+ try {
57
+ return new URL(url).host;
58
+ } catch {
59
+ return url;
60
+ }
61
+ };
62
+ async function deliver(url, feed) {
63
+ const kind = sinkKind(url);
64
+ let body;
65
+ let contentType;
66
+ if (kind === "slack") {
67
+ body = JSON.stringify(buildSlackBody(feed.title, feed.entries));
68
+ contentType = "application/json";
69
+ } else if (kind === "discord") {
70
+ body = JSON.stringify(buildDiscordBody(feed.title, feed.entries));
71
+ contentType = "application/json";
72
+ } else {
73
+ body = buildWebhookBody(feed);
74
+ contentType = "application/feed+json";
75
+ }
76
+ try {
77
+ const res = await fetch(url, {
78
+ method: "POST",
79
+ headers: { "content-type": contentType },
80
+ body
81
+ });
82
+ if (!res.ok) {
83
+ process.stderr.write(`[sink] ${hostOf(url)} failed: ${res.status} ${res.statusText}
84
+ `);
85
+ return false;
86
+ }
87
+ return true;
88
+ } catch (error) {
89
+ const reason = error instanceof Error ? error.message : String(error);
90
+ process.stderr.write(`[sink] ${hostOf(url)} failed: ${reason}
91
+ `);
92
+ return false;
93
+ }
94
+ }
95
+
96
+ // src/index.ts
97
+ var VERSION = "0.5.0";
19
98
  var SORT_KEYS = ["date", "title", "source"];
20
99
  var SORT_ORDERS = ["asc", "desc"];
100
+ var FILTER_FIELDS = ["title", "summary", "source", "author", "tag"];
21
101
  var HELP = `Neurowire ${VERSION} - turn any blog or feed into Atom and friends.
22
102
 
23
103
  Usage:
@@ -35,6 +115,10 @@ Options:
35
115
  -v, --version Show the version.
36
116
 
37
117
  Shape the output (applied before --format):
118
+ --filter <f:p> Keep entries where field f matches pattern p. Repeatable.
119
+ --exclude <f:p> Drop entries where field f matches pattern p. Repeatable.
120
+ Pattern is a substring by default, or /regex/ for a regex.
121
+ Fields: title, summary, source, author, tag.
38
122
  --sort <key> Sort by date, title, or source.
39
123
  --order <dir> asc or desc (default: newest-first for date, A-Z otherwise).
40
124
  -n, --limit <n> Keep at most n entries. Handy for integrations: --limit 10.
@@ -44,8 +128,19 @@ Shape the output (applied before --format):
44
128
  --this-week Keep entries since Monday midnight UTC.
45
129
  --between <a>..<b> Keep entries between two dates, e.g. 2026-01-01..2026-02-01.
46
130
 
131
+ Watch a feed or mesh and emit only new entries:
132
+ -w, --watch Long-poll on an interval, printing only entries not seen yet.
133
+ --interval <age> Poll interval, e.g. 30m, 6h, 1d (default: 5m).
134
+ --state <file> JSON file of seen entry keys, so restarts skip old items.
135
+
136
+ Deliver to sinks (push entries to a destination):
137
+ --sink <url> POST entries to a destination. Repeatable. Slack, Discord,
138
+ or a generic webhook, auto-detected by URL. With --watch,
139
+ only the new entries are delivered each tick.
140
+
47
141
  Commands:
48
142
  validate <file-or-url> Check that an nwf document is well-formed (exits non-zero if not).
143
+ tap doctor <url> Propose a FeedTemplate (tap) for a feed-less page.
49
144
 
50
145
  A mesh bundles many sources into one feed:
51
146
  { "name": "AI News", "sources": [{ "name": "...", "url": "..." }] }
@@ -59,7 +154,11 @@ Examples:
59
154
  neurowire https://example.com/feed.xml --format atom > feed.xml
60
155
  neurowire --mesh ai-news.json --format json --limit 10
61
156
  neurowire --mesh ai-news.json --since 24h --sort date --format atom
157
+ neurowire --mesh ai-news.json --filter tag:release --exclude title:sponsored --format json
158
+ neurowire --mesh ai-news.json --watch --interval 15m --format json
159
+ neurowire --mesh ai-news.json --watch --sink https://hooks.slack.com/services/...
62
160
  neurowire validate feed.nwf
161
+ neurowire tap doctor https://example.com/blog > ~/.config/neurowire/taps/example.com.json
63
162
  `;
64
163
  var useColor = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
65
164
  var paint = (code) => (s) => useColor ? `\x1B[${code}m${s}\x1B[0m` : s;
@@ -115,6 +214,82 @@ async function runValidate(input) {
115
214
  process.exitCode = 1;
116
215
  }
117
216
  }
217
+ async function runTapDoctor(url) {
218
+ if (!url) {
219
+ process.stderr.write("error: tap doctor needs a url\n\nUsage: neurowire tap doctor <url>\n");
220
+ process.exitCode = 1;
221
+ return;
222
+ }
223
+ const doc = await fetchDocument(url);
224
+ const proposal = proposeTemplate(doc.body, doc.url);
225
+ if (!proposal) {
226
+ process.stderr.write(`${red("error")}: could not propose a template for ${url}
227
+ `);
228
+ process.exitCode = 1;
229
+ return;
230
+ }
231
+ process.stdout.write(`${JSON.stringify(proposal.template, null, 2)}
232
+ `);
233
+ process.stderr.write(
234
+ `${green("\u2713")} matched ${proposal.matched} entr${proposal.matched === 1 ? "y" : "ies"}
235
+ `
236
+ );
237
+ for (const title of proposal.sampleTitles) {
238
+ process.stderr.write(` ${dim("\xB7")} ${title}
239
+ `);
240
+ }
241
+ const host = proposal.template.host ?? "host";
242
+ process.stderr.write(
243
+ dim(`# save this as ~/.config/neurowire/taps/${host}.json or pass with --taps
244
+ `)
245
+ );
246
+ }
247
+ function parseFilterRule(value) {
248
+ const colon = value.indexOf(":");
249
+ const field = colon === -1 ? value : value.slice(0, colon);
250
+ if (!FILTER_FIELDS.includes(field)) return void 0;
251
+ let pattern = colon === -1 ? "" : value.slice(colon + 1);
252
+ let regex = false;
253
+ if (pattern.length >= 2 && pattern.startsWith("/") && pattern.endsWith("/")) {
254
+ pattern = pattern.slice(1, -1);
255
+ regex = true;
256
+ }
257
+ return { field, pattern, regex };
258
+ }
259
+ function applyFilters(feed, values) {
260
+ const fail = (raw) => {
261
+ process.stderr.write(
262
+ `error: bad filter "${raw}". Use field:pattern with one of: ${FILTER_FIELDS.join(", ")}
263
+ `
264
+ );
265
+ process.exitCode = 1;
266
+ return void 0;
267
+ };
268
+ const collect = (raw) => {
269
+ const rules = [];
270
+ for (const value of raw) {
271
+ const rule = parseFilterRule(value);
272
+ if (!rule) return fail(value);
273
+ rules.push(rule);
274
+ }
275
+ return rules;
276
+ };
277
+ const filterRaw = values.filter ?? [];
278
+ const excludeRaw = values.exclude ?? [];
279
+ if (filterRaw.length === 0 && excludeRaw.length === 0) return feed;
280
+ const include = collect(filterRaw);
281
+ if (!include) return void 0;
282
+ const exclude = collect(excludeRaw);
283
+ if (!exclude) return void 0;
284
+ const spec = { include, exclude };
285
+ const before = feed.entries.length;
286
+ const filtered = filterEntries(feed, spec);
287
+ if (filtered.entries.length !== before) {
288
+ process.stderr.write(`Filtered to ${filtered.entries.length} of ${before} entries
289
+ `);
290
+ }
291
+ return filtered;
292
+ }
118
293
  function refineFeed(feed, values) {
119
294
  const fail = (message) => {
120
295
  process.stderr.write(`error: ${message}
@@ -200,6 +375,80 @@ function renderTerminal(feed) {
200
375
  process.stdout.write(`${out.join("\n")}
201
376
  `);
202
377
  }
378
+ async function loadFeed(values, positionals) {
379
+ if (typeof values.mesh === "string") {
380
+ const mesh = MeshSchema.parse(JSON.parse(readFileSync(values.mesh, "utf8")));
381
+ return fetchMesh(mesh);
382
+ }
383
+ const url = positionals[0];
384
+ if (!url) {
385
+ process.stderr.write("error: missing <url> (or use --mesh <file>)\n\n");
386
+ process.stderr.write(HELP);
387
+ process.exitCode = 1;
388
+ return void 0;
389
+ }
390
+ let template;
391
+ if (typeof values.template === "string") {
392
+ template = FeedTemplateSchema.parse(JSON.parse(readFileSync(values.template, "utf8")));
393
+ }
394
+ return fetchFeed(url, { template });
395
+ }
396
+ function emitFeed(feed, values) {
397
+ if (typeof values.format === "string") {
398
+ if (!isFormat(values.format)) {
399
+ process.stderr.write(
400
+ `error: unknown format "${values.format}". Use one of: ${FORMATS.join(", ")}
401
+ `
402
+ );
403
+ process.exitCode = 1;
404
+ return false;
405
+ }
406
+ process.stdout.write(serialize2(feed, values.format));
407
+ return true;
408
+ }
409
+ renderTerminal(feed);
410
+ return true;
411
+ }
412
+ async function deliverToSinks(feed, values) {
413
+ if (feed.entries.length === 0) return;
414
+ const sinks = values.sink ?? [];
415
+ for (const url of sinks) {
416
+ await deliver(url, feed);
417
+ }
418
+ }
419
+ function loadSeenState(path) {
420
+ if (!existsSync(path)) return [];
421
+ return JSON.parse(readFileSync(path, "utf8"));
422
+ }
423
+ async function runWatch(values, positionals) {
424
+ const intervalMs = parseDuration(values.interval ?? "5m");
425
+ if (intervalMs === void 0) {
426
+ process.stderr.write(
427
+ `error: invalid --interval "${values.interval}" (use e.g. 30m, 6h, 1d)
428
+ `
429
+ );
430
+ process.exitCode = 1;
431
+ return;
432
+ }
433
+ const statePath = values.state;
434
+ const seen = new Set(statePath ? loadSeenState(statePath) : []);
435
+ for (; ; ) {
436
+ const feed = await loadFeed(values, positionals);
437
+ if (!feed) return;
438
+ const filtered = applyFilters(feed, values);
439
+ if (!filtered) return;
440
+ const refined = refineFeed(filtered, values);
441
+ if (!refined) return;
442
+ const fresh = newEntries(refined, seen);
443
+ if (fresh.length > 0) emitFeed({ ...refined, entries: fresh }, values);
444
+ await deliverToSinks({ ...refined, entries: fresh }, values);
445
+ for (const entry of fresh) seen.add(entryKey(entry));
446
+ if (statePath) writeFileSync(statePath, JSON.stringify([...seen]));
447
+ process.stderr.write(`[watch] ${fresh.length} new (${seen.size} seen)
448
+ `);
449
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
450
+ }
451
+ }
203
452
  async function main() {
204
453
  const argv = process.argv.slice(2);
205
454
  const args = argv[0] === "--" ? argv.slice(1) : argv;
@@ -212,6 +461,8 @@ async function main() {
212
461
  template: { type: "string", short: "t" },
213
462
  mesh: { type: "string", short: "m" },
214
463
  taps: { type: "string", multiple: true },
464
+ filter: { type: "string", multiple: true },
465
+ exclude: { type: "string", multiple: true },
215
466
  sort: { type: "string" },
216
467
  order: { type: "string" },
217
468
  limit: { type: "string", short: "n" },
@@ -220,6 +471,10 @@ async function main() {
220
471
  today: { type: "boolean" },
221
472
  "this-week": { type: "boolean" },
222
473
  between: { type: "string" },
474
+ watch: { type: "boolean", short: "w" },
475
+ interval: { type: "string" },
476
+ state: { type: "string" },
477
+ sink: { type: "string", multiple: true },
223
478
  help: { type: "boolean", short: "h" },
224
479
  version: { type: "boolean", short: "v" }
225
480
  }
@@ -237,30 +492,27 @@ async function main() {
237
492
  await runValidate(positionals[1]);
238
493
  return;
239
494
  }
495
+ if (positionals[0] === "tap" && positionals[1] === "doctor") {
496
+ await runTapDoctor(positionals[2]);
497
+ return;
498
+ }
499
+ if (positionals[0] === "doctor") {
500
+ await runTapDoctor(positionals[1]);
501
+ return;
502
+ }
240
503
  const { user } = registerAllTaps(values.taps ?? []);
241
504
  if (user.length) process.stderr.write(`Loaded ${user.length} custom tap(s)
242
505
  `);
243
- let feed;
244
- if (values.mesh) {
245
- const mesh = MeshSchema.parse(JSON.parse(readFileSync(values.mesh, "utf8")));
246
- feed = await fetchMesh(mesh);
247
- } else {
248
- const url = positionals[0];
249
- if (!url) {
250
- process.stderr.write("error: missing <url> (or use --mesh <file>)\n\n");
251
- process.stderr.write(HELP);
252
- process.exitCode = 1;
253
- return;
254
- }
255
- let template;
256
- if (values.template) {
257
- template = FeedTemplateSchema.parse(JSON.parse(readFileSync(values.template, "utf8")));
258
- }
259
- feed = await fetchFeed(url, { template });
506
+ if (values.watch) {
507
+ await runWatch(values, positionals);
508
+ return;
260
509
  }
261
- const refined = refineFeed(feed, values);
262
- if (!refined) return;
263
- feed = refined;
510
+ const loaded = await loadFeed(values, positionals);
511
+ if (!loaded) return;
512
+ const filtered = applyFilters(loaded, values);
513
+ if (!filtered) return;
514
+ const feed = refineFeed(filtered, values);
515
+ if (!feed) return;
264
516
  if (values.format) {
265
517
  if (!isFormat(values.format)) {
266
518
  process.stderr.write(
@@ -270,7 +522,7 @@ async function main() {
270
522
  process.exitCode = 1;
271
523
  return;
272
524
  }
273
- const output = serialize(feed, values.format);
525
+ const output = serialize2(feed, values.format);
274
526
  if (values.out) {
275
527
  writeFileSync(values.out, output);
276
528
  process.stderr.write(`Wrote ${feed.entries.length} entries to ${values.out}
@@ -278,9 +530,11 @@ async function main() {
278
530
  } else {
279
531
  process.stdout.write(output);
280
532
  }
533
+ await deliverToSinks(feed, values);
281
534
  return;
282
535
  }
283
536
  renderTerminal(feed);
537
+ await deliverToSinks(feed, values);
284
538
  }
285
539
  main().catch((error) => {
286
540
  process.stderr.write(`error: ${error instanceof Error ? error.message : String(error)}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neurowire/cli",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Neurowire command-line tool: turn any blog, feed, or mesh into Atom, JSON Feed, Markdown, or nwf.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -39,9 +39,9 @@
39
39
  "access": "public"
40
40
  },
41
41
  "dependencies": {
42
- "@neurowire/ingest": "0.2.0",
43
- "@neurowire/core": "0.3.0",
44
- "@neurowire/taps": "0.2.0"
42
+ "@neurowire/ingest": "0.4.0",
43
+ "@neurowire/taps": "0.2.0",
44
+ "@neurowire/core": "0.5.0"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@types/node": "^22.10.5",