Skip to content

news: /news feed reader and /rss full-screen reader - #370

Merged
ralyodio merged 1 commit into
mainfrom
worktree-news-feed
Aug 12, 2026
Merged

news: /news feed reader and /rss full-screen reader#370
ralyodio merged 1 commit into
mainfrom
worktree-news-feed

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Adds /news and /rss, wired into the CLI, the pit, /help and completion.

/news                    headlines across every subscribed feed
/news latest             the same thing, said out loud
/news <keyword…>         search the news
/news <url>              read one feed without subscribing
/news add <url|opml|bundle>
/news rm · list · open <n> · sources · export
/rss [keyword…]          the same headlines, full-screen

Where the feeds come from

The defaults and the search are the sources brisk.news and advis0r.com already use in production, not invented ones:

from what
brisk.news apps/web/src/lib/news/fetch-feed.ts Google News top stories + the category map
brisk.news scripts/import-opml-feeds.ts the four public OPML lists, offered by name (/news add journalists)
advis0r src/providers/news/rss.ts PR Newswire + GlobeNewswire wires, and the Google/Bing search pairing
advis0r src/providers/news/tiers.ts which named publishers earned a default slot

Querying both search engines is advis0r's reasoning carried over: Google has the coverage, Bing's links carry the publisher URL that unwrapRedirect decodes into something a reader can actually open.

All 13 default feeds were fetched and parsed before being committed as defaults — RSS, Atom and both wires, every item with a link and a date.

Storage

~/.moshcode/news.opml, override with MOSHCODE_NEWS_OPML. OPML rather than a news.json of our own invention: it is what every reader speaks, so a list can be exported from an existing reader, dropped in, and taken back out with /news export. With nothing subscribed the defaults stand in — and say that they are — so /news works on a fresh install.

Parsing

Dependency-free, like the rest of moshcode. One path for RSS 2.0, Atom and RDF. DOCTYPE stripped and entities decoded from a fixed table, so no feed can expand one into a file read; http(s) only, so none can talk the reader into opening file: or data:.

The reader

Borrows herd-ui.mjs's terminal discipline — hand-written alternate screen and SGR mouse reporting, one restore path so a crash cannot leave a terminal with no cursor. Renders from a pure function, so a frame is asserted in a test without a tty.

Tests

71 new, 1495 pass / 0 fail across the suite. Four bugs the tests caught, all fixed here:

  • escaped HTML in a <description> survived because entities were decoded after tags were stripped — Google News headlines rendered as <a href=…>
  • a greedy attribute group swallowed the / of a self-closing OPML outline, so every feed read as a folder that never closed and the category stack grew without bound
  • a mouse release decoded to no event and fell through to the key decoder, typing the mouse position into the search box
  • an over-long word was never split, so a link wrapped the terminal and tore the frame

🤖 Generated with Claude Code

Adds two commands, both wired into the CLI, the pit, help and completion:

  /news              headlines across every subscribed feed
  /news <keyword…>   search the news
  /news <url>        read one feed without subscribing
  /news add …        subscribe to a feed, an OPML list, or a named bundle
  /rss               the same headlines as a full-screen reader

Subscriptions live in ~/.moshcode/news.opml. OPML rather than a news.json
of our own invention: it is the format every reader already speaks, so a
list can be exported from an existing reader, dropped in, and taken back
out with `/news export`.

The defaults and the search are the sources brisk.news and advis0r.com
already use in production — Google News top stories and category feeds
(brisk's fetch-feed.ts), the PR Newswire and GlobeNewswire wires plus the
Google/Bing search pairing (advis0r's providers/news/rss.ts), and the four
public OPML lists brisk seeds its publisher table from, offered by name.
Querying both search engines is advis0r's reasoning carried over: Google
has the coverage, Bing's links carry the publisher URL that unwrapRedirect
can decode into something a reader can actually open. All 13 default feeds
were fetched and parsed before being committed as defaults.

Feed parsing is dependency-free, like the rest of moshcode: one path for
RSS 2.0, Atom and RDF, DOCTYPE stripped and entities decoded from a fixed
table so no feed can expand one into a file read, and http(s)-only links
so none can talk the reader into opening file: or data:.

The reader borrows herd-ui's terminal discipline — hand-written alternate
screen and SGR mouse reporting, one restore path — and renders from a pure
function so a frame can be asserted without a tty.

71 new tests. Four bugs they caught, all fixed here: escaped HTML in a
description survived because entities were decoded after tags were
stripped; a greedy attribute group swallowed the `/` of a self-closing
OPML outline, so the category stack grew without bound; a mouse release
decoded to no event and fell through to the key decoder, typing the mouse
position into the search box; and an over-long word was never split, so a
link wrapped the terminal and tore the frame.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread src/cli-schema.mjs
],
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. "
Comment thread test/news.test.mjs

test("the feed list distinguishes defaults from a real subscription", () => {
assert.match(renderFeeds(DEFAULT_FEEDS, { usingDefaults: true }), /default feeds · nothing subscribed yet/);
assert.match(renderFeeds([{ name: "mine", title: "Mine", url: "https://m.example/rss", category: "" }], { file: "/tmp/x.opml" }), /1 feed · \/tmp\/x\.opml/);
@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

97 finding(s)

HIGH/CRITICAL: 5 | MEDIUM: 41 | LOW: 51

Severity Rule Location
HIGH js-ssrf-outbound-request apps/pwa/public/sw.js:45
HIGH tls-verification-disabled apps/pwa/src/lib/moshpit-gateway.mjs:299
HIGH sh-remote-script-execution install.sh:79
HIGH sh-remote-script-execution install.sh:83
HIGH tls-verification-disabled src/dns.mjs:741
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:61
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:75
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:101
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:265
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:269
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:314
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:499
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:675
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:677
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:736
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:782
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:852
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:955
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:1063
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:1199
MEDIUM js-unescaped-html-sink apps/pwa/src/routes/moshpit.mjs:1419
MEDIUM js-timing-unsafe-mac-compare apps/pwa/src/routes/settings-sync.mjs:179
MEDIUM js-dynamic-code-execution apps/pwa/test/apikey-mask.test.mjs:129
MEDIUM sql-template-interpolation apps/pwa/test/credits-webhook-event-match.test.mjs:111
MEDIUM sql-template-interpolation apps/pwa/test/credits-webhook-event-match.test.mjs:131
MEDIUM sql-template-interpolation apps/pwa/test/moshpit-terms.test.mjs:192
MEDIUM sql-string-concatenation src/cli-schema.mjs:128
MEDIUM sql-string-concatenation src/cli-schema.mjs:417
MEDIUM sql-string-concatenation src/cli-schema.mjs:543
MEDIUM js-timing-unsafe-mac-compare src/settings-sync.mjs:309
MEDIUM insecure-temp-file test/dns-disable-restore.test.mjs:93
MEDIUM insecure-temp-file test/dns-disable-restore.test.mjs:310
MEDIUM insecure-temp-file test/news.test.mjs:479
MEDIUM insecure-temp-file test/plugins.test.mjs:152
MEDIUM insecure-temp-file test/pty.test.mjs:28
MEDIUM insecure-temp-file test/pty.test.mjs:31
MEDIUM insecure-temp-file test/pty.test.mjs:40
MEDIUM insecure-temp-file test/pty.test.mjs:42
MEDIUM insecure-temp-file test/pty.test.mjs:47
MEDIUM insecure-temp-file test/pty.test.mjs:48
MEDIUM insecure-temp-file test/pty.test.mjs:49
MEDIUM insecure-temp-file test/tabs.test.mjs:8
MEDIUM insecure-temp-file test/tabs.test.mjs:13
MEDIUM insecure-temp-file test/tabs.test.mjs:14
MEDIUM insecure-temp-file test/tabs.test.mjs:22
MEDIUM insecure-temp-file test/trust.test.mjs:240
LOW secret-generic-credential apps/pwa/test/apikey-bearer-scheme.test.mjs:30
LOW secret-generic-credential apps/pwa/test/apikey-mask.test.mjs:38
LOW secret-generic-credential apps/pwa/test/apikey-reveal.test.mjs:35
LOW secret-generic-credential apps/pwa/test/approvals-context.test.mjs:28

…and 47 more. Full results in the Security tab.

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio marked this pull request as ready for review August 12, 2026 02:59
@ralyodio
ralyodio merged commit 4de880b into main Aug 12, 2026
4 checks passed
@ralyodio ralyodio mentioned this pull request Aug 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants