From affcad332daf183c9e528d2657278761d7d7cde9 Mon Sep 17 00:00:00 2001 From: Sreeram Akhil Date: Fri, 21 Aug 2026 18:58:30 +0530 Subject: [PATCH 1/8] feat(npm): add versions command, test suite, and expanded README - Add webcmd npm versions command that lists all published versions of a package newest-first, with publishedAt, isLatest flag, and a direct npmjs.com URL per version. Mirrors the pypi releases cmd. - Add optional equest parameter to pmFetch in utils.js so commands can inject a fake fetch function in tests without patching globals (matches the pattern used in the pypi plugin). - Add test/npm.test.js with 14 tests covering all four commands: package, versions, downloads, search. Includes happy paths, empty result / 404 handling, input validation, and a contract test asserting browser: false for every registered command. - Expand README.md with a full command table (including the new versions command), argument descriptions, and copy-paste examples for all four commands. --- plugins/npm/README.md | 35 ++++- plugins/npm/test/npm.test.js | 263 +++++++++++++++++++++++++++++++++++ plugins/npm/utils.js | 4 +- plugins/npm/versions.js | 50 +++++++ 4 files changed, 346 insertions(+), 6 deletions(-) create mode 100644 plugins/npm/test/npm.test.js create mode 100644 plugins/npm/versions.js diff --git a/plugins/npm/README.md b/plugins/npm/README.md index 43e11f87..ad541345 100644 --- a/plugins/npm/README.md +++ b/plugins/npm/README.md @@ -1,6 +1,7 @@ # webcmd-plugin-npm -Webcmd commands for npm. +Inspect public npm package metadata, download stats, version history, and +search results. No login or API key is required. ## Install @@ -12,6 +13,32 @@ webcmd plugin install github:agentrhq/webcmd/npm | Command | Description | | --- | --- | -| `webcmd npm downloads` | Daily download counts for an npm package over a window | -| `webcmd npm package` | Single npm package metadata (latest version, license, homepage, repository). Use `npm downloads` for stats. | -| `webcmd npm search` | Search the public npm registry by keyword | +| `webcmd npm package ` | Latest metadata: version, license, homepage, repository, maintainers | +| `webcmd npm versions ` | Published version history, newest first | +| `webcmd npm downloads ` | Daily download counts over a time window | +| `webcmd npm search ` | Search the public registry by keyword | + +## Examples + +```bash +# Package metadata +webcmd npm package react +webcmd npm package @vercel/og + +# Version history +webcmd npm versions typescript +webcmd npm versions react --limit 5 + +# Download stats (defaults to last week, one row per day) +webcmd npm downloads express +webcmd npm downloads express --period last-month +webcmd npm downloads express --period last-year +webcmd npm downloads express --period 2026-01-01:2026-06-30 + +# Search +webcmd npm search "graphql client" +webcmd npm search vite --limit 5 +``` + +Use this plugin when an agent needs deterministic package metadata before +installing, upgrading, or comparing JavaScript tools. diff --git a/plugins/npm/test/npm.test.js b/plugins/npm/test/npm.test.js new file mode 100644 index 00000000..b642cd75 --- /dev/null +++ b/plugins/npm/test/npm.test.js @@ -0,0 +1,263 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { afterAll, test } from 'vitest'; +import { fileURLToPath } from 'node:url'; + +const pluginRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const repoRoot = path.resolve(pluginRoot, '..', '..'); +const peerScopeDir = path.join(pluginRoot, 'node_modules', '@agentrhq'); +const peerLink = path.join(peerScopeDir, 'webcmd'); + +let createdPeerLink = false; +if (!fs.existsSync(peerLink)) { + fs.mkdirSync(peerScopeDir, { recursive: true }); + // On Windows, directory junctions don't require elevated privileges. + const linkType = process.platform === 'win32' ? 'junction' : 'dir'; + fs.symlinkSync(repoRoot, peerLink, linkType); + createdPeerLink = true; +} + +afterAll(() => { + if (!createdPeerLink) return; + fs.rmSync(peerLink, { force: true, recursive: true }); + for (const dir of [peerScopeDir, path.dirname(peerScopeDir)]) { + try { fs.rmdirSync(dir); } catch { /* leave unrelated local state alone */ } + } +}); + +const { getRegistry } = await import('@agentrhq/webcmd/registry'); +const [{ versionsNpm }] = await Promise.all([ + import('../versions.js'), + import('../package.js'), + import('../downloads.js'), + import('../search.js'), +]); + +// --------------------------------------------------------------------------- +// Shared fixture — a minimal registry payload for a fictional package "exlib" +// --------------------------------------------------------------------------- +const REGISTRY_PAYLOAD = { + name: 'exlib', + description: 'An example library', + 'dist-tags': { latest: '2.1.0' }, + versions: { + '2.1.0': { + description: 'An example library', + license: 'MIT', + homepage: 'https://exlib.dev', + repository: { type: 'git', url: 'git+https://github.com/example/exlib.git' }, + bugs: { url: 'https://github.com/example/exlib/issues' }, + keywords: ['example', 'lib'], + }, + '2.0.0': { + description: 'An example library', + license: 'MIT', + }, + }, + maintainers: [{ name: 'alice', email: 'alice@example.com' }], + time: { + created: '2024-01-01T00:00:00.000Z', + modified: '2026-06-15T12:00:00.000Z', + '2.0.0': '2025-03-10T08:00:00.000Z', + '2.1.0': '2026-06-15T12:00:00.000Z', + }, +}; + +const DOWNLOADS_PAYLOAD = { + package: 'exlib', + downloads: [ + { day: '2026-06-09', downloads: 1200 }, + { day: '2026-06-10', downloads: 1350 }, + { day: '2026-06-11', downloads: 980 }, + ], +}; + +const SEARCH_PAYLOAD = { + objects: [ + { + package: { + name: 'exlib', + version: '2.1.0', + description: 'An example library', + license: 'MIT', + publisher: { username: 'alice' }, + links: { npm: 'https://www.npmjs.com/package/exlib' }, + }, + downloads: { weekly: 50000 }, + dependents: 120, + updated: '2026-06-15T12:00:00.000Z', + }, + ], +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- +function fakeRequest(payload, { ok = true, status = 200 } = {}) { + const req = async (url, _opts) => { + req.calls.push(String(url)); + return { ok, status, json: async () => payload }; + }; + req.calls = []; + return req; +} + +function withFetch(payload, fn, { ok = true, status = 200 } = {}) { + const original = globalThis.fetch; + globalThis.fetch = fakeRequest(payload, { ok, status }); + return fn().finally(() => { globalThis.fetch = original; }); +} + +// --------------------------------------------------------------------------- +// npm package +// --------------------------------------------------------------------------- +test('npm package returns latest metadata', () => + withFetch(REGISTRY_PAYLOAD, async () => { + const rows = await getRegistry().get('npm/package').func({ name: 'exlib' }); + assert.equal(rows.length, 1); + const [row] = rows; + assert.equal(row.name, 'exlib'); + assert.equal(row.latestVersion, '2.1.0'); + assert.equal(row.description, 'An example library'); + assert.equal(row.license, 'MIT'); + assert.equal(row.homepage, 'https://exlib.dev'); + assert.equal(row.repository, 'https://github.com/example/exlib'); + assert.equal(row.bugs, 'https://github.com/example/exlib/issues'); + assert.equal(row.maintainers, 'alice'); + assert.equal(row.keywords, 'example, lib'); + assert.equal(row.created, '2024-01-01'); + assert.equal(row.modified, '2026-06-15'); + assert.equal(row.url, 'https://www.npmjs.com/package/exlib'); + }), +); + +test('npm package hits the correct registry URL', () => { + const req = fakeRequest(REGISTRY_PAYLOAD); + const original = globalThis.fetch; + globalThis.fetch = req; + return getRegistry().get('npm/package').func({ name: 'exlib' }) + .then(() => { + assert.ok(req.calls[0].startsWith('https://registry.npmjs.org/')); + }) + .finally(() => { globalThis.fetch = original; }); +}); + +test('npm package rejects invalid package names', async () => { + await assert.rejects( + () => getRegistry().get('npm/package').func({ name: '' }), + /required/, + ); + await assert.rejects( + () => getRegistry().get('npm/package').func({ name: '../etc/passwd' }), + /valid/, + ); +}); + +test('npm package throws EmptyResultError on 404', () => + withFetch({}, async () => { + await assert.rejects( + () => getRegistry().get('npm/package').func({ name: 'no-such-pkg-xyz' }), + (err) => err.code === 'EMPTY_RESULT', + ); + }, { ok: false, status: 404 }), +); + +// --------------------------------------------------------------------------- +// npm versions +// --------------------------------------------------------------------------- +test('npm versions returns rows newest first', async () => { + const req = fakeRequest(REGISTRY_PAYLOAD); + const rows = await versionsNpm({ name: 'exlib', limit: 10 }, req); + assert.equal(rows.length, 2); + assert.equal(rows[0].version, '2.1.0'); + assert.equal(rows[0].publishedAt, '2026-06-15'); + assert.equal(rows[0].isLatest, true); + assert.ok(rows[0].url.includes('2.1.0')); + assert.equal(rows[1].version, '2.0.0'); + assert.equal(rows[1].isLatest, false); +}); + +test('npm versions strips created/modified bookkeeping keys', async () => { + const req = fakeRequest(REGISTRY_PAYLOAD); + const rows = await versionsNpm({ name: 'exlib', limit: 50 }, req); + assert.ok(rows.every((r) => r.version !== 'created' && r.version !== 'modified')); +}); + +test('npm versions respects --limit', async () => { + const req = fakeRequest(REGISTRY_PAYLOAD); + const rows = await versionsNpm({ name: 'exlib', limit: 1 }, req); + assert.equal(rows.length, 1); + assert.equal(rows[0].version, '2.1.0'); +}); + +test('npm versions rejects out-of-range limit', async () => { + await assert.rejects( + () => versionsNpm({ name: 'exlib', limit: 51 }, fakeRequest(REGISTRY_PAYLOAD)), + /50/, + ); +}); + +// --------------------------------------------------------------------------- +// npm downloads +// --------------------------------------------------------------------------- +test('npm downloads returns one row per day', () => + withFetch(DOWNLOADS_PAYLOAD, async () => { + const rows = await getRegistry().get('npm/downloads').func({ name: 'exlib', period: 'last-week' }); + assert.equal(rows.length, 3); + assert.equal(rows[0].rank, 1); + assert.equal(rows[0].package, 'exlib'); + assert.equal(rows[0].day, '2026-06-09'); + assert.equal(rows[0].downloads, 1200); + }), +); + +test('npm downloads rejects invalid period', async () => { + await assert.rejects( + () => getRegistry().get('npm/downloads').func({ name: 'exlib', period: 'bad-period' }), + /invalid/, + ); +}); + +test('npm downloads rejects date range where start is after end', async () => { + await assert.rejects( + () => getRegistry().get('npm/downloads').func({ name: 'exlib', period: '2026-06-15:2026-01-01' }), + /after end/, + ); +}); + +// --------------------------------------------------------------------------- +// npm search +// --------------------------------------------------------------------------- +test('npm search returns ranked results', () => + withFetch(SEARCH_PAYLOAD, async () => { + const rows = await getRegistry().get('npm/search').func({ query: 'exlib', limit: 20 }); + assert.equal(rows.length, 1); + const [row] = rows; + assert.equal(row.rank, 1); + assert.equal(row.name, 'exlib'); + assert.equal(row.version, '2.1.0'); + assert.equal(row.weeklyDownloads, 50000); + assert.equal(row.dependents, 120); + assert.equal(row.url, 'https://www.npmjs.com/package/exlib'); + }), +); + +test('npm search rejects empty query', async () => { + await assert.rejects( + () => getRegistry().get('npm/search').func({ query: '', limit: 20 }), + /empty/, + ); +}); + +// --------------------------------------------------------------------------- +// All registered commands are browser: false +// --------------------------------------------------------------------------- +test('all npm commands are browser-free', () => { + const registry = getRegistry(); + for (const name of ['npm/package', 'npm/downloads', 'npm/search', 'npm/versions']) { + const cmd = registry.get(name); + assert.ok(cmd, `command ${name} not registered`); + assert.equal(cmd.browser, false, `${name} should not require a browser`); + } +}); diff --git a/plugins/npm/utils.js b/plugins/npm/utils.js index fd2aaaed..f041cd87 100644 --- a/plugins/npm/utils.js +++ b/plugins/npm/utils.js @@ -42,10 +42,10 @@ export function requireBoundedInt(value, defaultValue, maxValue, label = 'limit' return n; } -export async function npmFetch(url, label) { +export async function npmFetch(url, label, request = fetch) { let resp; try { - resp = await fetch(url, { headers: { 'user-agent': UA, accept: 'application/json' } }); + resp = await request(url, { headers: { 'user-agent': UA, accept: 'application/json' } }); } catch (err) { throw new CommandExecutionError( diff --git a/plugins/npm/versions.js b/plugins/npm/versions.js new file mode 100644 index 00000000..56693e35 --- /dev/null +++ b/plugins/npm/versions.js @@ -0,0 +1,50 @@ +// npm versions — list published versions for a package, newest first. +// +// Hits `https://registry.npmjs.org/` and projects `time` entries so +// agents can answer "when was X released?" or "what's the latest stable?". +import { cli, Strategy } from '@agentrhq/webcmd/registry'; +import { EmptyResultError } from '@agentrhq/webcmd/errors'; +import { NPM_REGISTRY, npmFetch, requireBoundedInt, requirePackageName } from './utils.js'; + +export async function versionsNpm(args, request = fetch) { + const name = requirePackageName(args.name); + const limit = requireBoundedInt(args.limit ?? 10, 10, 50); + const url = `${NPM_REGISTRY}/${name.split('/').map(encodeURIComponent).join('/')}`; + const body = await npmFetch(url, `npm versions ${name}`, request); + + const timeMap = body?.time && typeof body.time === 'object' ? body.time : {}; + const latest = body?.['dist-tags']?.latest ?? ''; + + const rows = Object.entries(timeMap) + // skip internal bookkeeping keys that npm puts in time + .filter(([version]) => version !== 'created' && version !== 'modified') + .map(([version, publishedAt]) => ({ + version, + publishedAt: String(publishedAt ?? '').slice(0, 10), + isLatest: version === latest, + url: `https://www.npmjs.com/package/${name}/v/${version}`, + })) + .sort((a, b) => String(b.publishedAt).localeCompare(String(a.publishedAt))) + .slice(0, limit); + + if (!rows.length) { + throw new EmptyResultError('npm versions', `npm registry has no version history for "${name}".`); + } + return rows; +} + +cli({ + site: 'npm', + name: 'versions', + access: 'read', + description: 'List published versions of an npm package, newest first', + domain: 'registry.npmjs.org', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'name', positional: true, required: true, help: 'npm package name (e.g. "react", "@vercel/og")' }, + { name: 'limit', type: 'int', default: 10, help: 'Maximum versions to return (1-50)' }, + ], + columns: ['version', 'publishedAt', 'isLatest', 'url'], + func: (args) => versionsNpm(args), +}); From 69faaaa961972f4f8a8d0e71f1f728a4b6a163a8 Mon Sep 17 00:00:00 2001 From: Sreeram Akhil Date: Fri, 21 Aug 2026 19:09:25 +0530 Subject: [PATCH 2/8] fix(npm): sort versions by full ISO timestamp before formatting Slicing publishedAt to 10 chars before sorting caused versions published on the same calendar date to lose sub-day precision, producing non-deterministic newest-first ordering. Fix: sort on the raw full timestamp first, then format to date-only inside .map(). Add a regression test with two versions sharing the same date (08:00 and 14:00) to pin the correct ordering. --- plugins/npm/test/npm.test.js | 24 ++++++++++++++++++++++++ plugins/npm/versions.js | 8 +++++--- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/plugins/npm/test/npm.test.js b/plugins/npm/test/npm.test.js index b642cd75..0ddc2c11 100644 --- a/plugins/npm/test/npm.test.js +++ b/plugins/npm/test/npm.test.js @@ -191,6 +191,30 @@ test('npm versions respects --limit', async () => { assert.equal(rows[0].version, '2.1.0'); }); +test('npm versions sorts correctly when two versions share the same date', async () => { + // Regression: sort must use the full ISO timestamp, not the truncated + // date-only string, so same-day releases still come out newest-first. + const sameDayPayload = { + name: 'exlib', + 'dist-tags': { latest: '2.1.1' }, + time: { + created: '2026-06-15T08:00:00.000Z', + modified: '2026-06-15T14:00:00.000Z', + '2.1.0': '2026-06-15T08:00:00.000Z', // earlier on same day + '2.1.1': '2026-06-15T14:00:00.000Z', // later on same day + }, + }; + const req = fakeRequest(sameDayPayload); + const rows = await versionsNpm({ name: 'exlib', limit: 10 }, req); + assert.equal(rows.length, 2); + // 2.1.1 published at 14:00 must come before 2.1.0 published at 08:00 + assert.equal(rows[0].version, '2.1.1'); + assert.equal(rows[1].version, '2.1.0'); + // Both format to the same date string + assert.equal(rows[0].publishedAt, '2026-06-15'); + assert.equal(rows[1].publishedAt, '2026-06-15'); +}); + test('npm versions rejects out-of-range limit', async () => { await assert.rejects( () => versionsNpm({ name: 'exlib', limit: 51 }, fakeRequest(REGISTRY_PAYLOAD)), diff --git a/plugins/npm/versions.js b/plugins/npm/versions.js index 56693e35..4fe7c88d 100644 --- a/plugins/npm/versions.js +++ b/plugins/npm/versions.js @@ -18,14 +18,16 @@ export async function versionsNpm(args, request = fetch) { const rows = Object.entries(timeMap) // skip internal bookkeeping keys that npm puts in time .filter(([version]) => version !== 'created' && version !== 'modified') + // sort on the raw full ISO timestamp BEFORE formatting so that two + // versions published on the same calendar date still sort correctly + .sort(([, left], [, right]) => String(right ?? '').localeCompare(String(left ?? ''))) + .slice(0, limit) .map(([version, publishedAt]) => ({ version, publishedAt: String(publishedAt ?? '').slice(0, 10), isLatest: version === latest, url: `https://www.npmjs.com/package/${name}/v/${version}`, - })) - .sort((a, b) => String(b.publishedAt).localeCompare(String(a.publishedAt))) - .slice(0, limit); + })); if (!rows.length) { throw new EmptyResultError('npm versions', `npm registry has no version history for "${name}".`); From 5a2f02adbed805f62eb9bddeedbd46feb1b5acd4 Mon Sep 17 00:00:00 2001 From: Sreeram Akhil Date: Fri, 21 Aug 2026 19:18:17 +0530 Subject: [PATCH 3/8] fix(npm): filter versions by body.versions to exclude time-only ghost entries A version key can exist in body.time without a matching entry in body.versions (e.g. yanked or unpublished releases). The previous code returned bogus rows for those keys with an invalid URL and misleading date. Fix: cross-filter timeMap entries against body.versions so only keys that exist in both are returned. Also guard that the timestamp is a string before sorting. Update the same-day regression fixture to include matching body.versions entries and add a time-only ghost key (0.0.1-ghost) to assert it is excluded from results. --- plugins/npm/test/npm.test.js | 17 +++++++++++++---- plugins/npm/versions.js | 4 ++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/plugins/npm/test/npm.test.js b/plugins/npm/test/npm.test.js index 0ddc2c11..7697b4ae 100644 --- a/plugins/npm/test/npm.test.js +++ b/plugins/npm/test/npm.test.js @@ -197,15 +197,22 @@ test('npm versions sorts correctly when two versions share the same date', async const sameDayPayload = { name: 'exlib', 'dist-tags': { latest: '2.1.1' }, + versions: { + '2.1.0': { description: 'v2.1.0' }, + '2.1.1': { description: 'v2.1.1' }, + // '0.0.1-ghost' intentionally absent — time-only entry below must be excluded + }, time: { - created: '2026-06-15T08:00:00.000Z', - modified: '2026-06-15T14:00:00.000Z', - '2.1.0': '2026-06-15T08:00:00.000Z', // earlier on same day - '2.1.1': '2026-06-15T14:00:00.000Z', // later on same day + created: '2026-06-15T08:00:00.000Z', + modified: '2026-06-15T14:00:00.000Z', + '2.1.0': '2026-06-15T08:00:00.000Z', // earlier on same day + '2.1.1': '2026-06-15T14:00:00.000Z', // later on same day + '0.0.1-ghost': '2026-06-15T06:00:00.000Z', // time-only, no body.versions entry }, }; const req = fakeRequest(sameDayPayload); const rows = await versionsNpm({ name: 'exlib', limit: 10 }, req); + // ghost entry must be excluded assert.equal(rows.length, 2); // 2.1.1 published at 14:00 must come before 2.1.0 published at 08:00 assert.equal(rows[0].version, '2.1.1'); @@ -213,6 +220,8 @@ test('npm versions sorts correctly when two versions share the same date', async // Both format to the same date string assert.equal(rows[0].publishedAt, '2026-06-15'); assert.equal(rows[1].publishedAt, '2026-06-15'); + // ghost must not appear at all + assert.ok(rows.every((r) => r.version !== '0.0.1-ghost')); }); test('npm versions rejects out-of-range limit', async () => { diff --git a/plugins/npm/versions.js b/plugins/npm/versions.js index 4fe7c88d..2eee020f 100644 --- a/plugins/npm/versions.js +++ b/plugins/npm/versions.js @@ -13,11 +13,15 @@ export async function versionsNpm(args, request = fetch) { const body = await npmFetch(url, `npm versions ${name}`, request); const timeMap = body?.time && typeof body.time === 'object' ? body.time : {}; + const versionsMap = body?.versions && typeof body.versions === 'object' ? body.versions : {}; const latest = body?.['dist-tags']?.latest ?? ''; const rows = Object.entries(timeMap) // skip internal bookkeeping keys that npm puts in time .filter(([version]) => version !== 'created' && version !== 'modified') + // only keep versions that actually exist in body.versions — time-only + // keys (e.g. unpublished entries) have no real release and must be omitted + .filter(([version, publishedAt]) => version in versionsMap && typeof publishedAt === 'string') // sort on the raw full ISO timestamp BEFORE formatting so that two // versions published on the same calendar date still sort correctly .sort(([, left], [, right]) => String(right ?? '').localeCompare(String(left ?? ''))) From c267318af4eafc0d4de78fffb8518ba37c51162d Mon Sep 17 00:00:00 2001 From: Sreeram Akhil Date: Sat, 22 Aug 2026 10:08:35 +0530 Subject: [PATCH 4/8] feat(omnisearch): add Reddit as a 7th research source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reddit is one of the most valuable developer discussion platforms (r/programming, r/webdev, r/javascript, r/rust, r/python, etc.) but was missing from the omnisearch aggregator entirely. - Add redditSearch() to sources.js using the free public JSON search API (reddit.com/search.json, no auth or key required). Returns normalized rows: platform/title/author/score/commentCount/createdAt/ url/text — matching the shared schema used by all other sources. Falls back to permalink when the url field is absent (self posts). - Wire redditSearch into research.js: added to the fetchers map and included in the default sources string so every agent using webcmd omnisearch research gets Reddit results automatically. - Wire redditSearch into verdict.js so community sentiment analysis now includes Reddit engagement alongside HN, SO, GitHub, arXiv, Dev.to, and Lobsters. - Expand test/research.test.js from 1 test to 8 tests: - redditSearch: normalized rows, permalink fallback, empty results, correct endpoint URL - research command: Reddit rows returned, default sources includes reddit, Reddit failure isolated via Promise.allSettled - Original HN limit regression test preserved --- plugins/omnisearch/research.js | 6 +- plugins/omnisearch/sources.js | 30 ++++ plugins/omnisearch/test/research.test.js | 183 ++++++++++++++++++++++- plugins/omnisearch/verdict.js | 3 +- 4 files changed, 212 insertions(+), 10 deletions(-) diff --git a/plugins/omnisearch/research.js b/plugins/omnisearch/research.js index ef636f5f..31c9dc8d 100644 --- a/plugins/omnisearch/research.js +++ b/plugins/omnisearch/research.js @@ -15,6 +15,7 @@ import { devtoSearch, githubSearch, arxivSearch, + redditSearch, } from './sources.js'; function requireQuery(value) { @@ -28,7 +29,7 @@ cli({ name: 'research', tags: ['search'], access: 'read', - description: "Aggregate results about a topic across all public platforms (Hacker News, Lobsters, Stack Overflow, Dev.to, GitHub, arXiv)", + description: "Aggregate results about a topic across all public platforms (Hacker News, Lobsters, Stack Overflow, Dev.to, GitHub, arXiv, Reddit)", strategy: Strategy.PUBLIC, browser: false, args: [ @@ -36,7 +37,7 @@ cli({ { name: 'limit', type: 'int', default: 20, help: 'Maximum total results' }, { name: 'sources', - default: 'hn,lobsters,stackoverflow,devto,github,arxiv', + default: 'hn,lobsters,stackoverflow,devto,github,arxiv,reddit', help: 'Comma-separated sources to query (default: all)', }, ], @@ -61,6 +62,7 @@ cli({ devto: () => devtoSearch(query, perPlatform), github: () => githubSearch(query, perPlatform), arxiv: () => arxivSearch(query, perPlatform), + reddit: () => redditSearch(query, perPlatform), }; const selected = wanted.length ? wanted.filter((s) => fetchers[s]) : Object.keys(fetchers); diff --git a/plugins/omnisearch/sources.js b/plugins/omnisearch/sources.js index 5fedf25a..f7381a67 100644 --- a/plugins/omnisearch/sources.js +++ b/plugins/omnisearch/sources.js @@ -192,3 +192,33 @@ export async function blueskyPosts(handle, limit) { }; }); } + +// --- Reddit (public JSON search API, no auth) --- +export async function redditSearch(query, limit) { + const url = new URL('https://www.reddit.com/search.json'); + url.searchParams.set('q', query); + url.searchParams.set('sort', 'relevance'); + url.searchParams.set('type', 'link'); + url.searchParams.set('limit', String(Math.min(limit, 100))); + const res = await get(url, { + headers: { 'User-Agent': 'Mozilla/5.0 (compatible; OmniSearch/0.1; +https://github.com/agentrhq/webcmd)' }, + }, { source: 'Reddit' }); + const json = await res.json(); + const children = Array.isArray(json?.data?.children) ? json.data.children : []; + return children.slice(0, limit).map((child) => { + const d = child?.data ?? {}; + return { + platform: 'reddit', + title: String(d.title ?? '').trim(), + author: String(d.author ?? ''), + score: d.score ?? 0, + commentCount: d.num_comments ?? 0, + createdAt: d.created_utc + ? new Date(d.created_utc * 1000).toISOString() + : '', + url: d.url ? String(d.url) : `https://www.reddit.com${d.permalink ?? ''}`, + text: String(d.selftext ?? '').slice(0, 200), + }; + }); +} + diff --git a/plugins/omnisearch/test/research.test.js b/plugins/omnisearch/test/research.test.js index 699fe6cb..0104e79a 100644 --- a/plugins/omnisearch/test/research.test.js +++ b/plugins/omnisearch/test/research.test.js @@ -1,17 +1,188 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { getRegistry } from '@agentrhq/webcmd/registry'; import '../research.js'; +import '../verdict.js'; afterEach(() => vi.unstubAllGlobals()); -describe('omnisearch research', () => { +// --------------------------------------------------------------------------- +// Fake fetch helpers +// --------------------------------------------------------------------------- + +/** Reddit JSON API shape */ +function redditResponse(posts) { + return { + data: { + children: posts.map((p) => ({ kind: 't3', data: p })), + }, + }; +} + +/** HN Algolia shape */ +function hnResponse(hits) { + return { hits }; +} + +/** Generic 200 OK stub */ +function stubFetch(handler) { + vi.stubGlobal('fetch', async (input) => { + const body = await handler(String(input)); + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }); +} + +// --------------------------------------------------------------------------- +// redditSearch (via sources.js) +// --------------------------------------------------------------------------- + +describe('redditSearch', () => { + it('returns normalized rows from Reddit JSON API', async () => { + const { redditSearch } = await import('../sources.js'); + + stubFetch(() => + redditResponse([ + { + title: 'Why Rust is fast', + author: 'rustacean', + score: 420, + num_comments: 87, + created_utc: 1700000000, + url: 'https://example.com/rust-fast', + selftext: '', + permalink: '/r/rust/comments/abc/why_rust_is_fast/', + }, + ]), + ); + + const rows = await redditSearch('rust', 5); + expect(rows).toHaveLength(1); + expect(rows[0].platform).toBe('reddit'); + expect(rows[0].title).toBe('Why Rust is fast'); + expect(rows[0].author).toBe('rustacean'); + expect(rows[0].score).toBe(420); + expect(rows[0].commentCount).toBe(87); + expect(rows[0].url).toBe('https://example.com/rust-fast'); + expect(rows[0].createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + it('falls back to permalink when url field is absent', async () => { + const { redditSearch } = await import('../sources.js'); + + stubFetch(() => + redditResponse([ + { + title: 'A self post', + author: 'op', + score: 10, + num_comments: 2, + created_utc: 1700000000, + url: null, + selftext: 'Some body text', + permalink: '/r/programming/comments/xyz/a_self_post/', + }, + ]), + ); + + const rows = await redditSearch('selfpost', 5); + expect(rows[0].url).toBe('https://www.reddit.com/r/programming/comments/xyz/a_self_post/'); + }); + + it('returns empty array when Reddit returns no children', async () => { + const { redditSearch } = await import('../sources.js'); + stubFetch(() => ({ data: { children: [] } })); + const rows = await redditSearch('xyzzy-no-results', 5); + expect(rows).toHaveLength(0); + }); + + it('hits the correct Reddit search endpoint', async () => { + const { redditSearch } = await import('../sources.js'); + const calls = []; + vi.stubGlobal('fetch', async (input) => { + calls.push(String(input)); + return new Response(JSON.stringify({ data: { children: [] } }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }); + + await redditSearch('browser automation', 10); + expect(calls[0]).toContain('reddit.com/search.json'); + expect(calls[0]).toContain('browser+automation'); + }); +}); + +// --------------------------------------------------------------------------- +// omnisearch research — Reddit integration +// --------------------------------------------------------------------------- + +describe('omnisearch research with reddit source', () => { + it('returns Reddit rows when sources=reddit', async () => { + const command = getRegistry().get('omnisearch/research'); + + stubFetch(() => + redditResponse([ + { + title: 'Playwright vs Puppeteer', + author: 'tester', + score: 300, + num_comments: 45, + created_utc: 1700000000, + url: 'https://example.com/pw-vs-pp', + selftext: '', + permalink: '/r/webdev/comments/pw-vs-pp/', + }, + ]), + ); + + const rows = await command.func({ query: 'playwright', limit: 5, sources: 'reddit' }); + expect(rows.length).toBeGreaterThan(0); + expect(rows[0].platform).toBe('reddit'); + expect(rows[0].title).toBe('Playwright vs Puppeteer'); + }); + + it('includes reddit in default sources', () => { + const command = getRegistry().get('omnisearch/research'); + const sourcesArg = command.args.find((a) => a.name === 'sources'); + expect(sourcesArg.default).toContain('reddit'); + }); + + it('handles reddit failure gracefully when other sources succeed', async () => { + const command = getRegistry().get('omnisearch/research'); + + vi.stubGlobal('fetch', async (input) => { + if (String(input).includes('reddit.com')) { + return new Response('Service Unavailable', { status: 503 }); + } + // HN succeeds + return new Response( + JSON.stringify(hnResponse([ + { objectID: '1', title: 'HN result', author: 'a', points: 10, num_comments: 2, created_at: '2026-01-01T00:00:00Z', url: 'https://example.com' }, + ])), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + }); + + // Should not throw — Reddit failure is isolated via Promise.allSettled + const rows = await command.func({ query: 'test', limit: 5, sources: 'hn,reddit' }); + expect(rows.some((r) => r.platform === 'hackernews')).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Original limit test (kept for regression) +// --------------------------------------------------------------------------- + +describe('omnisearch research — limit enforcement', () => { it('honors the total limit when research is narrowed to one source', async () => { const hits = [ - { objectID: '1', title: 'One', author: 'a', points: 5, num_comments: 1, created_at: '2026-01-01T00:00:00Z', url: 'https://example.com/1' }, - { objectID: '2', title: 'Two', author: 'b', points: 4, num_comments: 2, created_at: '2026-01-02T00:00:00Z', url: 'https://example.com/2' }, + { objectID: '1', title: 'One', author: 'a', points: 5, num_comments: 1, created_at: '2026-01-01T00:00:00Z', url: 'https://example.com/1' }, + { objectID: '2', title: 'Two', author: 'b', points: 4, num_comments: 2, created_at: '2026-01-02T00:00:00Z', url: 'https://example.com/2' }, { objectID: '3', title: 'Three', author: 'c', points: 3, num_comments: 3, created_at: '2026-01-03T00:00:00Z', url: 'https://example.com/3' }, - { objectID: '4', title: 'Four', author: 'd', points: 2, num_comments: 4, created_at: '2026-01-04T00:00:00Z', url: 'https://example.com/4' }, - { objectID: '5', title: 'Five', author: 'e', points: 1, num_comments: 5, created_at: '2026-01-05T00:00:00Z', url: 'https://example.com/5' }, + { objectID: '4', title: 'Four', author: 'd', points: 2, num_comments: 4, created_at: '2026-01-04T00:00:00Z', url: 'https://example.com/4' }, + { objectID: '5', title: 'Five', author: 'e', points: 1, num_comments: 5, created_at: '2026-01-05T00:00:00Z', url: 'https://example.com/5' }, ]; vi.stubGlobal('fetch', async (input) => { const count = Number(new URL(input).searchParams.get('hitsPerPage')); @@ -21,9 +192,7 @@ describe('omnisearch research', () => { }); }); const command = getRegistry().get('omnisearch/research'); - const rows = await command.func({ query: 'webcmd', limit: 5, sources: 'hn' }); - expect(rows.map((row) => row.title)).toEqual(['One', 'Two', 'Three', 'Four', 'Five']); }); }); diff --git a/plugins/omnisearch/verdict.js b/plugins/omnisearch/verdict.js index b37b7a06..f0094285 100644 --- a/plugins/omnisearch/verdict.js +++ b/plugins/omnisearch/verdict.js @@ -7,7 +7,7 @@ */ import { cli, Strategy } from '@agentrhq/webcmd/registry'; import { ArgumentError, CommandExecutionError, EmptyResultError } from '@agentrhq/webcmd/errors'; -import { hnSearch, lobstersSearch, stackoverflowSearch, githubSearch, arxivSearch, devtoSearch } from './sources.js'; +import { hnSearch, lobstersSearch, stackoverflowSearch, githubSearch, arxivSearch, devtoSearch, redditSearch } from './sources.js'; function requireQuery(value) { const s = String(value ?? '').trim(); @@ -41,6 +41,7 @@ cli({ () => arxivSearch(topic, perSource), () => devtoSearch(topic, perSource), () => lobstersSearch(topic, perSource), + () => redditSearch(topic, perSource), ]; let results; From 7934049ec2779b91aa15a5f32f2b046fb86c03be Mon Sep 17 00:00:00 2001 From: Sreeram Akhil Date: Sat, 22 Aug 2026 10:17:49 +0530 Subject: [PATCH 5/8] fix(omnisearch): guard against invalid created_utc in redditSearch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A non-numeric or out-of-range created_utc value would cause new Date(...).toISOString() to throw, rejecting the entire Reddit result set for that query. Fix: construct the Date object first, then check Number.isNaN on getTime() before calling toISOString() — returning empty string for malformed timestamps so one bad post never kills the whole fetch. Add regression test: a post with created_utc='not-a-number' must produce createdAt='' without throwing. --- plugins/omnisearch/sources.js | 5 ++--- plugins/omnisearch/test/research.test.js | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/plugins/omnisearch/sources.js b/plugins/omnisearch/sources.js index f7381a67..02562dbe 100644 --- a/plugins/omnisearch/sources.js +++ b/plugins/omnisearch/sources.js @@ -207,15 +207,14 @@ export async function redditSearch(query, limit) { const children = Array.isArray(json?.data?.children) ? json.data.children : []; return children.slice(0, limit).map((child) => { const d = child?.data ?? {}; + const createdAt = new Date(d.created_utc ? Number(d.created_utc) * 1000 : NaN); return { platform: 'reddit', title: String(d.title ?? '').trim(), author: String(d.author ?? ''), score: d.score ?? 0, commentCount: d.num_comments ?? 0, - createdAt: d.created_utc - ? new Date(d.created_utc * 1000).toISOString() - : '', + createdAt: Number.isNaN(createdAt.getTime()) ? '' : createdAt.toISOString(), url: d.url ? String(d.url) : `https://www.reddit.com${d.permalink ?? ''}`, text: String(d.selftext ?? '').slice(0, 200), }; diff --git a/plugins/omnisearch/test/research.test.js b/plugins/omnisearch/test/research.test.js index 0104e79a..9efb4d78 100644 --- a/plugins/omnisearch/test/research.test.js +++ b/plugins/omnisearch/test/research.test.js @@ -97,6 +97,28 @@ describe('redditSearch', () => { expect(rows).toHaveLength(0); }); + it('returns empty string for createdAt when created_utc is invalid', async () => { + const { redditSearch } = await import('../sources.js'); + stubFetch(() => + redditResponse([ + { + title: 'Malformed post', + author: 'op', + score: 1, + num_comments: 0, + created_utc: 'not-a-number', + url: 'https://example.com/post', + selftext: '', + permalink: '/r/test/comments/abc/', + }, + ]), + ); + // Must not throw — one bad timestamp returns '' not an exception + const rows = await redditSearch('test', 5); + expect(rows).toHaveLength(1); + expect(rows[0].createdAt).toBe(''); + }); + it('hits the correct Reddit search endpoint', async () => { const { redditSearch } = await import('../sources.js'); const calls = []; From 9c33fd272ba838fd63a4a76ced019006287f5f2e Mon Sep 17 00:00:00 2001 From: Sreeram Akhil Date: Sat, 22 Aug 2026 10:24:00 +0530 Subject: [PATCH 6/8] fix(omnisearch): drop null/data-less Reddit children before normalization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit child?.data ?? {} silently converted null entries and entries missing a data object into fake normalized rows, which downstream research could count or render as real Reddit results. Fix: filter children to only those where child.data is a non-null object before slice/map. Add regression test: a mix of null, no-data, and null-data children alongside one valid entry — only the valid entry must appear in results. --- plugins/omnisearch/sources.js | 5 +++- plugins/omnisearch/test/research.test.js | 30 ++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/plugins/omnisearch/sources.js b/plugins/omnisearch/sources.js index 02562dbe..8efca5b9 100644 --- a/plugins/omnisearch/sources.js +++ b/plugins/omnisearch/sources.js @@ -205,7 +205,10 @@ export async function redditSearch(query, limit) { }, { source: 'Reddit' }); const json = await res.json(); const children = Array.isArray(json?.data?.children) ? json.data.children : []; - return children.slice(0, limit).map((child) => { + return children + .filter((child) => child?.data && typeof child.data === 'object') + .slice(0, limit) + .map((child) => { const d = child?.data ?? {}; const createdAt = new Date(d.created_utc ? Number(d.created_utc) * 1000 : NaN); return { diff --git a/plugins/omnisearch/test/research.test.js b/plugins/omnisearch/test/research.test.js index 9efb4d78..06de88d7 100644 --- a/plugins/omnisearch/test/research.test.js +++ b/plugins/omnisearch/test/research.test.js @@ -119,6 +119,36 @@ describe('redditSearch', () => { expect(rows[0].createdAt).toBe(''); }); + it('drops null or data-less children before normalization', async () => { + const { redditSearch } = await import('../sources.js'); + stubFetch(() => ({ + data: { + children: [ + null, + { kind: 't3' }, // no data field + { kind: 't3', data: null }, // data is null not object + { + kind: 't3', + data: { + title: 'Valid post', + author: 'op', + score: 5, + num_comments: 1, + created_utc: 1700000000, + url: 'https://example.com/valid', + selftext: '', + permalink: '/r/test/comments/valid/', + }, + }, + ], + }, + })); + const rows = await redditSearch('test', 10); + // Only the valid entry should appear + expect(rows).toHaveLength(1); + expect(rows[0].title).toBe('Valid post'); + }); + it('hits the correct Reddit search endpoint', async () => { const { redditSearch } = await import('../sources.js'); const calls = []; From 89073d7ed03aa178687494e86de7da8cd653786b Mon Sep 17 00:00:00 2001 From: Sreeram Akhil Date: Sat, 22 Aug 2026 10:30:30 +0530 Subject: [PATCH 7/8] fix(omnisearch): reject array-shaped child.data in redditSearch typeof [] === 'object' is true in JavaScript, so {data:[]} passed the previous filter and mapped to a fake row that could displace a valid result when limit was applied after slicing. Fix: add !Array.isArray(child.data) to the filter so only plain objects are accepted as valid post data. Add regression test: one array-data entry + one valid entry with limit=1 asserts the valid result is returned, not the array-shaped fake. --- plugins/omnisearch/sources.js | 2 +- plugins/omnisearch/test/research.test.js | 27 ++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/plugins/omnisearch/sources.js b/plugins/omnisearch/sources.js index 8efca5b9..12f7d262 100644 --- a/plugins/omnisearch/sources.js +++ b/plugins/omnisearch/sources.js @@ -206,7 +206,7 @@ export async function redditSearch(query, limit) { const json = await res.json(); const children = Array.isArray(json?.data?.children) ? json.data.children : []; return children - .filter((child) => child?.data && typeof child.data === 'object') + .filter((child) => child?.data && typeof child.data === 'object' && !Array.isArray(child.data)) .slice(0, limit) .map((child) => { const d = child?.data ?? {}; diff --git a/plugins/omnisearch/test/research.test.js b/plugins/omnisearch/test/research.test.js index 06de88d7..98e9a3dc 100644 --- a/plugins/omnisearch/test/research.test.js +++ b/plugins/omnisearch/test/research.test.js @@ -149,6 +149,33 @@ describe('redditSearch', () => { expect(rows[0].title).toBe('Valid post'); }); + it('excludes children with array-shaped data that would displace valid results', async () => { + const { redditSearch } = await import('../sources.js'); + stubFetch(() => ({ + data: { + children: [ + { kind: 't3', data: [] }, // array passes typeof 'object' — must be rejected + { + kind: 't3', + data: { + title: 'Real result', + author: 'op', + score: 10, + num_comments: 2, + created_utc: 1700000000, + url: 'https://example.com/real', + selftext: '', + permalink: '/r/test/comments/real/', + }, + }, + ], + }, + })); + const rows = await redditSearch('test', 1); + expect(rows).toHaveLength(1); + expect(rows[0].title).toBe('Real result'); + }); + it('hits the correct Reddit search endpoint', async () => { const { redditSearch } = await import('../sources.js'); const calls = []; From 8c93f2f0a185abafec70e23fbcf1887a6988f906 Mon Sep 17 00:00:00 2001 From: Sreeram Akhil Date: Sat, 22 Aug 2026 10:36:18 +0530 Subject: [PATCH 8/8] fix(omnisearch): bound Reddit requests with a 10-second AbortSignal timeout Without a timeout, a slow or stalled Reddit connection could keep the fetch pending indefinitely, blocking the entire research aggregation. Fix: pass AbortSignal.timeout(10_000) in the get() init options so the request is automatically aborted after 10 seconds. The existing headers and response handling are preserved. --- plugins/omnisearch/sources.js | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/omnisearch/sources.js b/plugins/omnisearch/sources.js index 12f7d262..72ab7cbe 100644 --- a/plugins/omnisearch/sources.js +++ b/plugins/omnisearch/sources.js @@ -202,6 +202,7 @@ export async function redditSearch(query, limit) { url.searchParams.set('limit', String(Math.min(limit, 100))); const res = await get(url, { headers: { 'User-Agent': 'Mozilla/5.0 (compatible; OmniSearch/0.1; +https://github.com/agentrhq/webcmd)' }, + signal: AbortSignal.timeout(10_000), }, { source: 'Reddit' }); const json = await res.json(); const children = Array.isArray(json?.data?.children) ? json.data.children : [];