diff --git a/README.md b/README.md
index 8ceea6f..e81b0ce 100644
--- a/README.md
+++ b/README.md
@@ -57,6 +57,8 @@ or miss one that does. A test fails the build when it drifts.
| `moshcode trade` | tools | look up markets and trade through Alpaca |
| `moshcode stocks` `advisor` | tools | equity research from advis0r.com |
| `moshcode crypto` `coins` | tools | crypto market data from advis0r.com |
+| `moshcode news` | tools | headlines from your feeds, or a search |
+| `moshcode rss` | tools | read the same headlines in a full-screen reader |
| `moshcode plugin` `plugins` | extend | install moshcode's slash commands into Claude Code |
| `moshcode commands` | script | list built-in moshscript commands |
| `moshcode completion` | extend | print a shell completion script |
diff --git a/bin/moshcode.mjs b/bin/moshcode.mjs
index 456721b..48c0019 100755
--- a/bin/moshcode.mjs
+++ b/bin/moshcode.mjs
@@ -394,6 +394,22 @@ async function main() {
if (code) process.exitCode = code;
return;
}
+ if (cmd === "news") {
+ const { newsCommand } = await import("../src/news.mjs");
+ const code = await newsCommand(rest, {
+ openUrl: (url) => canOpenBrowser() && openBrowser(url),
+ });
+ if (code) process.exitCode = code;
+ return;
+ }
+ if (cmd === "rss") {
+ const { rssUi } = await import("../src/rss-ui.mjs");
+ const code = await rssUi(rest, {
+ openUrl: (url) => canOpenBrowser() && openBrowser(url),
+ });
+ if (code) process.exitCode = code;
+ return;
+ }
if (cmd === "plugin" || cmd === "plugins") {
const code = await pluginCommand(rest);
if (code) process.exitCode = code;
diff --git a/src/cli-schema.mjs b/src/cli-schema.mjs
index 7359a58..2fa2208 100644
--- a/src/cli-schema.mjs
+++ b/src/cli-schema.mjs
@@ -515,6 +515,50 @@ export const CORE_CLI_COMMANDS = [
note: "research aid, not advice — prices are Alpaca's US crypto venue alone and can differ materially from other exchanges. Crypto trades 24/7 with no circuit breakers. Set MOSHCODE_ADVISOR_URL to point at another instance.",
},
{ name: "coins", aliasOf: "crypto", description: "alias for crypto" },
+ {
+ name: "news",
+ group: "tools",
+ description: "headlines from your feeds, or a search",
+ synopsis: [
+ ["moshcode news", "latest headlines across every subscribed feed"],
+ ["moshcode news ", "search the news"],
+ ["moshcode news [args…]", ""],
+ ],
+ verbs: "NEWS_VERBS",
+ flags: [
+ ["--json", "structured output instead of headlines", ""],
+ ["--limit ", "how many headlines to show", "20"],
+ ["--feed ", "only this feed", "all of them"],
+ ["--timeout ", "per-feed fetch timeout", "10"],
+ ],
+ examples: [
+ ["moshcode news", "everything you subscribe to, newest first"],
+ ["moshcode news openai earnings", "search Google News and Bing News"],
+ ["moshcode news https://example.com/feed.xml", "read one feed without subscribing"],
+ ["moshcode news add journalists", "pull in a public OPML bundle"],
+ ["moshcode news export > feeds.opml", "take the list to another reader"],
+ ],
+ seeAlso: ["rss", "stocks", "crypto"],
+ note: "subscriptions live in ~/.moshcode/news.opml (override with MOSHCODE_NEWS_OPML). "
+ + "With none, a default set is read instead — `moshcode news sources` lists it. "
+ + "The defaults and the search are the same sources brisk.news and advis0r.com use.",
+ },
+ {
+ name: "rss",
+ group: "tools",
+ description: "read the same headlines in a full-screen reader",
+ synopsis: [
+ ["moshcode rss", "open the reader on your feeds"],
+ ["moshcode rss ", "open it on a search"],
+ ],
+ examples: [
+ ["moshcode rss", "feeds on the left, headlines in the middle"],
+ ["moshcode rss tariffs", "open straight into a search"],
+ ],
+ seeAlso: ["news"],
+ note: "needs an interactive terminal. ↑↓/jk move · ⏎ read · o open in a browser · "
+ + "tab the feed list · / search · r refresh · q quit.",
+ },
{
name: "plugin",
group: "extend",
@@ -740,6 +784,31 @@ export const STOCKS_VERBS = [
{ name: "open", description: "open the shareable report page in a browser", synopsis: [["moshcode stocks open ", ""]] },
];
+/**
+ * `news`'s verbs.
+ *
+ * `latest` earns its place the way crypto's `report` does: a bare argument is
+ * the shortcut — `moshcode news tariffs` searches — so the plain listing needs
+ * a spelling that a keyword cannot be mistaken for. src/news.mjs owns the
+ * parser and test/news.test.mjs fails when the two lists disagree.
+ */
+export const NEWS_VERBS = [
+ { name: "latest", description: "headlines across every subscribed feed", synopsis: [["moshcode news latest", "same as `moshcode news`"]] },
+ {
+ name: "search", description: "search the news for a word or phrase",
+ synopsis: [["moshcode news search ", "same as `moshcode news `"]],
+ },
+ { name: "list", description: "the feeds you are subscribed to", synopsis: [["moshcode news list [--json]", ""]] },
+ {
+ name: "add", description: "subscribe to a feed, an OPML list, or a bundle",
+ synopsis: [["moshcode news add ", "an RSS/Atom link, an OPML list, or journalists|web3|blockchain"]],
+ },
+ { name: "rm", description: "unsubscribe", synopsis: [["moshcode news rm ", ""]] },
+ { name: "open", description: "open a headline from the last listing", synopsis: [["moshcode news open ", ""]] },
+ { name: "sources", description: "the default feeds and the bundles on offer", synopsis: [["moshcode news sources", ""]] },
+ { name: "export", description: "print the subscription list as OPML", synopsis: [["moshcode news export > feeds.opml", ""]] },
+];
+
/**
* `crypto`'s verbs.
*
@@ -888,6 +957,7 @@ export const VERB_TABLES = {
TRADE_VERBS,
STOCKS_VERBS,
CRYPTO_VERBS,
+ NEWS_VERBS,
PLUGIN_VERBS,
};
@@ -931,6 +1001,10 @@ export const PIT_COMMANDS = [
description: "equity research from advis0r.com" },
{ name: "crypto", aliases: ["coins"], args: " [args…]", cli: "crypto",
description: "crypto market data from advis0r.com" },
+ { name: "news", args: "[keyword…|verb] [args…]", cli: "news",
+ description: "headlines from your feeds, or a search" },
+ { name: "rss", aliases: ["reader"], cli: "rss",
+ description: "read the same headlines in a full-screen reader" },
{ name: "plugin", aliases: ["plugins"], args: " [name]", cli: "plugin",
description: "install moshcode's slash commands into Claude Code" },
{ name: "games", aliases: ["game", "arcade", "play"], args: "[game]", cli: "games",
diff --git a/src/news-sources.mjs b/src/news-sources.mjs
new file mode 100644
index 0000000..54b65b9
--- /dev/null
+++ b/src/news-sources.mjs
@@ -0,0 +1,154 @@
+// Where `/news` gets its headlines when nobody has subscribed to anything yet.
+//
+// These are not invented: they are the sources the two profullstack properties
+// that already do this in production use, lifted so the pit answers the same
+// way they do.
+//
+// brisk.news — apps/web/src/lib/news/fetch-feed.ts builds Google News feeds,
+// top stories for the front page and one per category, and
+// scripts/import-opml-feeds.ts seeds its publisher table from four public
+// OPML lists. Both are represented here: the Google feeds as the defaults,
+// the OPML lists as named bundles `/news add` accepts by name.
+//
+// advis0r.com — src/providers/news/rss.ts pairs a Google News query feed with
+// a Bing one (Google's links are interstitials, Bing's carry the publisher
+// URL in a `url=` parameter), and reads two newswires directly. The search
+// pairing is why `/news ` queries both, and the wires are here
+// under `markets`.
+//
+// Deliberately small. A default list is a claim that every entry works, so it
+// holds feeds with stable well-known URLs and defers everything else to the
+// OPML bundles, where the list is somebody else's to maintain.
+
+/** Google News locale. One place, because every builder below needs it. */
+const GOOGLE_LOCALE = "hl=en-US&gl=US&ceid=US:en";
+
+/**
+ * Google News category feeds, keyed the way brisk.news keys them.
+ *
+ * `general` is null there and means "top stories", which is a different URL
+ * rather than a search for the word "general" — the same distinction is kept.
+ */
+export const GOOGLE_NEWS_CATEGORIES = {
+ general: null,
+ science: "science",
+ sports: "sports",
+ business: "business",
+ health: "health",
+ entertainment: "entertainment",
+ tech: "technology",
+ politics: "politics",
+ food: "food",
+ travel: "travel",
+};
+
+/** Google News top stories, or one category from the map above. */
+export function googleNewsFeed(category = null) {
+ const mapped = category ? GOOGLE_NEWS_CATEGORIES[category] ?? null : null;
+ if (!mapped) return `https://news.google.com/rss?${GOOGLE_LOCALE}`;
+ return `https://news.google.com/rss/search?q=${encodeURIComponent(mapped)}&${GOOGLE_LOCALE}`;
+}
+
+/**
+ * Google News query feed. `when:7d` style windows keep results recent —
+ * advis0r's googleNewsFeed() does the same, for the same reason.
+ */
+export function googleNewsSearch(query, { window = "7d" } = {}) {
+ const q = window ? `${query} when:${window}` : String(query);
+ return `https://news.google.com/rss/search?q=${encodeURIComponent(q)}&${GOOGLE_LOCALE}`;
+}
+
+/** Bing News query feed — the half of a search whose links are real articles. */
+export function bingNewsSearch(query) {
+ return `https://www.bing.com/news/search?q=${encodeURIComponent(query)}&format=RSS`;
+}
+
+/**
+ * Resolve a redirect wrapper to the publisher URL it carries.
+ *
+ * Lifted from advis0r's unwrapRedirect for the same reason it exists there:
+ * aggregator feeds link to themselves, and a reader that opens
+ * `news.google.com/rss/articles/CBM…` shows an interstitial instead of the
+ * story. Only decodes what is already in the link — it never follows a
+ * redirect, so it costs no request and cannot be led somewhere unexpected.
+ */
+export function unwrapRedirect(url) {
+ try {
+ const parsed = new URL(url);
+ const inner = parsed.searchParams.get("url") ?? parsed.searchParams.get("u");
+ if (!inner) return url;
+ const decoded = decodeURIComponent(inner);
+ return /^https?:\/\//i.test(decoded) ? decoded : url;
+ } catch {
+ return url;
+ }
+}
+
+/**
+ * The feeds a fresh install reads.
+ *
+ * Google News carries the general desks because that is exactly what brisk.news
+ * serves its front page from, and it means one URL shape covers ten sections
+ * without ten publisher relationships to keep working. The named publishers are
+ * tier-1 hosts from advis0r's own tiering table that publish a stable feed, and
+ * they earn their place by being readable end to end — a Google News item is a
+ * headline behind an interstitial, theirs is the article.
+ */
+export const DEFAULT_FEEDS = [
+ { name: "top-stories", title: "Google News — Top Stories", url: googleNewsFeed(), site: "https://news.google.com", category: "" },
+ { name: "world", title: "Google News — World", url: googleNewsSearch("world news", { window: "2d" }), site: "https://news.google.com", category: "" },
+
+ { name: "tech", title: "Google News — Technology", url: googleNewsFeed("tech"), site: "https://news.google.com", category: "tech" },
+ { name: "ars-technica", title: "Ars Technica", url: "https://feeds.arstechnica.com/arstechnica/index", site: "https://arstechnica.com", category: "tech" },
+ { name: "techcrunch", title: "TechCrunch", url: "https://techcrunch.com/feed/", site: "https://techcrunch.com", category: "tech" },
+ { name: "the-register", title: "The Register", url: "https://www.theregister.com/headlines.atom", site: "https://www.theregister.com", category: "tech" },
+ { name: "hacker-news", title: "Hacker News — Front Page", url: "https://hnrss.org/frontpage", site: "https://news.ycombinator.com", category: "tech" },
+
+ { name: "business", title: "Google News — Business", url: googleNewsFeed("business"), site: "https://news.google.com", category: "markets" },
+ { name: "marketwatch", title: "MarketWatch — Top Stories", url: "https://feeds.content.dowjones.io/public/rss/mw_topstories", site: "https://www.marketwatch.com", category: "markets" },
+ // The two newswires advis0r reads directly. Tier 0 there: issuers speaking
+ // for themselves rather than a publication speaking about them.
+ { name: "pr-newswire", title: "PR Newswire — Financial Services", url: "https://www.prnewswire.com/rss/financial-services-latest-news/financial-services-latest-news-list.rss", site: "https://www.prnewswire.com", category: "markets" },
+ { name: "globenewswire", title: "GlobeNewswire — Public Companies", url: "https://www.globenewswire.com/RssFeed/orgclass/1/feedTitle/GlobeNewswire%20-%20News%20about%20Public%20Companies", site: "https://www.globenewswire.com", category: "markets" },
+
+ { name: "science", title: "Google News — Science", url: googleNewsFeed("science"), site: "https://news.google.com", category: "science" },
+ { name: "politics", title: "Google News — Politics", url: googleNewsFeed("politics"), site: "https://news.google.com", category: "politics" },
+];
+
+/**
+ * The public OPML lists brisk.news imports its publisher table from.
+ *
+ * Offered by name (`/news add journalists`) rather than only by URL because
+ * these are long, exact raw.githubusercontent paths that nobody is going to
+ * retype, and importing one is the fastest way from an empty reader to a real
+ * one. They are somebody else's lists, and that is the point: the feeds inside
+ * them stay current without moshcode shipping a release.
+ */
+export const OPML_BUNDLES = [
+ {
+ name: "journalists",
+ description: "Dave Winer's feedsForJournalists — mainstream desks",
+ url: "https://raw.githubusercontent.com/scripting/feedsForJournalists/master/list.opml",
+ },
+ {
+ name: "web3",
+ description: "ChainFeeds RSSAggregatorforWeb3 — crypto and web3",
+ url: "https://raw.githubusercontent.com/chainfeeds/RSSAggregatorforWeb3/main/RAW.opml",
+ },
+ {
+ name: "blockchain",
+ description: "CoinFabrik decentralized-and-blockchain-feeds",
+ url: "https://raw.githubusercontent.com/CoinFabrik/resources/master/decentralized-and-blockchain-feeds.opml",
+ },
+];
+
+/** Resolve a bundle name to its OPML URL, or null. */
+export function resolveBundle(name) {
+ const wanted = String(name ?? "").trim().toLowerCase();
+ return OPML_BUNDLES.find((b) => b.name === wanted) ?? null;
+}
+
+/** A fresh copy of the defaults — callers mutate feed lists. */
+export function defaultFeeds() {
+ return DEFAULT_FEEDS.map((feed) => ({ ...feed }));
+}
diff --git a/src/news.mjs b/src/news.mjs
new file mode 100644
index 0000000..de4f45d
--- /dev/null
+++ b/src/news.mjs
@@ -0,0 +1,1071 @@
+// `moshcode news` — the headlines, in the pit.
+//
+// The same split as src/crypto.mjs, for the same reasons: argument translation
+// is pure and testable, the network call is injectable, and rendering is a
+// function of the parsed feed. What differs is where the data comes from —
+// there is no advis0r API here, only whatever feeds the operator subscribed to.
+//
+// Subscriptions live in an OPML file (~/.moshcode/news.opml) rather than in a
+// news.json of our own invention. OPML is the interchange format every reader
+// already speaks, so the subscription list can be exported from an existing
+// reader, dropped in, and taken back out again — which is the whole reason to
+// have a file instead of a flag. `/news add` accepts either an OPML document or
+// a single RSS/Atom link and works out which it was given, because "a feed" and
+// "a list of feeds" are the two shapes a URL handed to a news reader can have.
+//
+// The XML is read with targeted regexes rather than a parser. That is a real
+// constraint and it is deliberate: moshcode ships with no runtime dependencies,
+// and feeds are a small, well-trodden subset of XML. It also removes a class of
+// risk outright — DOCTYPE is stripped and entities are decoded from a fixed
+// table, so a hostile feed cannot expand an entity into a file read.
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+
+import { acid, ash, amber, bone, danger } from "./ui.mjs";
+import {
+ bingNewsSearch,
+ defaultFeeds,
+ googleNewsSearch,
+ OPML_BUNDLES,
+ resolveBundle,
+ unwrapRedirect,
+} from "./news-sources.mjs";
+
+const USAGE = `usage: moshcode news [verb|keyword…] [args…]
+
+ (no verb) latest headlines across every subscribed feed
+ latest the same thing, said out loud
+ search the news for a word or phrase
+ read one feed without subscribing to it
+ list the feeds you are subscribed to
+ add subscribe — an RSS/Atom link, an OPML list, or
+ a bundle: ${OPML_BUNDLES.map((b) => b.name).join(", ")}
+ rm unsubscribe
+ open open headline from the last listing
+ sources the default feeds and the bundles on offer
+ export print the subscription list as OPML
+
+ --json print structured data instead of headlines
+ --limit how many headlines to show (default 20)
+ --feed only this subscribed feed
+ --timeout per-feed fetch timeout (default 10)
+
+\`moshcode rss\` opens the same headlines as a full-screen reader.
+
+Feeds live in ~/.moshcode/news.opml — export it to any reader, or point
+MOSHCODE_NEWS_OPML at a list you already keep somewhere else. With no
+subscriptions the defaults are read instead, so \`/news\` works on a fresh
+install; \`/news add\` anything and the defaults step aside.`;
+
+export function newsUsage() {
+ return USAGE;
+}
+
+/** Verb names, in help order. cli-schema's NEWS_VERBS must match (drift test). */
+export const NEWS_VERB_NAMES = ["latest", "search", "list", "add", "rm", "open", "sources", "export"];
+
+// The same reasoning as crypto's alias table: the obvious synonym should not be
+// an error. `import` is the word an OPML file invites, and it is the same verb
+// as `add` here precisely because `add` already takes an OPML document.
+const VERB_ALIASES = {
+ new: "latest", recent: "latest", top: "latest", headlines: "latest",
+ find: "search", q: "search", query: "search", grep: "search",
+ feeds: "list", ls: "list", subscriptions: "list",
+ sub: "add", subscribe: "add", import: "add", follow: "add",
+ remove: "rm", unsub: "rm", unsubscribe: "rm", del: "rm", delete: "rm",
+ read: "open", browse: "open", www: "open",
+ bundles: "sources", defaults: "sources",
+ opml: "export", dump: "export",
+};
+
+/** Resolve a first argument to a canonical verb, or null when it is not one. */
+export function resolveVerb(word) {
+ const key = String(word ?? "").toLowerCase();
+ if (NEWS_VERB_NAMES.includes(key)) return key;
+ return VERB_ALIASES[key] ?? null;
+}
+
+/** Owner-only, like aliases.json: a subscription list is a reading history. */
+const FILE_MODE = 0o600;
+
+/** Enough for a large feed, small enough that one bad URL cannot eat the pit. */
+const MAX_BYTES = 8 * 1024 * 1024;
+
+/** How many feeds are in flight at once. Politeness, not throughput. */
+const CONCURRENCY = 6;
+
+const DEFAULT_LIMIT = 20;
+const MAX_LIMIT = 200;
+const DEFAULT_TIMEOUT_MS = 10_000;
+
+/**
+ * Where the subscription list lives. Derived per call so tests can move $HOME,
+ * and overridable so an operator can point at a list they already maintain.
+ */
+export function opmlFile(env = process.env) {
+ const override = String(env.MOSHCODE_NEWS_OPML || "").trim();
+ if (override) return path.resolve(override);
+ return path.join(os.homedir(), ".moshcode", "news.opml");
+}
+
+/** Where the last rendered listing is remembered, so `/news open 3` knows what 3 was. */
+export function cacheFile(env = process.env) {
+ return path.join(path.dirname(opmlFile(env)), "news-last.json");
+}
+
+// ---------------------------------------------------------------------------
+// XML, the small subset feeds actually use
+// ---------------------------------------------------------------------------
+
+const ENTITIES = {
+ amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ",
+ ldquo: "“", rdquo: "”", lsquo: "‘", rsquo: "’",
+ mdash: "—", ndash: "–", hellip: "…", eacute: "é",
+};
+
+/**
+ * Decode the entities a feed actually carries. A fixed table plus numeric
+ * escapes — never the document's own DOCTYPE entities, which is what keeps a
+ * feed from declaring one that expands to the contents of /etc/passwd.
+ */
+export function decodeEntities(text) {
+ return String(text ?? "").replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (match, body) => {
+ if (body[0] === "#") {
+ const code = body[1] === "x" || body[1] === "X"
+ ? Number.parseInt(body.slice(2), 16)
+ : Number.parseInt(body.slice(1), 10);
+ // Surrogates and out-of-range code points would throw; leave them as text.
+ if (!Number.isFinite(code) || code < 1 || code > 0x10ffff) return match;
+ if (code >= 0xd800 && code <= 0xdfff) return match;
+ return String.fromCodePoint(code);
+ }
+ const named = ENTITIES[body.toLowerCase()];
+ return named === undefined ? match : named;
+ });
+}
+
+/** Strip the parts of a document that are never content: BOM, comments, DOCTYPE. */
+function scrub(xml) {
+ return String(xml ?? "")
+ .replace(/^/, "")
+ .replace(//g, "")
+ .replace(/[]*(\[[\s\S]*?\])?[^>]*>/gi, "");
+}
+
+/**
+ * CDATA out, entities decoded, tags out, whitespace collapsed.
+ *
+ * Decoding before stripping, not after, and the order is load-bearing: a
+ * `` arrives as markup two different ways. CDATA carries real
+ * tags, but plenty of publishers — Google News among them — escape the same
+ * HTML into `<a href=…>` instead. Strip first and the escaped form sails
+ * through untouched, then decoding turns it back into the markup that was
+ * supposed to have been removed, and the headline reads as an anchor tag.
+ */
+function text(raw) {
+ let value = String(raw ?? "").replace(//g, "$1");
+ // Twice, because aggregators escape HTML that was already escaped: a Google
+ // News description arrives as `<a…>` for the tags and ` ` for
+ // the spaces between them, so one round leaves a literal ` ` on screen.
+ // Bounded at two — that is every doubling seen in the wild, and looping until
+ // a document stops changing is a decompression bomb waiting to happen.
+ for (let round = 0; round < 2; round++) {
+ value = decodeEntities(value).replace(/<[^>]*>/g, " ");
+ }
+ return value.replace(/\s+/g, " ").trim();
+}
+
+/**
+ * The text of the first `` in a block, namespace prefix optional.
+ *
+ * Namespace-agnostic because feeds are inconsistent about it in exactly the
+ * places that matter: the same publisher's `` and `` mean the
+ * same thing. Callers that need one specific namespace pass the prefix in.
+ */
+function pick(block, tag) {
+ const name = tag.includes(":") ? tag.replace(":", "\\:") : `(?:[a-z0-9]+\\:)?${tag}`;
+ const match = new RegExp(`<${name}(?:\\s[^>]*)?>([\\s\\S]*?)${name}>`, "i").exec(block);
+ return match ? text(match[1]) : "";
+}
+
+/** The value of an attribute on a tag, unescaped. Single or double quoted. */
+function attr(tag, name) {
+ const match = new RegExp(`\\s${name}\\s*=\\s*("([^"]*)"|'([^']*)')`, "i").exec(tag);
+ return match ? decodeEntities(match[2] ?? match[3] ?? "") : "";
+}
+
+/** Every `` opening in a document, in order, as raw strings. */
+function tagsNamed(xml, name) {
+ return xml.match(new RegExp(`<${name}(?:\\s[^>]*)?/?>`, "gi")) || [];
+}
+
+/** Only http(s) survives. A feed must not talk us into opening file: or data:. */
+export function safeUrl(raw, base = null) {
+ const value = String(raw ?? "").trim();
+ if (!value) return null;
+ let url;
+ try { url = base ? new URL(value, base) : new URL(value); }
+ catch { return null; }
+ return url.protocol === "http:" || url.protocol === "https:" ? url.toString() : null;
+}
+
+// ---------------------------------------------------------------------------
+// OPML — the subscription list
+// ---------------------------------------------------------------------------
+
+/**
+ * Every feed in an OPML document, as { name, url, site, category }.
+ *
+ * Outlines nest: readers use a bare `` as a folder around
+ * the feeds inside it. The stack below tracks that, so an imported list keeps
+ * the grouping its owner gave it instead of flattening to one pile. Only
+ * outlines carrying an `xmlUrl` are feeds; the rest are folders.
+ */
+export function parseOpml(xml) {
+ const doc = scrub(xml);
+ const body = /]*)?>([\s\S]*)<\/body>/i.exec(doc);
+ const source = body ? body[1] : doc;
+ const feeds = [];
+ const seen = new Set();
+ const stack = [];
+
+ // One pass over every outline tag and every , in document order, so
+ // the folder stack stays in step with the nesting.
+ const token = /]*)?>|<\/outline\s*>/gi;
+ let match;
+ while ((match = token.exec(source)) !== null) {
+ if (match[0][1] === "/") { stack.pop(); continue; }
+ const tag = match[0];
+ // Read the slash off the raw tag rather than capturing it: an attribute
+ // group greedy enough to hold `text="Tech" xmlUrl="…"` also swallows the
+ // trailing `/`, so every self-closing outline reads as a folder that never
+ // closes and the category stack grows without bound.
+ const selfClosing = /\/\s*>$/.test(tag);
+ const xmlUrl = safeUrl(attr(tag, "xmlUrl"));
+ const label = attr(tag, "title") || attr(tag, "text") || "";
+
+ if (!xmlUrl) {
+ // A folder. Self-closing folders enclose nothing, so they never nest.
+ if (!selfClosing) stack.push(label);
+ continue;
+ }
+ if (!seen.has(xmlUrl)) {
+ seen.add(xmlUrl);
+ feeds.push({
+ name: slugify(label) || hostSlug(xmlUrl),
+ title: label || hostOf(xmlUrl),
+ url: xmlUrl,
+ site: safeUrl(attr(tag, "htmlUrl")) || "",
+ category: stack.filter(Boolean).join("/"),
+ });
+ }
+ // A feed outline can still have children in the wild; keep the stack honest.
+ if (!selfClosing) stack.push(label);
+ }
+ return feeds;
+}
+
+/** Escape a string for an XML attribute. */
+function xmlAttr(value) {
+ return String(value ?? "")
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """);
+}
+
+/** Render a subscription list back to OPML, grouped by category. */
+export function buildOpml(feeds, { title = "moshcode news" } = {}) {
+ const groups = new Map();
+ for (const feed of feeds) {
+ const key = feed.category || "";
+ if (!groups.has(key)) groups.set(key, []);
+ groups.get(key).push(feed);
+ }
+ const outline = (feed, indent) =>
+ `${indent}`;
+
+ const lines = [
+ '',
+ '',
+ " ",
+ ` ${xmlAttr(title)}`,
+ " ",
+ " ",
+ ];
+ // Ungrouped feeds first, then folders — the order a reader displays them in.
+ for (const [category, rows] of [...groups].sort((a, b) => a[0].localeCompare(b[0]))) {
+ if (!category) { for (const feed of rows) lines.push(outline(feed, " ")); continue; }
+ lines.push(` `);
+ for (const feed of rows) lines.push(outline(feed, " "));
+ lines.push(" ");
+ }
+ lines.push(" ", "", "");
+ return lines.join("\n");
+}
+
+/** A stable, typeable short name for a feed. */
+export function slugify(label) {
+ return String(label ?? "")
+ .toLowerCase()
+ .replace(/['’]/g, "")
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "")
+ .slice(0, 32);
+}
+
+function hostOf(url) {
+ try { return new URL(url).hostname.replace(/^www\./, ""); }
+ catch { return String(url); }
+}
+
+function hostSlug(url) {
+ return slugify(hostOf(url).replace(/\.[a-z]{2,}$/i, "")) || "feed";
+}
+
+/** Make `name` unique against `taken` by suffixing -2, -3, … */
+function uniqueName(name, taken) {
+ if (!taken.has(name)) return name;
+ for (let i = 2; i < 1000; i++) {
+ const candidate = `${name}-${i}`;
+ if (!taken.has(candidate)) return candidate;
+ }
+ return `${name}-${taken.size}`;
+}
+
+// ---------------------------------------------------------------------------
+// The subscription store
+// ---------------------------------------------------------------------------
+
+/**
+ * The subscribed feeds.
+ *
+ * A missing or unreadable file reads as "no subscriptions" rather than
+ * throwing, the way loadAliases() does: this runs on the way into a command
+ * that should still be able to tell you how to fix it.
+ */
+export function loadFeeds(env = process.env) {
+ let raw;
+ try { raw = fs.readFileSync(opmlFile(env), "utf8"); }
+ catch { return []; }
+ try { return parseOpml(raw); }
+ catch { return []; }
+}
+
+/**
+ * The feeds to read, and whether they are the operator's own.
+ *
+ * An empty reader is a useless one — `/news` on a fresh install should print
+ * the news, not instructions for how to earn the news. So with no
+ * subscriptions the defaults stand in, and the flag comes back with them so
+ * the UI can say which it is showing rather than quietly implying the operator
+ * subscribed to thirteen feeds they have never seen.
+ */
+export function readingList(env = process.env) {
+ const subscribed = loadFeeds(env);
+ if (subscribed.length) return { feeds: subscribed, usingDefaults: false };
+ return { feeds: defaultFeeds(), usingDefaults: true };
+}
+
+/** Write the list back, creating ~/.moshcode if this is the first feed. */
+export function saveFeeds(feeds, env = process.env) {
+ const file = opmlFile(env);
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
+ fs.writeFileSync(file, buildOpml(feeds), { mode: FILE_MODE });
+ // `mode` only applies at creation, so tighten every write — aliases.mjs and
+ // the history file do the same for the same reason.
+ try { fs.chmodSync(file, FILE_MODE); } catch { /* best effort */ }
+}
+
+/** Add one feed to a list, naming it uniquely. Returns { feeds, added, existed }. */
+export function withFeed(feeds, candidate) {
+ const existing = feeds.find((f) => f.url === candidate.url);
+ if (existing) return { feeds, added: existing, existed: true };
+ const taken = new Set(feeds.map((f) => f.name));
+ const added = { ...candidate, name: uniqueName(candidate.name || hostSlug(candidate.url), taken) };
+ return { feeds: [...feeds, added], added, existed: false };
+}
+
+/** Find a feed by name, url, or title. */
+export function findFeed(feeds, needle) {
+ const wanted = String(needle ?? "").trim().toLowerCase();
+ if (!wanted) return null;
+ return feeds.find((f) => f.name.toLowerCase() === wanted)
+ ?? feeds.find((f) => f.url.toLowerCase() === wanted)
+ ?? feeds.find((f) => (f.title || "").toLowerCase() === wanted)
+ ?? null;
+}
+
+// ---------------------------------------------------------------------------
+// Feeds — RSS 2.0, Atom, and RSS 1.0/RDF
+// ---------------------------------------------------------------------------
+
+/** Is this document a subscription list rather than a feed? */
+export function looksLikeOpml(xml) {
+ return /]/i.test(scrub(xml));
+}
+
+/**
+ * The link for an entry.
+ *
+ * Atom puts it in an attribute and may carry several: `rel="alternate"` (or no
+ * rel at all, which means alternate) is the human-readable page, while
+ * `rel="self"`, `"replies"` and `"enclosure"` are not what a reader should
+ * open. RSS puts it in element text, and some publishers only fill a permalink
+ * `` — hence the third fallback.
+ */
+function linkOf(block, base) {
+ const links = tagsNamed(block, "(?:[a-z0-9]+\\:)?link");
+ const alternate = links.find((tag) => {
+ const rel = attr(tag, "rel").toLowerCase();
+ return (!rel || rel === "alternate") && attr(tag, "href");
+ });
+ if (alternate) return safeUrl(attr(alternate, "href"), base);
+
+ const inline = pick(block, "link");
+ if (inline) return safeUrl(inline, base);
+
+ const guid = /<(?:[a-z0-9]+:)?guid(\s[^>]*)?>([\s\S]*?)<\/(?:[a-z0-9]+:)?guid>/i.exec(block);
+ if (guid && !/isPermaLink\s*=\s*["']false["']/i.test(guid[1] || "")) {
+ return safeUrl(text(guid[2]), base);
+ }
+ return null;
+}
+
+/** The publication time of an entry as epoch ms, or null when it has none. */
+function dateOf(block) {
+ for (const tag of ["pubDate", "published", "updated", "dc:date", "date", "created"]) {
+ const raw = pick(block, tag);
+ if (!raw) continue;
+ const ms = Date.parse(raw);
+ if (Number.isFinite(ms)) return ms;
+ }
+ return null;
+}
+
+/**
+ * Parse an RSS/Atom/RDF document into { title, site, items }.
+ *
+ * One code path for all three because, at the level a headline list needs, they
+ * genuinely are the same document: a channel with a title and a list of dated,
+ * linked entries. Where they disagree — the entry element name, where the link
+ * lives, which tag holds the date — the difference is handled at that field
+ * rather than by forking the whole parser.
+ */
+export function parseFeed(xml, { url = "" } = {}) {
+ const doc = scrub(xml);
+ const blocks = doc.match(/<(?:[a-z0-9]+:)?(?:item|entry)(?:\s[^>]*)?>[\s\S]*?<\/(?:[a-z0-9]+:)?(?:item|entry)>/gi) || [];
+
+ // The channel header is whatever precedes the first entry — taking the title
+ // from the whole document would pick up an entry's title on a feed whose
+ // channel has none.
+ const head = blocks.length ? doc.slice(0, doc.indexOf(blocks[0])) : doc;
+ const feedTitle = pick(head, "title") || hostOf(url);
+ const site = linkOf(head, url) || "";
+
+ const items = [];
+ for (const block of blocks) {
+ const title = pick(block, "title");
+ const link = linkOf(block, url);
+ if (!title && !link) continue; // nothing to show and nothing to open
+ items.push({
+ title: title || link,
+ link,
+ date: dateOf(block),
+ author: pick(block, "creator") || pick(block, "author") || "",
+ summary: clip(pick(block, "description") || pick(block, "summary") || "", 400),
+ });
+ }
+ return { title: feedTitle, site, url, items };
+}
+
+function clip(value, max) {
+ const s = String(value ?? "");
+ return s.length > max ? `${s.slice(0, max - 1)}…` : s;
+}
+
+// ---------------------------------------------------------------------------
+// Fetching
+// ---------------------------------------------------------------------------
+
+/**
+ * Fetch one document. Returns { ok, body, error }.
+ *
+ * Size-capped while streaming rather than after: a feed that turns out to be a
+ * disk image should cost a few megabytes of transfer, not all of it.
+ */
+export async function fetchDocument(url, { fetchImpl, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
+ const impl = fetchImpl || globalThis.fetch;
+ if (typeof impl !== "function") return { ok: false, error: "no fetch available in this runtime" };
+ const safe = safeUrl(url);
+ if (!safe) return { ok: false, error: `not an http(s) URL: ${url}` };
+
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
+ try {
+ const res = await impl(safe, {
+ signal: controller.signal,
+ redirect: "follow",
+ headers: {
+ accept: "application/rss+xml, application/atom+xml, application/xml, text/xml, */*;q=0.8",
+ "user-agent": "moshcode/news (+https://moshcode.sh)",
+ },
+ });
+ if (!res.ok) return { ok: false, status: res.status, error: `${res.status} ${res.statusText || ""}`.trim() };
+
+ // Prefer the stream so the cap can stop a huge body early; fall back to
+ // text() for any fetch implementation (tests included) that has no body.
+ if (!res.body || typeof res.body.getReader !== "function") {
+ const body = await res.text();
+ if (body.length > MAX_BYTES) return { ok: false, error: `feed is larger than ${MAX_BYTES} bytes` };
+ return { ok: true, body };
+ }
+ const reader = res.body.getReader();
+ const decoder = new TextDecoder("utf-8");
+ let body = "";
+ let bytes = 0;
+ for (;;) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ bytes += value.byteLength;
+ if (bytes > MAX_BYTES) {
+ try { await reader.cancel(); } catch { /* already gone */ }
+ return { ok: false, error: `feed is larger than ${MAX_BYTES} bytes` };
+ }
+ body += decoder.decode(value, { stream: true });
+ }
+ body += decoder.decode();
+ return { ok: true, body };
+ } catch (e) {
+ const aborted = e?.name === "AbortError";
+ return { ok: false, error: aborted ? `timed out after ${Math.round(timeoutMs / 1000)}s` : String(e?.message || e) };
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
+/** Read a document from a local path, or over the network when it is a URL. */
+export async function readSource(source, opts = {}) {
+ const asUrl = safeUrl(source);
+ if (asUrl) return fetchDocument(asUrl, opts);
+ try { return { ok: true, body: fs.readFileSync(path.resolve(source), "utf8"), local: true }; }
+ catch (e) { return { ok: false, error: `can't read ${source}: ${e.message}` }; }
+}
+
+/** Run `worker` over `items` with a bounded number in flight. Order is preserved. */
+async function mapLimit(items, limit, worker) {
+ const out = new Array(items.length);
+ let cursor = 0;
+ const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
+ for (;;) {
+ const index = cursor++;
+ if (index >= items.length) return;
+ out[index] = await worker(items[index], index);
+ }
+ });
+ await Promise.all(runners);
+ return out;
+}
+
+/**
+ * Fetch every feed and merge them into one dated list.
+ *
+ * A feed that fails is reported, not fatal: a reader whose whole listing
+ * disappears because one publisher is having an outage is not a reader. The
+ * failures come back alongside the items so the caller can say which.
+ */
+export async function collectNews(feeds, { fetchImpl, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
+ const results = await mapLimit(feeds, CONCURRENCY, async (feed) => {
+ const res = await fetchDocument(feed.url, { fetchImpl, timeoutMs });
+ if (!res.ok) return { feed, error: res.error };
+ let parsed;
+ try { parsed = parseFeed(res.body, { url: feed.url }); }
+ catch (e) { return { feed, error: `unreadable feed (${e.message})` }; }
+ return { feed, parsed };
+ });
+
+ const items = [];
+ const failures = [];
+ const seen = new Set();
+ for (const result of results) {
+ if (result.error) { failures.push({ name: result.feed.name, url: result.feed.url, error: result.error }); continue; }
+ for (const item of result.parsed.items) {
+ // Aggregator feeds wrap the publisher's URL in one of their own. Unwrap
+ // before deduping, so the same story arriving via Google News and via the
+ // publisher's own feed is recognised as one story rather than two.
+ const link = item.link ? unwrapRedirect(item.link) : null;
+ const key = link || `${result.feed.name}:${item.title}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+ items.push({ ...item, link, feed: result.feed.name, feedTitle: result.parsed.title || result.feed.title });
+ }
+ }
+ // Newest first, and undated entries last rather than pretending they are old:
+ // plenty of feeds omit dates entirely, and sorting them to the bottom keeps
+ // them reachable without letting them claim the top of the list.
+ items.sort((a, b) => (b.date ?? -Infinity) - (a.date ?? -Infinity));
+ return { items, failures };
+}
+
+/**
+ * The feeds a keyword search reads.
+ *
+ * Two engines rather than one, which is advis0r's reasoning carried over
+ * verbatim: Google has the better index, but its RSS links are interstitials
+ * that a reader cannot open into an article, while Bing wraps the real
+ * publisher URL in a `url=` parameter that unwrapRedirect decodes. Querying
+ * both and deduping on the unwrapped link gets Google's coverage with Bing's
+ * openable links wherever the two overlap.
+ */
+export function searchFeeds(query) {
+ return [
+ { name: "google", title: `Google News — ${query}`, url: googleNewsSearch(query), site: "", category: "" },
+ { name: "bing", title: `Bing News — ${query}`, url: bingNewsSearch(query), site: "", category: "" },
+ ];
+}
+
+// ---------------------------------------------------------------------------
+// Arguments
+// ---------------------------------------------------------------------------
+
+function takeFlag(args, name, { boolean = false } = {}) {
+ const out = { value: null, rest: [], missing: false, present: false };
+ for (let i = 0; i < args.length; i++) {
+ const arg = String(args[i]);
+ if (arg === name) {
+ out.present = true;
+ if (boolean) continue;
+ const next = args[i + 1];
+ if (next == null || String(next).startsWith("-")) out.missing = true;
+ else { out.value = String(next); i++; }
+ continue;
+ }
+ if (!boolean && arg.startsWith(`${name}=`)) {
+ out.present = true;
+ const value = arg.slice(name.length + 1);
+ if (value === "") out.missing = true; else out.value = value;
+ continue;
+ }
+ out.rest.push(arg);
+ }
+ return out;
+}
+
+/**
+ * Translate argv into a request. Pure — no network, no filesystem.
+ *
+ * Returns { verb, target, limit, feed, json, timeoutMs } or { error } / { usage }.
+ */
+export function newsArgs(argv = []) {
+ const args = (Array.isArray(argv) ? argv : []).map(String);
+ if (args.includes("--help") || args.includes("-h") || args.includes("help")) return { usage: true };
+
+ const json = takeFlag(args, "--json", { boolean: true });
+ const limitFlag = takeFlag(json.rest, "--limit");
+ if (limitFlag.missing) return { error: "--limit needs a number" };
+ const feedFlag = takeFlag(limitFlag.rest, "--feed");
+ if (feedFlag.missing) return { error: "--feed needs a feed name" };
+ const timeoutFlag = takeFlag(feedFlag.rest, "--timeout");
+ if (timeoutFlag.missing) return { error: "--timeout needs a number of seconds" };
+
+ let limit = DEFAULT_LIMIT;
+ if (limitFlag.value != null) {
+ const n = Number(limitFlag.value);
+ if (!Number.isInteger(n) || n < 1) return { error: `--limit takes a whole number of headlines, got ${JSON.stringify(limitFlag.value)}` };
+ limit = Math.min(n, MAX_LIMIT);
+ }
+
+ let timeoutMs = DEFAULT_TIMEOUT_MS;
+ if (timeoutFlag.value != null) {
+ const secs = Number(timeoutFlag.value);
+ if (!Number.isFinite(secs) || secs <= 0 || secs > 120) return { error: `--timeout takes 1-120 seconds, got ${JSON.stringify(timeoutFlag.value)}` };
+ timeoutMs = Math.round(secs * 1000);
+ }
+
+ const rest = timeoutFlag.rest.filter((a) => a !== "");
+ const unknown = rest.find((a) => a.startsWith("--"));
+ if (unknown) return { error: `unknown flag ${unknown}` };
+
+ const base = { limit, feed: feedFlag.value, json: json.present, timeoutMs };
+ const [first, ...tail] = rest;
+
+ // No argument at all: the headlines. This is the common case and it is why
+ // `/news` is worth having as one word.
+ if (!first) return { ...base, verb: "headlines", target: null };
+
+ const verb = resolveVerb(first);
+ if (!verb) {
+ // Not a verb. Two things it can still be, and the URL check decides which:
+ // a feed to read directly — the "or rss link" half of the feature — or a
+ // keyword to search for. Everything that is not a URL is a keyword, so
+ // `/news tariffs` and `/news openai earnings` both work and neither needs
+ // a verb in front of it. That does mean a mistyped verb searches for the
+ // typo instead of erroring, which is the right trade: `/news lst` finding
+ // nothing is recoverable, and refusing every unrecognised word would make
+ // the headline search unreachable without ceremony.
+ const url = safeUrl(first);
+ if (url) {
+ if (tail.length) return { error: "reading one feed takes a single URL" };
+ return { ...base, verb: "headlines", target: url, oneOff: true };
+ }
+ return { ...base, verb: "search", query: [first, ...tail].join(" ") };
+ }
+
+ if (verb === "latest") {
+ if (tail.length) return { error: "latest takes no arguments — /news to search" };
+ return { ...base, verb: "headlines", target: null };
+ }
+ if (verb === "search") {
+ const query = tail.join(" ").trim();
+ if (!query) return { error: "usage: moshcode news search " };
+ return { ...base, verb, query };
+ }
+ if (verb === "add") {
+ if (!tail.length) return { error: "usage: moshcode news add " };
+ if (tail.length > 1) return { error: "add takes one URL or file at a time" };
+ return { ...base, verb, target: tail[0] };
+ }
+ if (verb === "rm") {
+ if (!tail.length) return { error: "usage: moshcode news rm " };
+ return { ...base, verb, target: tail.join(" ") };
+ }
+ if (verb === "open") {
+ if (tail.length !== 1) return { error: "usage: moshcode news open " };
+ const n = Number(tail[0]);
+ if (!Number.isInteger(n) || n < 1) return { error: `open takes a headline number, got ${JSON.stringify(tail[0])}` };
+ return { ...base, verb, index: n };
+ }
+ if (tail.length) return { error: `${verb} takes no arguments` };
+ return { ...base, verb, target: null };
+}
+
+// ---------------------------------------------------------------------------
+// Rendering
+// ---------------------------------------------------------------------------
+
+/** "3h ago" — how long before `now` something was published. */
+export function ago(ms, now = Date.now()) {
+ if (!Number.isFinite(ms)) return "";
+ const secs = Math.round((now - ms) / 1000);
+ if (secs < 0) return "just now";
+ if (secs < 90) return `${secs}s ago`;
+ const mins = Math.round(secs / 60);
+ if (mins < 90) return `${mins}m ago`;
+ const hours = Math.round(mins / 60);
+ if (hours < 36) return `${hours}h ago`;
+ const days = Math.round(hours / 24);
+ if (days < 14) return `${days}d ago`;
+ const weeks = Math.round(days / 7);
+ if (weeks < 9) return `${weeks}w ago`;
+ return `${Math.round(days / 30)}mo ago`;
+}
+
+/** The headline list. */
+export function renderHeadlines(items, { failures = [], columns, limit = DEFAULT_LIMIT, now = Date.now(), source = "" } = {}) {
+ const width = Math.max(48, Math.min(Number(columns) || 88, 100));
+ const shown = items.slice(0, limit);
+ if (!shown.length) {
+ const lines = ["", ` ${ash("nothing came back")}`];
+ if (failures.length) lines.push("", ...failureLines(failures));
+ else lines.push("", ` ${ash("subscribe with")} ${bone("/news add ")}`);
+ return lines.join("\n");
+ }
+
+ // The widest index, so "9." and "10." line their titles up.
+ const gutter = String(shown.length).length + 1;
+ const tails = shown.map((item) => {
+ const when = ago(item.date, now);
+ return `${item.feed || ""}${when ? ` · ${when}` : ""}`.trim();
+ });
+ // One column width for every tail, not one per row: the feed name and the age
+ // are what make a headline placeable, so they keep their room and the title
+ // is what gives — and they line up, which is the whole point of a column.
+ const tailWidth = Math.max(0, ...tails.map((t) => t.length));
+ const room = Math.max(24, width - gutter - tailWidth - 4);
+
+ const lines = ["", ` ${ash(source || `${items.length} headline${items.length === 1 ? "" : "s"}`)}`, ""];
+ for (const [i, item] of shown.entries()) {
+ const n = `${i + 1}.`.padStart(gutter);
+ lines.push(` ${acid(n)} ${bone(clip(item.title, room).padEnd(room))} ${ash(tails[i].padStart(tailWidth))}`);
+ }
+ lines.push("", ` ${ash("open one with")} ${bone("/news open ")}`);
+ if (failures.length) lines.push("", ...failureLines(failures));
+ return lines.join("\n");
+}
+
+function failureLines(failures) {
+ return [
+ ` ${amber(`${failures.length} feed${failures.length === 1 ? "" : "s"} didn't answer`)}`,
+ ...failures.map((f) => ` ${ash(`${f.name} — ${f.error}`)}`),
+ ];
+}
+
+/** The subscription list. */
+export function renderFeeds(feeds, { file = "", usingDefaults = false } = {}) {
+ if (!feeds.length) {
+ return ["", ` ${ash("no feeds yet")}`, "",
+ ` ${ash("add one:")} ${bone("/news add https://example.com/feed.xml")}`,
+ ` ${ash("or a list:")} ${bone("/news add ~/subscriptions.opml")}`,
+ ` ${ash("or a bundle:")}${bone(" /news add journalists")}`].join("\n");
+ }
+ const width = Math.max(...feeds.map((f) => f.name.length));
+ const header = usingDefaults
+ ? `${feeds.length} default feeds · nothing subscribed yet`
+ : `${feeds.length} feed${feeds.length === 1 ? "" : "s"}${file ? ` · ${file}` : ""}`;
+ const lines = ["", ` ${ash(header)}`, ""];
+ let category = null;
+ for (const feed of feeds) {
+ if ((feed.category || "") !== category) {
+ category = feed.category || "";
+ if (category) lines.push(` ${ash(category)}`);
+ }
+ lines.push(` ${acid(feed.name.padEnd(width))} ${bone(clip(feed.title || "", 34).padEnd(36))}${ash(feed.url)}`);
+ }
+ lines.push("", usingDefaults
+ ? ` ${ash("subscribe to your own with")} ${bone("/news add ")} ${ash("· see them with")} ${bone("/news sources")}`
+ : ` ${ash("read them with")} ${bone("/news")} ${ash("· one of them with")} ${bone("/news --feed ")}`);
+ return lines.join("\n");
+}
+
+/** What a fresh install reads, and the lists it can pull in by name. */
+export function renderSources() {
+ const defaults = defaultFeeds();
+ const width = Math.max(...defaults.map((f) => f.name.length));
+ const lines = ["", ` ${bone("defaults")} ${ash(`— read when nothing is subscribed (${defaults.length})`)}`, ""];
+ let category = null;
+ for (const feed of defaults) {
+ if ((feed.category || "") !== category) {
+ category = feed.category || "";
+ if (category) lines.push(` ${ash(category)}`);
+ }
+ lines.push(` ${acid(feed.name.padEnd(width))} ${ash(clip(feed.title || "", 44))}`);
+ }
+ lines.push("", ` ${bone("bundles")} ${ash("— public OPML lists, pull one in by name")}`, "");
+ const bw = Math.max(...OPML_BUNDLES.map((b) => b.name.length));
+ for (const bundle of OPML_BUNDLES) {
+ lines.push(` ${acid(bundle.name.padEnd(bw))} ${ash(bundle.description)}`);
+ }
+ lines.push("", ` ${ash("pull one in with")} ${bone(`/news add ${OPML_BUNDLES[0].name}`)}`);
+ return lines.join("\n");
+}
+
+// ---------------------------------------------------------------------------
+// The command
+// ---------------------------------------------------------------------------
+
+/** Remember what the numbers in the last listing pointed at. Best effort. */
+function rememberListing(items, env) {
+ try {
+ const file = cacheFile(env);
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
+ const rows = items.map(({ title, link, feed, date }) => ({ title, link, feed, date }));
+ fs.writeFileSync(file, `${JSON.stringify({ at: Date.now(), items: rows }, null, 2)}\n`, { mode: FILE_MODE });
+ try { fs.chmodSync(file, FILE_MODE); } catch { /* best effort */ }
+ } catch { /* a cache that cannot be written must not fail the listing */ }
+}
+
+function readListing(env) {
+ try {
+ const parsed = JSON.parse(fs.readFileSync(cacheFile(env), "utf8"));
+ return Array.isArray(parsed?.items) ? parsed.items : [];
+ } catch { return []; }
+}
+
+/**
+ * Run a `news` invocation end to end. Returns a process exit code.
+ *
+ * `deps` exists so tests drive the whole command — parse, fetch, render — with
+ * no network and no stdout, the way cryptoCommand's does.
+ */
+export async function newsCommand(argv = [], deps = {}) {
+ const {
+ out = (s) => console.log(s),
+ fail = (s) => console.error(s),
+ fetchImpl,
+ openUrl,
+ columns = process.stdout.columns,
+ env = process.env,
+ now = Date.now(),
+ } = deps;
+
+ const request = newsArgs(argv);
+ if (request.usage) { out(newsUsage()); return 0; }
+ if (request.error) { fail(danger(`✗ ${request.error}`)); return 1; }
+
+ if (request.verb === "list") {
+ const { feeds, usingDefaults } = readingList(env);
+ if (request.json) { out(JSON.stringify({ file: opmlFile(env), usingDefaults, feeds }, null, 2)); return 0; }
+ out(renderFeeds(feeds, { file: opmlFile(env), usingDefaults }));
+ return 0;
+ }
+
+ if (request.verb === "sources") {
+ if (request.json) { out(JSON.stringify({ defaults: defaultFeeds(), bundles: OPML_BUNDLES }, null, 2)); return 0; }
+ out(renderSources());
+ return 0;
+ }
+
+ if (request.verb === "export") {
+ // The defaults deliberately: exporting an empty file to hand to a reader is
+ // not what anyone means by "export my feeds" on a fresh install.
+ out(buildOpml(readingList(env).feeds).trimEnd());
+ return 0;
+ }
+
+ if (request.verb === "add") return addCommand(request, { out, fail, fetchImpl, env });
+ if (request.verb === "rm") return removeCommand(request, { out, fail, env });
+
+ if (request.verb === "open") {
+ const items = readListing(env);
+ if (!items.length) { fail(danger("✗ nothing to open — run `/news` first")); return 1; }
+ const item = items[request.index - 1];
+ if (!item) { fail(danger(`✗ there is no headline ${request.index} — the last listing had ${items.length}`)); return 1; }
+ if (!item.link) { fail(danger(`✗ "${clip(item.title, 60)}" has no link`)); return 1; }
+ if (request.json) { out(JSON.stringify(item, null, 2)); return 0; }
+ const opened = openUrl ? openUrl(item.link) : false;
+ out(opened
+ ? `${acid("✓ ")}opened ${bone(clip(item.title, 60))}`
+ : `${ash("· ")}open this in a browser:\n ${acid(item.link)}`);
+ return 0;
+ }
+
+ // Headlines — a keyword search, one URL passed straight in, or the reading list.
+ let feeds;
+ let source;
+ if (request.verb === "search") {
+ feeds = searchFeeds(request.query);
+ source = `“${request.query}”`;
+ } else if (request.oneOff) {
+ feeds = [{ name: hostSlug(request.target), title: hostOf(request.target), url: request.target, site: "", category: "" }];
+ source = hostOf(request.target);
+ } else {
+ const list = readingList(env);
+ feeds = list.feeds;
+ if (list.usingDefaults) source = "default feeds";
+ if (request.feed) {
+ const one = findFeed(feeds, request.feed);
+ if (!one) {
+ fail(danger(`✗ no feed named "${request.feed}"`));
+ if (feeds.length) fail(` ${ash("try:")} ${bone(feeds.map((f) => f.name).slice(0, 8).join(", "))}`);
+ return 1;
+ }
+ feeds = [one];
+ source = one.title || one.name;
+ }
+ }
+
+ const { items, failures } = await collectNews(feeds, { fetchImpl, timeoutMs: request.timeoutMs });
+ const shown = items.slice(0, request.limit);
+
+ if (request.json) {
+ out(JSON.stringify({ items: shown, failures, feeds: feeds.length }, null, 2));
+ } else {
+ out(renderHeadlines(items, {
+ failures,
+ columns,
+ limit: request.limit,
+ now,
+ source: source ? `${source} · ${items.length} headline${items.length === 1 ? "" : "s"}` : "",
+ }));
+ }
+ // Cached even for --json: the numbers a script just read are the numbers
+ // `/news open ` should resolve.
+ if (shown.length) rememberListing(shown, env);
+ // Every feed failing is a failed command, not an empty one — a script that
+ // branches on the exit status should not read a total outage as "no news".
+ return items.length === 0 && failures.length === feeds.length ? 1 : 0;
+}
+
+/** `news add ` — one feed, or every feed in an OPML list. */
+async function addCommand(request, { out, fail, fetchImpl, env }) {
+ // A bundle name resolves to somebody else's OPML list. Checked before the URL
+ // and the path so `journalists` is a name rather than a missing file.
+ const bundle = resolveBundle(request.target);
+ const target = bundle ? bundle.url : request.target;
+ if (bundle) out(`${ash("· ")}fetching ${bone(bundle.name)} ${ash(`— ${bundle.description}`)}`);
+
+ const res = await readSource(target, { fetchImpl, timeoutMs: request.timeoutMs });
+ if (!res.ok) { fail(danger(`✗ ${res.error}`)); return 1; }
+
+ // Subscribing for the first time replaces the defaults rather than merging
+ // with them: the defaults are a stand-in, and silently welding thirteen feeds
+ // onto the first one somebody chooses is not what `add` means.
+ const existing = loadFeeds(env);
+ const asUrl = safeUrl(target);
+
+ if (looksLikeOpml(res.body)) {
+ const incoming = parseOpml(res.body);
+ if (!incoming.length) { fail(danger("✗ that OPML file lists no feeds")); return 1; }
+ let feeds = existing;
+ const added = [];
+ let skipped = 0;
+ for (const feed of incoming) {
+ const result = withFeed(feeds, feed);
+ feeds = result.feeds;
+ if (result.existed) skipped++; else added.push(result.added);
+ }
+ if (!added.length) {
+ out(`${ash("· ")}already subscribed to all ${incoming.length} feed${incoming.length === 1 ? "" : "s"} in that list`);
+ return 0;
+ }
+ try { saveFeeds(feeds, env); }
+ catch (e) { fail(danger(`✗ can't write ${opmlFile(env)}: ${e.message}`)); return 1; }
+ if (request.json) { out(JSON.stringify({ added, skipped }, null, 2)); return 0; }
+ out(`${acid("✓ ")}subscribed to ${bone(String(added.length))} feed${added.length === 1 ? "" : "s"}${skipped ? ash(` (${skipped} already there)`) : ""}`);
+ for (const feed of added.slice(0, 10)) out(` ${acid(feed.name.padEnd(18))}${ash(clip(feed.title || feed.url, 56))}`);
+ if (added.length > 10) out(` ${ash(`…and ${added.length - 10} more — /news list`)}`);
+ return 0;
+ }
+
+ // A single feed. It has to be a URL: parsing a local file would subscribe to
+ // a path that only exists on this machine and would never refresh.
+ if (!asUrl) {
+ fail(danger(`✗ ${target} is a feed, not an OPML list — subscribe to it by URL so it can refresh`));
+ return 1;
+ }
+ let parsed;
+ try { parsed = parseFeed(res.body, { url: asUrl }); }
+ catch (e) { fail(danger(`✗ can't read that feed (${e.message})`)); return 1; }
+ if (!parsed.items.length && !parsed.title) {
+ fail(danger(`✗ ${asUrl} doesn't look like an RSS, Atom, or OPML document`));
+ return 1;
+ }
+
+ const candidate = {
+ name: slugify(parsed.title) || hostSlug(asUrl),
+ title: parsed.title || hostOf(asUrl),
+ url: asUrl,
+ site: parsed.site || "",
+ category: "",
+ };
+ const { feeds, added, existed } = withFeed(existing, candidate);
+ if (existed) {
+ out(`${ash("· ")}already subscribed to ${bone(added.name)} ${ash(added.url)}`);
+ return 0;
+ }
+ try { saveFeeds(feeds, env); }
+ catch (e) { fail(danger(`✗ can't write ${opmlFile(env)}: ${e.message}`)); return 1; }
+ if (request.json) { out(JSON.stringify({ added, skipped: 0 }, null, 2)); return 0; }
+ out(`${acid("✓ ")}subscribed to ${bone(added.name)} ${ash(`— ${added.title}`)}`);
+ out(` ${ash(`${parsed.items.length} item${parsed.items.length === 1 ? "" : "s"} right now · read them with`)} ${bone(`/news --feed ${added.name}`)}`);
+ return 0;
+}
+
+/** `news rm ` — unsubscribe. */
+function removeCommand(request, { out, fail, env }) {
+ const feeds = loadFeeds(env);
+ const feed = findFeed(feeds, request.target);
+ if (!feed) {
+ fail(danger(`✗ no feed named "${request.target}"`));
+ if (feeds.length) fail(` ${ash("try:")} ${bone(feeds.map((f) => f.name).slice(0, 8).join(", "))}`);
+ return 1;
+ }
+ try { saveFeeds(feeds.filter((f) => f !== feed), env); }
+ catch (e) { fail(danger(`✗ can't write ${opmlFile(env)}: ${e.message}`)); return 1; }
+ if (request.json) { out(JSON.stringify({ removed: feed }, null, 2)); return 0; }
+ out(`${acid("✓ ")}unsubscribed from ${bone(feed.name)} ${ash(feed.url)}`);
+ return 0;
+}
diff --git a/src/rss-ui.mjs b/src/rss-ui.mjs
new file mode 100644
index 0000000..abe6c56
--- /dev/null
+++ b/src/rss-ui.mjs
@@ -0,0 +1,517 @@
+// `moshcode rss` — the headlines as a place you can sit in.
+//
+// `/news` prints a list and gives the terminal back, which is the right shape
+// for "what happened" and the wrong one for reading. This is the same data as
+// somewhere you point at: feeds down the left, headlines in the middle, the
+// story itself in place of the list when you pick one.
+//
+// The same terminal discipline as src/herd-ui.mjs, and for the same reasons:
+// no dependencies, alternate screen and SGR mouse reporting written by hand,
+// and every escape sequence undone in a single restore path so a crash cannot
+// leave a terminal with no cursor and the mouse still captured.
+//
+// Rendering is a pure function of state (`renderReader`), so a frame can be
+// asserted in a test without a tty, a fetch, or a keystroke.
+import { parseMouse } from "./herd-ui.mjs";
+import { acid, amber, ash, bone, danger, dim } from "./ui.mjs";
+import {
+ ago,
+ collectNews,
+ findFeed,
+ readingList,
+ searchFeeds,
+} from "./news.mjs";
+
+const ESC = {
+ altOn: "\x1b[?1049h", altOff: "\x1b[?1049l",
+ hideCursor: "\x1b[?25l", showCursor: "\x1b[?25h",
+ mouseOn: "\x1b[?1000h\x1b[?1006h", mouseOff: "\x1b[?1006l\x1b[?1000l",
+ clear: "\x1b[2J\x1b[H",
+};
+
+/** Columns given to the feed sidebar, and the fixed chrome around the list. */
+const SIDEBAR = 20;
+const HEADER_LINES = 2; // title + rule
+const FOOTER_LINES = 2; // rule + keys
+
+/** Printable width, ignoring the SGR sequences ui.mjs wraps text in. */
+export function visibleWidth(text) {
+ return String(text ?? "").replace(/\x1b\[[0-9;]*m/g, "").length;
+}
+
+/** Pad to `width` printable columns, colour codes not counted. */
+function pad(text, width) {
+ const short = width - visibleWidth(text);
+ return short > 0 ? text + " ".repeat(short) : text;
+}
+
+/** Truncate to `width` printable columns. Only ever called on uncoloured text. */
+function clip(text, width) {
+ const s = String(text ?? "");
+ return s.length > width ? `${s.slice(0, Math.max(0, width - 1))}…` : s;
+}
+
+/**
+ * Break `text` into lines of at most `width`, on word boundaries where it can.
+ *
+ * A word longer than the pane — which in practice means a URL — is split across
+ * lines rather than truncated. It has to be split somehow: left whole it wraps
+ * the terminal itself and every line below it lands one row low, which is the
+ * one failure that tears the whole frame. Splitting rather than cutting because
+ * the over-long word is usually the link, and half a link is not a link.
+ */
+export function wrap(text, width) {
+ const columns = Math.max(1, Math.floor(width));
+ const words = String(text ?? "").split(/\s+/).filter(Boolean);
+ const lines = [];
+ let line = "";
+ const flush = () => { if (line) { lines.push(line); line = ""; } };
+
+ for (const word of words) {
+ if (word.length > columns) {
+ flush();
+ for (let i = 0; i < word.length; i += columns) lines.push(word.slice(i, i + columns));
+ continue;
+ }
+ if (!line) { line = word; continue; }
+ if (line.length + 1 + word.length <= columns) { line += ` ${word}`; continue; }
+ flush();
+ line = word;
+ }
+ flush();
+ return lines;
+}
+
+/**
+ * Decode a chunk of raw-mode input.
+ *
+ * herd-ui's parseInput is deliberately narrow — it answers a nine-key screen —
+ * so this reads the keys a reader needs instead of widening that one and
+ * changing what the herd list responds to. Mouse reports are shared, because
+ * SGR decoding has exactly one correct answer.
+ */
+export function decodeKeys(buffer) {
+ const events = [];
+ // Mouse reports are removed from the text, not merely read out of it. A
+ // release (`…m`) decodes to no event at all, so leaving the sequence behind
+ // would hand `[ < 0 ; 1 0 ; 5 m` to the key decoder below — which, with the
+ // search box open, types the mouse position into the query.
+ const text = String(buffer).replace(/\x1b\[<\d+;\d+;\d+[Mm]/g, (sequence) => {
+ const parsed = parseMouse(sequence);
+ if (parsed) events.push(parsed);
+ return "";
+ });
+ if (!text) return events;
+
+ // Escape sequences first, longest first, so ESC [ A is an arrow rather than
+ // an escape followed by two letters.
+ const SEQUENCES = [
+ ["\x1b[A", "up"], ["\x1b[B", "down"], ["\x1b[C", "right"], ["\x1b[D", "left"],
+ ["\x1b[5~", "pageup"], ["\x1b[6~", "pagedown"],
+ ["\x1b[H", "home"], ["\x1b[F", "end"],
+ ];
+ let i = 0;
+ while (i < text.length) {
+ const seq = SEQUENCES.find(([code]) => text.startsWith(code, i));
+ if (seq) { events.push({ kind: "key", name: seq[1] }); i += seq[0].length; continue; }
+ const ch = text[i];
+ if (ch === "\x1b") { events.push({ kind: "key", name: "escape" }); i += 1; continue; }
+ if (ch === "\r" || ch === "\n") { events.push({ kind: "key", name: "enter" }); i += 1; continue; }
+ if (ch === "\x7f" || ch === "\b") { events.push({ kind: "key", name: "backspace" }); i += 1; continue; }
+ if (ch === "\x03") { events.push({ kind: "key", name: "ctrl-c" }); i += 1; continue; }
+ events.push({ kind: "key", name: ch, char: ch });
+ i += 1;
+ }
+ return events;
+}
+
+/** Feeds down the left, with a count each and an "all" row on top. */
+export function sidebarRows(state) {
+ const counts = new Map();
+ for (const item of state.items) counts.set(item.feed, (counts.get(item.feed) || 0) + 1);
+ const rows = [{ key: null, label: "all", count: state.items.length }];
+ for (const feed of state.feeds) {
+ rows.push({ key: feed.name, label: feed.name, count: counts.get(feed.name) || 0 });
+ }
+ return rows;
+}
+
+/** The headlines currently on show — everything, or one feed's. */
+export function visibleItems(state) {
+ return state.filter ? state.items.filter((i) => i.feed === state.filter) : state.items;
+}
+
+/**
+ * One frame, as an array of exactly `rows` lines.
+ *
+ * Exactly, not at most: the screen is repainted by clearing and writing, so a
+ * short frame leaves the tail of the previous one on screen and a long one
+ * scrolls the terminal and tears the whole layout.
+ */
+export function renderReader(state, { rows = 24, cols = 80 } = {}) {
+ const width = Math.max(60, cols);
+ const bodyHeight = Math.max(3, rows - HEADER_LINES - FOOTER_LINES);
+ const listWidth = width - SIDEBAR - 3;
+ const out = [];
+
+ // Header ------------------------------------------------------------------
+ const items = visibleItems(state);
+ const where = state.query ? `“${state.query}”`
+ : state.filter ? state.filter
+ : state.usingDefaults ? "default feeds" : "all feeds";
+ const title = ` ${bone("moshcode rss")}${ash(` ${items.length} headline${items.length === 1 ? "" : "s"} · ${where}`)}`;
+ const status = state.loading ? acid("loading…")
+ : state.failures.length ? amber(`${state.failures.length} feed${state.failures.length === 1 ? "" : "s"} down`)
+ : "";
+ out.push(pad(title, width - visibleWidth(status) - 2) + status + " ");
+ out.push(` ${dim("─".repeat(Math.max(10, width - 4)))}`);
+
+ // Body --------------------------------------------------------------------
+ const side = sidebarRows(state);
+ const bodyLines = state.pane === "article"
+ ? articleLines(state, { width: width - 4, height: bodyHeight })
+ : listLines(state, items, { width: listWidth, height: bodyHeight });
+
+ for (let row = 0; row < bodyHeight; row++) {
+ if (state.pane === "article") { out.push(bodyLines[row] ?? ""); continue; }
+ const feedRow = side[row + state.sideOffset];
+ let left = "";
+ if (feedRow) {
+ const selected = state.pane === "feeds" && row + state.sideOffset === state.sideSelected;
+ const active = (feedRow.key ?? null) === (state.filter ?? null);
+ const cursor = selected ? acid("▸") : " ";
+ // 2 indent + cursor + space + label + space + 3 count = SIDEBAR exactly.
+ // Building it one wider than the column it is padded into is how every
+ // body row ends up a column past the edge of the terminal.
+ const label = clip(feedRow.label, SIDEBAR - 8);
+ const paint = active ? bone : ash;
+ left = ` ${cursor} ${paint(pad(label, SIDEBAR - 8))} ${dim(String(feedRow.count).padStart(3))}`;
+ }
+ out.push(`${pad(left, SIDEBAR)} ${dim("│")} ${bodyLines[row] ?? ""}`);
+ }
+
+ // Footer ------------------------------------------------------------------
+ out.push(` ${dim("─".repeat(Math.max(10, width - 4)))}`);
+ out.push(` ${keyHint(state, width - 4)}`);
+ return out.slice(0, rows);
+}
+
+/**
+ * The footer, trimmed to fit.
+ *
+ * Hints are dropped from the right until the line fits the terminal, rather
+ * than being allowed to run past it — a footer one column too wide wraps onto a
+ * line the frame did not budget for, which scrolls the screen and puts every
+ * row of the next frame one off. They are ordered least-guessable first, so
+ * what survives on a narrow terminal is what someone could not have guessed.
+ */
+function keyHint(state, width) {
+ if (state.mode === "search") {
+ const prompt = `${ash("search:")} ${bone(state.input)}${acid("▏")}`;
+ const help = dim(" ⏎ run · esc cancel");
+ return visibleWidth(prompt + help) <= width ? prompt + help : prompt;
+ }
+ const keys = state.pane === "article"
+ ? [["⏎/esc", "back"], ["o", "open"], ["j/k", "next/prev"], ["q", "quit"]]
+ : [["↑↓", "move"], ["⏎", "read"], ["o", "open"], ["/", "search"], ["tab", "feeds"], ["r", "refresh"], ["q", "quit"]];
+
+ const sep = dim(" · ");
+ let line = "";
+ for (const [key, what] of keys) {
+ const next = (line ? line + sep : "") + `${acid(key)} ${ash(what)}`;
+ if (visibleWidth(next) > width) break;
+ line = next;
+ }
+ return line;
+}
+
+/** The headline list, as `height` lines. */
+function listLines(state, items, { width, height }) {
+ if (state.loading && !items.length) return [ash("fetching feeds…")];
+ if (!items.length) {
+ const lines = [ash("nothing here")];
+ if (state.failures.length) {
+ lines.push("", amber(`${state.failures.length} feed${state.failures.length === 1 ? "" : "s"} didn't answer:`));
+ for (const f of state.failures.slice(0, height - 3)) lines.push(` ${dim(`${f.name} — ${f.error}`)}`);
+ }
+ return lines;
+ }
+ const window = items.slice(state.offset, state.offset + height);
+ // One tail column for the whole viewport, so the feed names line up instead
+ // of ending wherever each headline happens to stop. Capped at half the pane
+ // so a feed with a long name cannot squeeze the headline out of its own list,
+ // and the title takes whatever is left — floored at nothing, because a floor
+ // above the available width is how a line ends up wider than the terminal.
+ const tails = window.map((item) => {
+ const when = ago(item.date, state.now);
+ return `${item.feed}${when ? ` · ${when}` : ""}`.trim();
+ });
+ const tailWidth = Math.min(Math.max(0, ...tails.map((t) => t.length)), Math.floor(width / 2));
+ const room = Math.max(1, width - tailWidth - 4);
+
+ return window.map((item, i) => {
+ const selected = state.offset + i === state.selected;
+ const cursor = selected ? acid("▸") : " ";
+ const title = pad(clip(item.title, room), room);
+ const tail = clip(tails[i], tailWidth).padStart(tailWidth);
+ return `${cursor} ${selected ? bone(title) : ash(title)} ${dim(tail)}`;
+ });
+}
+
+/** The selected story, as `height` lines. */
+function articleLines(state, { width, height }) {
+ const items = visibleItems(state);
+ const item = items[state.selected];
+ if (!item) return [ash("nothing selected")];
+ const lines = [""];
+ for (const line of wrap(item.title, width - 4)) lines.push(` ${bone(line)}`);
+ lines.push("");
+ const when = item.date ? `${new Date(item.date).toLocaleString()} · ${ago(item.date, state.now)}` : "no date";
+ lines.push(` ${ash(`${item.feedTitle || item.feed} · ${when}`)}`);
+ if (item.author) lines.push(` ${ash(`by ${item.author}`)}`);
+ lines.push("");
+ if (item.summary) {
+ for (const line of wrap(item.summary, width - 4)) lines.push(` ${ash(line)}`);
+ lines.push("");
+ }
+ // Wrapped, not clipped: this is the line someone copies out of the reader.
+ if (item.link) for (const line of wrap(item.link, width - 4)) lines.push(` ${acid(line)}`);
+ else lines.push(` ${dim("this item has no link")}`);
+ return lines.slice(0, height);
+}
+
+/** Keep the selection inside the list, and the viewport around the selection. */
+function clampView(state, height) {
+ const items = visibleItems(state);
+ state.selected = Math.max(0, Math.min(state.selected, Math.max(0, items.length - 1)));
+ if (state.selected < state.offset) state.offset = state.selected;
+ if (state.selected >= state.offset + height) state.offset = state.selected - height + 1;
+ state.offset = Math.max(0, Math.min(state.offset, Math.max(0, items.length - height)));
+}
+
+/**
+ * Run the reader. Returns a process exit code.
+ *
+ * `deps` mirrors herdUi's: injectable stdin/stdout and an injectable fetch, so
+ * the loop can be driven in a test with no terminal and no network.
+ */
+export async function rssUi(argv = [], deps = {}) {
+ const {
+ stdin = process.stdin,
+ stdout = process.stdout,
+ fetchImpl,
+ openUrl,
+ env = process.env,
+ write = (s) => process.stdout.write(`${s}\n`),
+ } = deps;
+
+ if (!stdin.isTTY || !stdout.isTTY) {
+ write("moshcode rss needs an interactive terminal — try `moshcode news`");
+ return 1;
+ }
+
+ // A query on the command line (`moshcode rss tariffs`) opens straight into
+ // the search, which is the same shape `/news ` has.
+ const query = argv.filter((a) => !String(a).startsWith("-")).join(" ").trim();
+ const list = readingList(env);
+
+ const state = {
+ feeds: query ? searchFeeds(query) : list.feeds,
+ usingDefaults: query ? false : list.usingDefaults,
+ query: query || null,
+ items: [],
+ failures: [],
+ selected: 0,
+ offset: 0,
+ sideSelected: 0,
+ sideOffset: 0,
+ filter: null,
+ pane: "list",
+ mode: "browse",
+ input: "",
+ loading: true,
+ now: Date.now(),
+ };
+
+ let done = false;
+ let restored = false;
+ const wasRaw = Boolean(stdin.isRaw);
+ const restore = () => {
+ if (restored) return;
+ restored = true;
+ stdout.write(ESC.mouseOff + ESC.showCursor + ESC.altOff);
+ try { stdin.setRawMode?.(wasRaw); } catch { /* already gone */ }
+ stdin.pause();
+ };
+ const enter = () => {
+ restored = false;
+ stdout.write(ESC.altOn + ESC.hideCursor + ESC.mouseOn);
+ try { stdin.setRawMode?.(true); } catch { /* not a tty */ }
+ stdin.resume();
+ };
+ const onSignal = () => { restore(); process.exit(130); };
+ process.on("exit", restore);
+ process.on("SIGINT", onSignal);
+ process.on("SIGTERM", onSignal);
+
+ const height = () => Math.max(3, (stdout.rows || 24) - HEADER_LINES - FOOTER_LINES);
+ const draw = () => {
+ if (done) return;
+ clampView(state, height());
+ stdout.write(ESC.clear + renderReader(state, { rows: stdout.rows || 24, cols: stdout.columns || 80 }).join("\r\n"));
+ };
+
+ const load = async () => {
+ state.loading = true;
+ draw();
+ const { items, failures } = await collectNews(state.feeds, { fetchImpl });
+ state.items = items;
+ state.failures = failures;
+ state.now = Date.now();
+ state.loading = false;
+ state.selected = 0;
+ state.offset = 0;
+ draw();
+ };
+
+ enter();
+ draw();
+ const onResize = () => draw();
+ stdout.on("resize", onResize);
+ await load();
+
+ await new Promise((resolve) => {
+ const onData = async (buf) => {
+ for (const event of decodeKeys(buf)) {
+ if (done) return;
+
+ // The search prompt owns every key while it is up, or typing "q" into
+ // it would quit instead of searching for the letter q.
+ if (state.mode === "search") {
+ if (event.kind !== "key") continue;
+ if (event.name === "escape") { state.mode = "browse"; state.input = ""; draw(); continue; }
+ if (event.name === "backspace") { state.input = state.input.slice(0, -1); draw(); continue; }
+ if (event.name === "enter") {
+ const q = state.input.trim();
+ state.mode = "browse";
+ state.input = "";
+ if (!q) { draw(); continue; }
+ state.query = q;
+ state.feeds = searchFeeds(q);
+ state.usingDefaults = false;
+ state.filter = null;
+ state.pane = "list";
+ await load();
+ continue;
+ }
+ if (event.char && event.char >= " ") { state.input += event.char; draw(); }
+ continue;
+ }
+
+ if (event.kind === "wheel") {
+ state.selected += event.direction;
+ draw();
+ continue;
+ }
+ if (event.kind === "click") {
+ // Row 1-2 are the header, so the first list row is line 3.
+ const index = state.offset + (event.row - HEADER_LINES - 1);
+ if (event.col <= SIDEBAR) {
+ const side = sidebarRows(state)[state.sideOffset + (event.row - HEADER_LINES - 1)];
+ if (side) { state.filter = side.key; state.selected = 0; state.offset = 0; state.pane = "list"; draw(); }
+ continue;
+ }
+ const items = visibleItems(state);
+ if (index >= 0 && index < items.length) {
+ // A single click selects; a second on the same row reads it — the
+ // rule herd-ui settled on, so one stray click is never a trip.
+ const opening = index === state.selected && state.pane === "list";
+ state.selected = index;
+ state.pane = opening ? "article" : "list";
+ draw();
+ }
+ continue;
+ }
+ if (event.kind !== "key") continue;
+
+ const name = event.name;
+ if (name === "q" || name === "ctrl-c") { done = true; resolve(); return; }
+
+ if (state.pane === "article") {
+ if (name === "enter" || name === "escape" || name === "left" || name === "h") { state.pane = "list"; draw(); continue; }
+ if (name === "j" || name === "down") { state.selected += 1; draw(); continue; }
+ if (name === "k" || name === "up") { state.selected -= 1; draw(); continue; }
+ }
+
+ if (name === "/") { state.mode = "search"; state.input = ""; draw(); continue; }
+ if (name === "r") { await load(); continue; }
+ if (name === "tab" || name === "\t") {
+ state.pane = state.pane === "feeds" ? "list" : "feeds";
+ draw();
+ continue;
+ }
+
+ // The sidebar has its own selection, so it has to claim the movement
+ // keys before the headline list does — otherwise tab would highlight a
+ // feed and j/k would scroll the headlines beside it.
+ if (state.pane === "feeds") {
+ const side = sidebarRows(state);
+ const move = (delta) => {
+ state.sideSelected = Math.max(0, Math.min(state.sideSelected + delta, side.length - 1));
+ const rows = height();
+ if (state.sideSelected < state.sideOffset) state.sideOffset = state.sideSelected;
+ if (state.sideSelected >= state.sideOffset + rows) state.sideOffset = state.sideSelected - rows + 1;
+ draw();
+ };
+ if (name === "j" || name === "down") { move(1); continue; }
+ if (name === "k" || name === "up") { move(-1); continue; }
+ if (name === "g" || name === "home") { state.sideSelected = 0; state.sideOffset = 0; draw(); continue; }
+ if (name === "G" || name === "end") { move(side.length); continue; }
+ if (name === "enter" || name === "right" || name === "l") {
+ const row = side[state.sideSelected];
+ state.filter = row ? row.key : null;
+ state.selected = 0;
+ state.offset = 0;
+ state.pane = "list";
+ draw();
+ continue;
+ }
+ if (name === "escape") { state.pane = "list"; draw(); continue; }
+ continue;
+ }
+
+ if (name === "j" || name === "down") { state.selected += 1; draw(); continue; }
+ if (name === "k" || name === "up") { state.selected -= 1; draw(); continue; }
+ if (name === "pagedown" || name === " ") { state.selected += height(); draw(); continue; }
+ if (name === "pageup") { state.selected -= height(); draw(); continue; }
+ if (name === "g" || name === "home") { state.selected = 0; draw(); continue; }
+ if (name === "G" || name === "end") { state.selected = visibleItems(state).length - 1; draw(); continue; }
+ if (name === "enter") { state.pane = "article"; draw(); continue; }
+ if (name === "o") {
+ const item = visibleItems(state)[state.selected];
+ if (!item?.link) continue;
+ // The browser gets the terminal only for as long as the opener runs;
+ // a headless box just falls through with nothing opened.
+ const opened = openUrl ? openUrl(item.link) : false;
+ if (!opened) {
+ restore();
+ write(`open this in a browser:\n ${item.link}`);
+ enter();
+ }
+ draw();
+ continue;
+ }
+ if (name === "a") { state.filter = null; state.selected = 0; draw(); continue; }
+ }
+ };
+ stdin.on("data", onData);
+ });
+
+ stdout.off("resize", onResize);
+ restore();
+ process.off("exit", restore);
+ process.off("SIGINT", onSignal);
+ process.off("SIGTERM", onSignal);
+ stdout.write("\n");
+ return 0;
+}
diff --git a/src/tui.mjs b/src/tui.mjs
index 87a69ff..238e774 100644
--- a/src/tui.mjs
+++ b/src/tui.mjs
@@ -879,6 +879,26 @@ export async function tui() {
await cryptoCommand(rest, { openUrl: (url) => canOpenBrowser() && openBrowser(url) });
continue;
}
+ // `/news` renders in the pit rather than handing the terminal over: it is a
+ // list and a prompt to come back to, the same as `/stocks`.
+ if (cmd === "news") {
+ const { newsCommand } = await import("./news.mjs");
+ await newsCommand(rest, { openUrl: (url) => canOpenBrowser() && openBrowser(url) });
+ continue;
+ }
+ // `/rss` is the exception `/attach` is: it takes the whole terminal, so
+ // readline has to let go of stdin first or the two fight over every key.
+ if (cmd === "rss" || cmd === "reader") {
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
+ console.log(err("/rss needs an interactive terminal — try /news"));
+ continue;
+ }
+ const { rssUi } = await import("./rss-ui.mjs");
+ rl.close();
+ await rssUi(rest, { openUrl: (url) => canOpenBrowser() && openBrowser(url) });
+ rl = mkrl();
+ continue;
+ }
if (cmd === "plugin" || cmd === "plugins") {
await pluginCommand(rest);
continue;
diff --git a/test/news.test.mjs b/test/news.test.mjs
new file mode 100644
index 0000000..c305169
--- /dev/null
+++ b/test/news.test.mjs
@@ -0,0 +1,646 @@
+// `moshcode news` — argument translation, the three feed dialects, and the
+// things a reader must never get wrong: opening a link a feed talked it into,
+// letting one dead publisher take the whole listing down, and rendering an
+// escaped anchor tag as if it were the headline.
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import test from "node:test";
+
+import {
+ NEWS_VERB_NAMES, ago, buildOpml, collectNews, decodeEntities, findFeed,
+ loadFeeds, looksLikeOpml, newsArgs, newsCommand, newsUsage, opmlFile,
+ parseFeed, parseOpml, readingList, renderFeeds, renderHeadlines, resolveVerb,
+ safeUrl, saveFeeds, searchFeeds, slugify, withFeed,
+} from "../src/news.mjs";
+import { DEFAULT_FEEDS, resolveBundle, unwrapRedirect } from "../src/news-sources.mjs";
+import { NEWS_VERBS } from "../src/cli-schema.mjs";
+
+// --- fixtures ----------------------------------------------------------------
+
+const RSS = `
+
+ Example Wire
+ https://example.com
+
+ First story
+ https://example.com/1
+ Tue, 11 Aug 2026 12:00:00 GMT
+ A summary & more
]]>
+
+
+ Second story
+ https://example.com/2
+ Mon, 10 Aug 2026 12:00:00 GMT
+
+`;
+
+const ATOM = `
+
+ Atom Daily
+
+
+
+ Atom story
+
+
+ 2026-08-11T09:00:00Z
+ An atom summary
+
+`;
+
+const RDF = `
+
+ RDF Weekly
+
+ RDF story
+ https://rdf.example/1
+ 2026-08-09T00:00:00Z
+
+`;
+
+const OPML = `
+
+ subs
+
+
+
+
+
+
+
+
+`;
+
+/** A fetch stand-in that answers from a url → body map. */
+function fakeFetch(routes) {
+ return async (url) => {
+ const body = routes[url];
+ if (body === undefined) return { ok: false, status: 404, statusText: "Not Found", text: async () => "" };
+ if (body instanceof Error) throw body;
+ return { ok: true, status: 200, text: async () => body };
+ };
+}
+
+/** A throwaway $HOME so the real ~/.moshcode is never touched. */
+function sandbox() {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "moshcode-news-"));
+ return { env: { MOSHCODE_NEWS_OPML: path.join(dir, "news.opml") }, dir };
+}
+
+/** Collect a command's output the way cryptoCommand's tests do. */
+function sink() {
+ const lines = [];
+ const errors = [];
+ return {
+ lines, errors,
+ out: (s) => lines.push(String(s)),
+ fail: (s) => errors.push(String(s)),
+ text: () => lines.join("\n"),
+ errorText: () => errors.join("\n"),
+ };
+}
+
+// --- verbs and arguments -----------------------------------------------------
+
+test("every verb the schema documents is one the parser resolves", () => {
+ // The schema drives help and completion; the parser drives behaviour. A verb
+ // in one and not the other is a command that completes and then fails.
+ assert.deepEqual(NEWS_VERBS.map(({ name }) => name).sort(), [...NEWS_VERB_NAMES].sort());
+ for (const { name } of NEWS_VERBS) assert.equal(resolveVerb(name), name, `${name} does not resolve`);
+});
+
+test("no arguments is the headline list, not usage", () => {
+ // Unlike crypto, a bare `/news` has an obvious answer, and printing usage
+ // instead would make the common case the one that needs a manual.
+ const request = newsArgs([]);
+ assert.equal(request.verb, "headlines");
+ assert.equal(request.target, null);
+ assert.match(newsUsage(), /usage: moshcode news/);
+});
+
+test("`latest` says the default out loud and takes nothing", () => {
+ assert.equal(newsArgs(["latest"]).verb, "headlines");
+ assert.equal(newsArgs(["headlines"]).verb, "headlines");
+ assert.match(newsArgs(["latest", "tariffs"]).error, /takes no arguments/);
+});
+
+test("a bare word is a search, a bare URL is a feed to read", () => {
+ assert.deepEqual(
+ { verb: newsArgs(["tariffs"]).verb, query: newsArgs(["tariffs"]).query },
+ { verb: "search", query: "tariffs" },
+ );
+ // Several words are one phrase, not a verb plus arguments.
+ assert.equal(newsArgs(["openai", "earnings"]).query, "openai earnings");
+ const url = newsArgs(["https://example.com/feed.xml"]);
+ assert.equal(url.verb, "headlines");
+ assert.equal(url.oneOff, true);
+ assert.equal(url.target, "https://example.com/feed.xml");
+});
+
+test("search flags are flags, not part of the phrase", () => {
+ const request = newsArgs(["fed", "rates", "--limit", "5", "--json"]);
+ assert.equal(request.query, "fed rates");
+ assert.equal(request.limit, 5);
+ assert.equal(request.json, true);
+});
+
+test("flags that need a value say so instead of silently defaulting", () => {
+ assert.match(newsArgs(["--limit"]).error, /--limit needs a number/);
+ assert.match(newsArgs(["--limit", "zero"]).error, /whole number/);
+ assert.match(newsArgs(["--limit", "0"]).error, /whole number/);
+ assert.match(newsArgs(["--feed"]).error, /--feed needs a feed name/);
+ assert.match(newsArgs(["--timeout", "999"]).error, /1-120 seconds/);
+ assert.match(newsArgs(["--nope"]).error, /unknown flag --nope/);
+});
+
+test("--limit is capped rather than trusted", () => {
+ assert.equal(newsArgs(["--limit", "100000"]).limit, 200);
+});
+
+test("open takes a headline number and nothing else", () => {
+ assert.equal(newsArgs(["open", "3"]).index, 3);
+ assert.match(newsArgs(["open"]).error, /usage: moshcode news open/);
+ assert.match(newsArgs(["open", "x"]).error, /headline number/);
+ assert.match(newsArgs(["open", "0"]).error, /headline number/);
+});
+
+test("add takes exactly one target", () => {
+ assert.equal(newsArgs(["add", "journalists"]).target, "journalists");
+ assert.match(newsArgs(["add"]).error, /usage: moshcode news add/);
+ assert.match(newsArgs(["add", "a", "b"]).error, /one URL or file at a time/);
+});
+
+// --- URLs --------------------------------------------------------------------
+
+test("only http(s) survives — a feed cannot talk the reader into file: or data:", () => {
+ assert.equal(safeUrl("https://example.com/x"), "https://example.com/x");
+ assert.equal(safeUrl("http://example.com/x"), "http://example.com/x");
+ assert.equal(safeUrl("file:///etc/passwd"), null);
+ assert.equal(safeUrl("javascript:alert(1)"), null);
+ assert.equal(safeUrl("data:text/html,