@neurowire/cli 0.3.0 → 0.4.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 +190 -23
  2. package/package.json +3 -3
package/dist/index.js CHANGED
@@ -1,23 +1,33 @@
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
16
  serialize,
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
+ var VERSION = "0.4.0";
19
28
  var SORT_KEYS = ["date", "title", "source"];
20
29
  var SORT_ORDERS = ["asc", "desc"];
30
+ var FILTER_FIELDS = ["title", "summary", "source", "author", "tag"];
21
31
  var HELP = `Neurowire ${VERSION} - turn any blog or feed into Atom and friends.
22
32
 
23
33
  Usage:
@@ -35,6 +45,10 @@ Options:
35
45
  -v, --version Show the version.
36
46
 
37
47
  Shape the output (applied before --format):
48
+ --filter <f:p> Keep entries where field f matches pattern p. Repeatable.
49
+ --exclude <f:p> Drop entries where field f matches pattern p. Repeatable.
50
+ Pattern is a substring by default, or /regex/ for a regex.
51
+ Fields: title, summary, source, author, tag.
38
52
  --sort <key> Sort by date, title, or source.
39
53
  --order <dir> asc or desc (default: newest-first for date, A-Z otherwise).
40
54
  -n, --limit <n> Keep at most n entries. Handy for integrations: --limit 10.
@@ -44,8 +58,14 @@ Shape the output (applied before --format):
44
58
  --this-week Keep entries since Monday midnight UTC.
45
59
  --between <a>..<b> Keep entries between two dates, e.g. 2026-01-01..2026-02-01.
46
60
 
61
+ Watch a feed or mesh and emit only new entries:
62
+ -w, --watch Long-poll on an interval, printing only entries not seen yet.
63
+ --interval <age> Poll interval, e.g. 30m, 6h, 1d (default: 5m).
64
+ --state <file> JSON file of seen entry keys, so restarts skip old items.
65
+
47
66
  Commands:
48
67
  validate <file-or-url> Check that an nwf document is well-formed (exits non-zero if not).
68
+ tap doctor <url> Propose a FeedTemplate (tap) for a feed-less page.
49
69
 
50
70
  A mesh bundles many sources into one feed:
51
71
  { "name": "AI News", "sources": [{ "name": "...", "url": "..." }] }
@@ -59,7 +79,10 @@ Examples:
59
79
  neurowire https://example.com/feed.xml --format atom > feed.xml
60
80
  neurowire --mesh ai-news.json --format json --limit 10
61
81
  neurowire --mesh ai-news.json --since 24h --sort date --format atom
82
+ neurowire --mesh ai-news.json --filter tag:release --exclude title:sponsored --format json
83
+ neurowire --mesh ai-news.json --watch --interval 15m --format json
62
84
  neurowire validate feed.nwf
85
+ neurowire tap doctor https://example.com/blog > ~/.config/neurowire/taps/example.com.json
63
86
  `;
64
87
  var useColor = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
65
88
  var paint = (code) => (s) => useColor ? `\x1B[${code}m${s}\x1B[0m` : s;
@@ -115,6 +138,82 @@ async function runValidate(input) {
115
138
  process.exitCode = 1;
116
139
  }
117
140
  }
141
+ async function runTapDoctor(url) {
142
+ if (!url) {
143
+ process.stderr.write("error: tap doctor needs a url\n\nUsage: neurowire tap doctor <url>\n");
144
+ process.exitCode = 1;
145
+ return;
146
+ }
147
+ const doc = await fetchDocument(url);
148
+ const proposal = proposeTemplate(doc.body, doc.url);
149
+ if (!proposal) {
150
+ process.stderr.write(`${red("error")}: could not propose a template for ${url}
151
+ `);
152
+ process.exitCode = 1;
153
+ return;
154
+ }
155
+ process.stdout.write(`${JSON.stringify(proposal.template, null, 2)}
156
+ `);
157
+ process.stderr.write(
158
+ `${green("\u2713")} matched ${proposal.matched} entr${proposal.matched === 1 ? "y" : "ies"}
159
+ `
160
+ );
161
+ for (const title of proposal.sampleTitles) {
162
+ process.stderr.write(` ${dim("\xB7")} ${title}
163
+ `);
164
+ }
165
+ const host = proposal.template.host ?? "host";
166
+ process.stderr.write(
167
+ dim(`# save this as ~/.config/neurowire/taps/${host}.json or pass with --taps
168
+ `)
169
+ );
170
+ }
171
+ function parseFilterRule(value) {
172
+ const colon = value.indexOf(":");
173
+ const field = colon === -1 ? value : value.slice(0, colon);
174
+ if (!FILTER_FIELDS.includes(field)) return void 0;
175
+ let pattern = colon === -1 ? "" : value.slice(colon + 1);
176
+ let regex = false;
177
+ if (pattern.length >= 2 && pattern.startsWith("/") && pattern.endsWith("/")) {
178
+ pattern = pattern.slice(1, -1);
179
+ regex = true;
180
+ }
181
+ return { field, pattern, regex };
182
+ }
183
+ function applyFilters(feed, values) {
184
+ const fail = (raw) => {
185
+ process.stderr.write(
186
+ `error: bad filter "${raw}". Use field:pattern with one of: ${FILTER_FIELDS.join(", ")}
187
+ `
188
+ );
189
+ process.exitCode = 1;
190
+ return void 0;
191
+ };
192
+ const collect = (raw) => {
193
+ const rules = [];
194
+ for (const value of raw) {
195
+ const rule = parseFilterRule(value);
196
+ if (!rule) return fail(value);
197
+ rules.push(rule);
198
+ }
199
+ return rules;
200
+ };
201
+ const filterRaw = values.filter ?? [];
202
+ const excludeRaw = values.exclude ?? [];
203
+ if (filterRaw.length === 0 && excludeRaw.length === 0) return feed;
204
+ const include = collect(filterRaw);
205
+ if (!include) return void 0;
206
+ const exclude = collect(excludeRaw);
207
+ if (!exclude) return void 0;
208
+ const spec = { include, exclude };
209
+ const before = feed.entries.length;
210
+ const filtered = filterEntries(feed, spec);
211
+ if (filtered.entries.length !== before) {
212
+ process.stderr.write(`Filtered to ${filtered.entries.length} of ${before} entries
213
+ `);
214
+ }
215
+ return filtered;
216
+ }
118
217
  function refineFeed(feed, values) {
119
218
  const fail = (message) => {
120
219
  process.stderr.write(`error: ${message}
@@ -200,6 +299,72 @@ function renderTerminal(feed) {
200
299
  process.stdout.write(`${out.join("\n")}
201
300
  `);
202
301
  }
302
+ async function loadFeed(values, positionals) {
303
+ if (typeof values.mesh === "string") {
304
+ const mesh = MeshSchema.parse(JSON.parse(readFileSync(values.mesh, "utf8")));
305
+ return fetchMesh(mesh);
306
+ }
307
+ const url = positionals[0];
308
+ if (!url) {
309
+ process.stderr.write("error: missing <url> (or use --mesh <file>)\n\n");
310
+ process.stderr.write(HELP);
311
+ process.exitCode = 1;
312
+ return void 0;
313
+ }
314
+ let template;
315
+ if (typeof values.template === "string") {
316
+ template = FeedTemplateSchema.parse(JSON.parse(readFileSync(values.template, "utf8")));
317
+ }
318
+ return fetchFeed(url, { template });
319
+ }
320
+ function emitFeed(feed, values) {
321
+ if (typeof values.format === "string") {
322
+ if (!isFormat(values.format)) {
323
+ process.stderr.write(
324
+ `error: unknown format "${values.format}". Use one of: ${FORMATS.join(", ")}
325
+ `
326
+ );
327
+ process.exitCode = 1;
328
+ return false;
329
+ }
330
+ process.stdout.write(serialize(feed, values.format));
331
+ return true;
332
+ }
333
+ renderTerminal(feed);
334
+ return true;
335
+ }
336
+ function loadSeenState(path) {
337
+ if (!existsSync(path)) return [];
338
+ return JSON.parse(readFileSync(path, "utf8"));
339
+ }
340
+ async function runWatch(values, positionals) {
341
+ const intervalMs = parseDuration(values.interval ?? "5m");
342
+ if (intervalMs === void 0) {
343
+ process.stderr.write(
344
+ `error: invalid --interval "${values.interval}" (use e.g. 30m, 6h, 1d)
345
+ `
346
+ );
347
+ process.exitCode = 1;
348
+ return;
349
+ }
350
+ const statePath = values.state;
351
+ const seen = new Set(statePath ? loadSeenState(statePath) : []);
352
+ for (; ; ) {
353
+ const feed = await loadFeed(values, positionals);
354
+ if (!feed) return;
355
+ const filtered = applyFilters(feed, values);
356
+ if (!filtered) return;
357
+ const refined = refineFeed(filtered, values);
358
+ if (!refined) return;
359
+ const fresh = newEntries(refined, seen);
360
+ if (fresh.length > 0) emitFeed({ ...refined, entries: fresh }, values);
361
+ for (const entry of fresh) seen.add(entryKey(entry));
362
+ if (statePath) writeFileSync(statePath, JSON.stringify([...seen]));
363
+ process.stderr.write(`[watch] ${fresh.length} new (${seen.size} seen)
364
+ `);
365
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
366
+ }
367
+ }
203
368
  async function main() {
204
369
  const argv = process.argv.slice(2);
205
370
  const args = argv[0] === "--" ? argv.slice(1) : argv;
@@ -212,6 +377,8 @@ async function main() {
212
377
  template: { type: "string", short: "t" },
213
378
  mesh: { type: "string", short: "m" },
214
379
  taps: { type: "string", multiple: true },
380
+ filter: { type: "string", multiple: true },
381
+ exclude: { type: "string", multiple: true },
215
382
  sort: { type: "string" },
216
383
  order: { type: "string" },
217
384
  limit: { type: "string", short: "n" },
@@ -220,6 +387,9 @@ async function main() {
220
387
  today: { type: "boolean" },
221
388
  "this-week": { type: "boolean" },
222
389
  between: { type: "string" },
390
+ watch: { type: "boolean", short: "w" },
391
+ interval: { type: "string" },
392
+ state: { type: "string" },
223
393
  help: { type: "boolean", short: "h" },
224
394
  version: { type: "boolean", short: "v" }
225
395
  }
@@ -237,30 +407,27 @@ async function main() {
237
407
  await runValidate(positionals[1]);
238
408
  return;
239
409
  }
410
+ if (positionals[0] === "tap" && positionals[1] === "doctor") {
411
+ await runTapDoctor(positionals[2]);
412
+ return;
413
+ }
414
+ if (positionals[0] === "doctor") {
415
+ await runTapDoctor(positionals[1]);
416
+ return;
417
+ }
240
418
  const { user } = registerAllTaps(values.taps ?? []);
241
419
  if (user.length) process.stderr.write(`Loaded ${user.length} custom tap(s)
242
420
  `);
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 });
421
+ if (values.watch) {
422
+ await runWatch(values, positionals);
423
+ return;
260
424
  }
261
- const refined = refineFeed(feed, values);
262
- if (!refined) return;
263
- feed = refined;
425
+ const loaded = await loadFeed(values, positionals);
426
+ if (!loaded) return;
427
+ const filtered = applyFilters(loaded, values);
428
+ if (!filtered) return;
429
+ const feed = refineFeed(filtered, values);
430
+ if (!feed) return;
264
431
  if (values.format) {
265
432
  if (!isFormat(values.format)) {
266
433
  process.stderr.write(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neurowire/cli",
3
- "version": "0.3.0",
3
+ "version": "0.4.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,8 +39,8 @@
39
39
  "access": "public"
40
40
  },
41
41
  "dependencies": {
42
- "@neurowire/ingest": "0.2.0",
43
- "@neurowire/core": "0.3.0",
42
+ "@neurowire/core": "0.4.0",
43
+ "@neurowire/ingest": "0.3.0",
44
44
  "@neurowire/taps": "0.2.0"
45
45
  },
46
46
  "devDependencies": {