@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.
- package/dist/index.js +190 -23
- 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 {
|
|
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.
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
|
262
|
-
if (!
|
|
263
|
-
|
|
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
|
+
"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/
|
|
43
|
-
"@neurowire/
|
|
42
|
+
"@neurowire/core": "0.4.0",
|
|
43
|
+
"@neurowire/ingest": "0.3.0",
|
|
44
44
|
"@neurowire/taps": "0.2.0"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|