@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.
- package/dist/index.js +279 -25
- 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 {
|
|
19
|
+
import {
|
|
20
|
+
FeedTemplateSchema,
|
|
21
|
+
fetchDocument,
|
|
22
|
+
fetchFeed,
|
|
23
|
+
fetchMesh,
|
|
24
|
+
proposeTemplate
|
|
25
|
+
} from "@neurowire/ingest";
|
|
17
26
|
import { registerAllTaps } from "@neurowire/taps";
|
|
18
|
-
|
|
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
|
-
|
|
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 });
|
|
506
|
+
if (values.watch) {
|
|
507
|
+
await runWatch(values, positionals);
|
|
508
|
+
return;
|
|
260
509
|
}
|
|
261
|
-
const
|
|
262
|
-
if (!
|
|
263
|
-
|
|
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 =
|
|
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
|
+
"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.
|
|
43
|
-
"@neurowire/
|
|
44
|
-
"@neurowire/
|
|
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",
|