@neurowire/cli 0.1.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 +278 -21
  2. package/package.json +5 -5
package/dist/index.js CHANGED
@@ -1,18 +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,
13
+ parseDuration,
14
+ resolveWindow,
15
+ selectEntries,
10
16
  serialize,
11
17
  validateNwf
12
18
  } from "@neurowire/core";
13
- import { FeedTemplateSchema, fetchFeed, fetchMesh } from "@neurowire/ingest";
19
+ import {
20
+ FeedTemplateSchema,
21
+ fetchDocument,
22
+ fetchFeed,
23
+ fetchMesh,
24
+ proposeTemplate
25
+ } from "@neurowire/ingest";
14
26
  import { registerAllTaps } from "@neurowire/taps";
15
- var VERSION = "0.1.0";
27
+ var VERSION = "0.4.0";
28
+ var SORT_KEYS = ["date", "title", "source"];
29
+ var SORT_ORDERS = ["asc", "desc"];
30
+ var FILTER_FIELDS = ["title", "summary", "source", "author", "tag"];
16
31
  var HELP = `Neurowire ${VERSION} - turn any blog or feed into Atom and friends.
17
32
 
18
33
  Usage:
@@ -29,8 +44,28 @@ Options:
29
44
  -h, --help Show this help.
30
45
  -v, --version Show the version.
31
46
 
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.
52
+ --sort <key> Sort by date, title, or source.
53
+ --order <dir> asc or desc (default: newest-first for date, A-Z otherwise).
54
+ -n, --limit <n> Keep at most n entries. Handy for integrations: --limit 10.
55
+ --since <age> Keep entries within this window, e.g. 24h, 90m, 7d.
56
+ --max-age <age> Drop entries older than this (same window as --since).
57
+ --today Keep entries since midnight UTC today.
58
+ --this-week Keep entries since Monday midnight UTC.
59
+ --between <a>..<b> Keep entries between two dates, e.g. 2026-01-01..2026-02-01.
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
+
32
66
  Commands:
33
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.
34
69
 
35
70
  A mesh bundles many sources into one feed:
36
71
  { "name": "AI News", "sources": [{ "name": "...", "url": "..." }] }
@@ -42,8 +77,12 @@ Taps teach Neurowire to read sites with no RSS/Atom feed. Add your own with
42
77
  Examples:
43
78
  neurowire https://example.com/blog
44
79
  neurowire https://example.com/feed.xml --format atom > feed.xml
45
- neurowire --mesh ai-news.json --format atom
80
+ neurowire --mesh ai-news.json --format json --limit 10
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
46
84
  neurowire validate feed.nwf
85
+ neurowire tap doctor https://example.com/blog > ~/.config/neurowire/taps/example.com.json
47
86
  `;
48
87
  var useColor = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
49
88
  var paint = (code) => (s) => useColor ? `\x1B[${code}m${s}\x1B[0m` : s;
@@ -99,6 +138,145 @@ async function runValidate(input) {
99
138
  process.exitCode = 1;
100
139
  }
101
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
+ }
217
+ function refineFeed(feed, values) {
218
+ const fail = (message) => {
219
+ process.stderr.write(`error: ${message}
220
+ `);
221
+ process.exitCode = 1;
222
+ return void 0;
223
+ };
224
+ const sort = values.sort;
225
+ if (sort !== void 0 && !SORT_KEYS.includes(sort)) {
226
+ return fail(`unknown --sort "${sort}". Use one of: ${SORT_KEYS.join(", ")}`);
227
+ }
228
+ const order = values.order;
229
+ if (order !== void 0 && !SORT_ORDERS.includes(order)) {
230
+ return fail(`unknown --order "${order}". Use asc or desc`);
231
+ }
232
+ let limit;
233
+ if (values.limit !== void 0) {
234
+ limit = Number(values.limit);
235
+ if (!Number.isInteger(limit) || limit < 0) {
236
+ return fail(`--limit must be a non-negative integer (got "${values.limit}")`);
237
+ }
238
+ }
239
+ const spec = {};
240
+ if (typeof values.since === "string") spec.since = values.since;
241
+ if (typeof values["max-age"] === "string") spec.maxAge = values["max-age"];
242
+ if (values.today) spec.today = true;
243
+ if (values["this-week"]) spec.thisWeek = true;
244
+ if (typeof values.between === "string") {
245
+ const parts = values.between.split("..");
246
+ if (parts.length !== 2 || !parts[0] || !parts[1]) {
247
+ return fail("--between expects <start>..<end>, e.g. 2026-01-01..2026-02-01");
248
+ }
249
+ spec.between = [parts[0], parts[1]];
250
+ }
251
+ for (const [flag, value] of [
252
+ ["--since", spec.since],
253
+ ["--max-age", spec.maxAge]
254
+ ]) {
255
+ if (value !== void 0 && parseDuration(value) === void 0) {
256
+ return fail(`invalid ${flag} "${value}" (use e.g. 24h, 90m, 7d)`);
257
+ }
258
+ }
259
+ if (spec.between) {
260
+ const [a, b] = spec.between;
261
+ if (Number.isNaN(Date.parse(a)) || Number.isNaN(Date.parse(b))) {
262
+ return fail("--between needs two parseable dates, e.g. 2026-01-01..2026-02-01");
263
+ }
264
+ }
265
+ const window = resolveWindow(spec, Date.now());
266
+ const opts = {
267
+ ...window,
268
+ sort,
269
+ order,
270
+ limit
271
+ };
272
+ const before = feed.entries.length;
273
+ const refined = selectEntries(feed, opts);
274
+ if (refined.entries.length !== before) {
275
+ process.stderr.write(`Refined to ${refined.entries.length} of ${before} entries
276
+ `);
277
+ }
278
+ return refined;
279
+ }
102
280
  function renderTerminal(feed) {
103
281
  const out = [bold(cyan(feed.title))];
104
282
  const sub = [feed.home, `${feed.entries.length} entries`, `updated ${day(feed.updated)}`].filter(
@@ -121,6 +299,72 @@ function renderTerminal(feed) {
121
299
  process.stdout.write(`${out.join("\n")}
122
300
  `);
123
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
+ }
124
368
  async function main() {
125
369
  const argv = process.argv.slice(2);
126
370
  const args = argv[0] === "--" ? argv.slice(1) : argv;
@@ -133,6 +377,19 @@ async function main() {
133
377
  template: { type: "string", short: "t" },
134
378
  mesh: { type: "string", short: "m" },
135
379
  taps: { type: "string", multiple: true },
380
+ filter: { type: "string", multiple: true },
381
+ exclude: { type: "string", multiple: true },
382
+ sort: { type: "string" },
383
+ order: { type: "string" },
384
+ limit: { type: "string", short: "n" },
385
+ since: { type: "string" },
386
+ "max-age": { type: "string" },
387
+ today: { type: "boolean" },
388
+ "this-week": { type: "boolean" },
389
+ between: { type: "string" },
390
+ watch: { type: "boolean", short: "w" },
391
+ interval: { type: "string" },
392
+ state: { type: "string" },
136
393
  help: { type: "boolean", short: "h" },
137
394
  version: { type: "boolean", short: "v" }
138
395
  }
@@ -150,27 +407,27 @@ async function main() {
150
407
  await runValidate(positionals[1]);
151
408
  return;
152
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
+ }
153
418
  const { user } = registerAllTaps(values.taps ?? []);
154
419
  if (user.length) process.stderr.write(`Loaded ${user.length} custom tap(s)
155
420
  `);
156
- let feed;
157
- if (values.mesh) {
158
- const mesh = MeshSchema.parse(JSON.parse(readFileSync(values.mesh, "utf8")));
159
- feed = await fetchMesh(mesh);
160
- } else {
161
- const url = positionals[0];
162
- if (!url) {
163
- process.stderr.write("error: missing <url> (or use --mesh <file>)\n\n");
164
- process.stderr.write(HELP);
165
- process.exitCode = 1;
166
- return;
167
- }
168
- let template;
169
- if (values.template) {
170
- template = FeedTemplateSchema.parse(JSON.parse(readFileSync(values.template, "utf8")));
171
- }
172
- feed = await fetchFeed(url, { template });
421
+ if (values.watch) {
422
+ await runWatch(values, positionals);
423
+ return;
173
424
  }
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;
174
431
  if (values.format) {
175
432
  if (!isFormat(values.format)) {
176
433
  process.stderr.write(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neurowire/cli",
3
- "version": "0.1.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": {
@@ -33,15 +33,15 @@
33
33
  "url": "https://github.com/starside-io/neurowire/issues"
34
34
  },
35
35
  "engines": {
36
- "node": ">=20"
36
+ "node": ">=24"
37
37
  },
38
38
  "publishConfig": {
39
39
  "access": "public"
40
40
  },
41
41
  "dependencies": {
42
- "@neurowire/ingest": "0.1.0",
43
- "@neurowire/core": "0.1.0",
44
- "@neurowire/taps": "0.1.0"
42
+ "@neurowire/core": "0.4.0",
43
+ "@neurowire/ingest": "0.3.0",
44
+ "@neurowire/taps": "0.2.0"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@types/node": "^22.10.5",