Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` <br>`advisor` | tools | equity research from advis0r.com |
| `moshcode crypto` <br>`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` <br>`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 |
Expand Down
16 changes: 16 additions & 0 deletions bin/moshcode.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
74 changes: 74 additions & 0 deletions src/cli-schema.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,50 @@
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 <keyword…>", "search the news"],
["moshcode news <verb> [args…]", ""],
],
verbs: "NEWS_VERBS",
flags: [
["--json", "structured output instead of headlines", ""],
["--limit <n>", "how many headlines to show", "20"],
["--feed <name>", "only this feed", "all of them"],
["--timeout <sec>", "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 <keyword…>", "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",
Expand Down Expand Up @@ -740,6 +784,31 @@
{ name: "open", description: "open the shareable report page in a browser", synopsis: [["moshcode stocks open <symbol>", ""]] },
];

/**
* `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 <keyword…>", "same as `moshcode news <keyword…>`"]],
},
{ 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 <url|file|bundle>", "an RSS/Atom link, an OPML list, or journalists|web3|blockchain"]],
},
{ name: "rm", description: "unsubscribe", synopsis: [["moshcode news rm <name|url>", ""]] },
{ name: "open", description: "open a headline from the last listing", synopsis: [["moshcode news open <n>", ""]] },
{ 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.
*
Expand Down Expand Up @@ -888,6 +957,7 @@
TRADE_VERBS,
STOCKS_VERBS,
CRYPTO_VERBS,
NEWS_VERBS,
PLUGIN_VERBS,
};

Expand Down Expand Up @@ -931,6 +1001,10 @@
description: "equity research from advis0r.com" },
{ name: "crypto", aliases: ["coins"], args: "<pair|verb> [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: "<verb> [name]", cli: "plugin",
description: "install moshcode's slash commands into Claude Code" },
{ name: "games", aliases: ["game", "arcade", "play"], args: "[game]", cli: "games",
Expand Down
154 changes: 154 additions & 0 deletions src/news-sources.mjs
Original file line number Diff line number Diff line change
@@ -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 <keyword>` 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 }));
}
Loading