@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.
- package/dist/index.js +278 -21
- 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 {
|
|
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.
|
|
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
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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.
|
|
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": ">=
|
|
36
|
+
"node": ">=24"
|
|
37
37
|
},
|
|
38
38
|
"publishConfig": {
|
|
39
39
|
"access": "public"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@neurowire/
|
|
43
|
-
"@neurowire/
|
|
44
|
-
"@neurowire/taps": "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",
|