diff --git a/plugin-command-manifest.json b/plugin-command-manifest.json index c812e0a1..7e7206e8 100644 --- a/plugin-command-manifest.json +++ b/plugin-command-manifest.json @@ -8720,6 +8720,41 @@ "modulePath": "plugins/dockerhub/search.js", "sourceFile": "plugins/dockerhub/search.js" }, + { + "site": "dockerhub", + "name": "tags", + "description": "List public tags for a Docker Hub repository", + "access": "read", + "domain": "hub.docker.com", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "image", + "type": "str", + "required": true, + "positional": true, + "help": "Image name (e.g. \"nginx\", \"library/nginx\", \"bitnami/redis\")" + }, + { + "name": "limit", + "type": "int", + "default": 25, + "required": false, + "help": "Maximum tags to return (1-100)" + } + ], + "columns": [ + "tag", + "lastUpdated", + "size", + "architectures", + "url" + ], + "type": "js", + "modulePath": "plugins/dockerhub/tags.js", + "sourceFile": "plugins/dockerhub/tags.js" + }, { "site": "duckduckgo", "name": "search", @@ -17213,6 +17248,13 @@ "default": 10, "required": false, "help": "Maximum versions to return (1-50)" + }, + { + "name": "prereleases", + "type": "boolean", + "default": false, + "required": false, + "help": "Include prerelease and build versions (e.g. alpha, beta, rc, canary)" } ], "columns": [ @@ -17641,10 +17683,54 @@ "modulePath": "plugins/omnisearch/lobsters.js", "sourceFile": "plugins/omnisearch/lobsters.js" }, + { + "site": "omnisearch", + "name": "packages", + "description": "Search across 6 major package registries simultaneously (npm, Crates.io, NuGet, RubyGems, Packagist, Maven Central)", + "access": "read", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "query", + "type": "str", + "required": true, + "positional": true, + "help": "Library name or search keyword" + }, + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "help": "Maximum total results to return" + }, + { + "name": "registries", + "type": "str", + "default": "npm,crates,nuget,rubygems,packagist,maven", + "required": false, + "help": "Comma-separated registries to query (default: all)" + } + ], + "columns": [ + "registry", + "name", + "version", + "description", + "url" + ], + "tags": [ + "search" + ], + "type": "js", + "modulePath": "plugins/omnisearch/packages.js", + "sourceFile": "plugins/omnisearch/packages.js" + }, { "site": "omnisearch", "name": "research", - "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)", "access": "read", "strategy": "public", "browser": false, @@ -17666,7 +17752,7 @@ { "name": "sources", "type": "str", - "default": "hn,lobsters,stackoverflow,devto,github,arxiv", + "default": "hn,lobsters,stackoverflow,devto,github,arxiv,reddit", "required": false, "help": "Comma-separated sources to query (default: all)" } @@ -20486,6 +20572,43 @@ "sourceFile": "plugins/reddit/comment.js", "navigateBefore": "https://reddit.com" }, + { + "site": "reddit", + "name": "draft-comment", + "description": "Draft a comment on a Reddit post without submitting it", + "access": "write", + "example": "webcmd reddit draft-comment --window foreground", + "domain": "reddit.com", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "post-id", + "type": "string", + "required": true, + "positional": true, + "help": "Post ID (e.g. 1abc123), t3 fullname, or Reddit post URL" + }, + { + "name": "text", + "type": "string", + "required": true, + "positional": true, + "help": "Comment text to leave in the composer" + } + ], + "columns": [ + "status", + "message", + "url" + ], + "type": "js", + "modulePath": "plugins/reddit/draft-comment.js", + "sourceFile": "plugins/reddit/draft-comment.js", + "navigateBefore": false, + "siteSession": "persistent", + "freshPage": true + }, { "site": "reddit", "name": "frontpage", diff --git a/plugins/dockerhub/image.js b/plugins/dockerhub/image.js index ce354b4a..6de34698 100644 --- a/plugins/dockerhub/image.js +++ b/plugins/dockerhub/image.js @@ -6,16 +6,7 @@ // one-row projection: official-flag, star / pull counters, last-updated / // registered timestamps, repo status, short description, hub URL. import { cli, Strategy } from '@agentrhq/webcmd/registry'; -import { HUB_BASE, hubFetch, parseImage } from './utils.js'; - -function trimDate(value) { - const s = String(value ?? '').trim(); - if (!s) return null; - // Docker Hub returns mixed precision (`...45Z` and `...35.286495Z`). Drop - // the fractional part so all timestamp columns share `YYYY-MM-DDTHH:MM:SSZ`. - const noFrac = s.replace(/\.\d+/, ''); - return noFrac.endsWith('Z') ? noFrac : `${noFrac}Z`; -} +import { HUB_BASE, hubFetch, parseImage, trimDate } from './utils.js'; cli({ site: 'dockerhub', diff --git a/plugins/dockerhub/tags.js b/plugins/dockerhub/tags.js new file mode 100644 index 00000000..18bc194f --- /dev/null +++ b/plugins/dockerhub/tags.js @@ -0,0 +1,54 @@ +// dockerhub tags — list public tags for a Docker Hub repository. +// +// Hits `https://hub.docker.com/v2/repositories///tags/?page_size=`. +// Returns normalized rows of tags: tag, lastUpdated, size, architectures, url. +import { cli, Strategy } from '@agentrhq/webcmd/registry'; +import { EmptyResultError } from '@agentrhq/webcmd/errors'; +import { HUB_BASE, hubFetch, parseImage, requireBoundedInt, trimDate } from './utils.js'; + +cli({ + site: 'dockerhub', + name: 'tags', + access: 'read', + description: 'List public tags for a Docker Hub repository', + domain: 'hub.docker.com', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'image', positional: true, required: true, help: 'Image name (e.g. "nginx", "library/nginx", "bitnami/redis")' }, + { name: 'limit', type: 'int', default: 25, help: 'Maximum tags to return (1-100)' }, + ], + columns: ['tag', 'lastUpdated', 'size', 'architectures', 'url'], + func: async (args) => { + const { owner, name } = parseImage(args.image); + const limit = requireBoundedInt(args.limit, 25, 100); + const url = `${HUB_BASE}/repositories/${owner}/${name}/tags/?page_size=${limit}`; + const body = await hubFetch(url, 'dockerhub tags'); + const list = Array.isArray(body?.results) ? body.results : []; + if (!list.length) { + throw new EmptyResultError('dockerhub tags', `No tags found for repository "${args.image}".`); + } + + return list.slice(0, limit).map((t) => { + const tag = String(t.name ?? '').trim(); + const lastUpdated = trimDate(t.last_updated ?? t.tag_last_pushed); + const sizeBytes = t.full_size != null ? Number(t.full_size) : 0; + // Convert to MB with 2 decimal places + const sizeMB = sizeBytes > 0 ? `${(sizeBytes / (1024 * 1024)).toFixed(2)} MB` : '0.00 MB'; + + // Extract distinct architectures + const images = Array.isArray(t.images) ? t.images : []; + const archs = [...new Set(images.map(img => img.architecture).filter(Boolean))].join(', '); + + const imageSlug = owner === 'library' ? `library/${name}` : `${owner}/${name}`; + + return { + tag, + lastUpdated, + size: sizeMB, + architectures: archs || 'unknown', + url: `https://hub.docker.com/r/${imageSlug}/tags?name=${encodeURIComponent(tag)}`, + }; + }); + }, +}); diff --git a/plugins/dockerhub/test/tags.test.js b/plugins/dockerhub/test/tags.test.js new file mode 100644 index 00000000..4a16ca40 --- /dev/null +++ b/plugins/dockerhub/test/tags.test.js @@ -0,0 +1,116 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { afterAll, afterEach, test, vi } 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 }); + 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 { + // Directory is not empty; leave local state alone. + } + } +}); + +afterEach(() => vi.unstubAllGlobals()); + +const { getRegistry } = await import('@agentrhq/webcmd/registry'); +await Promise.all([ + import('../tags.js'), + import('../image.js'), + import('../search.js'), +]); + +const TAGS_PAYLOAD = { + results: [ + { + name: 'latest', + last_updated: '2026-08-25T10:52:43.606795Z', + full_size: 75487477, + images: [ + { architecture: 'amd64', os: 'linux', size: 75273372 }, + { architecture: 'arm64', os: 'linux', size: 73518870 }, + ], + }, + { + name: '1.25', + last_updated: '2026-08-20T08:00:00Z', + full_size: 75000000, + images: [ + { architecture: 'amd64', os: 'linux', size: 75000000 }, + ], + } + ] +}; + +function stubFetch(payload, { ok = true, status = 200 } = {}) { + vi.stubGlobal('fetch', async () => { + return new Response(JSON.stringify(payload), { + status, + headers: { 'content-type': 'application/json' }, + }); + }); +} + +test('dockerhub tags lists public tags with size, architectures and urls', async () => { + stubFetch(TAGS_PAYLOAD); + const command = getRegistry().get('dockerhub/tags'); + const rows = await command.func({ image: 'nginx', limit: 2 }); + + assert.equal(rows.length, 2); + + // Tag 1 + assert.equal(rows[0].tag, 'latest'); + assert.equal(rows[0].lastUpdated, '2026-08-25T10:52:43Z'); + assert.equal(rows[0].size, '71.99 MB'); + assert.equal(rows[0].architectures, 'amd64, arm64'); + assert.equal(rows[0].url, 'https://hub.docker.com/r/library/nginx/tags?name=latest'); + + // Tag 2 + assert.equal(rows[1].tag, '1.25'); + assert.equal(rows[1].size, '71.53 MB'); + assert.equal(rows[1].architectures, 'amd64'); +}); + +test('dockerhub tags respects limit', async () => { + stubFetch(TAGS_PAYLOAD); + const command = getRegistry().get('dockerhub/tags'); + const rows = await command.func({ image: 'library/nginx', limit: 1 }); + + assert.equal(rows.length, 1); + assert.equal(rows[0].tag, 'latest'); +}); + +test('dockerhub tags rejects invalid image names', async () => { + const command = getRegistry().get('dockerhub/tags'); + await assert.rejects( + () => command.func({ image: 'invalid/image/name/extra' }), + /slug/, + ); +}); + +test('dockerhub tags handles empty result error', async () => { + stubFetch({ results: [] }); + const command = getRegistry().get('dockerhub/tags'); + await assert.rejects( + () => command.func({ image: 'nginx' }), + /returned no data/, + ); +}); diff --git a/plugins/dockerhub/utils.js b/plugins/dockerhub/utils.js index 50f83ca6..5270c4f4 100644 --- a/plugins/dockerhub/utils.js +++ b/plugins/dockerhub/utils.js @@ -98,3 +98,12 @@ export async function hubFetch(url, label) { } return body; } + +export function trimDate(value) { + const s = String(value ?? '').trim(); + if (!s) return null; + // Docker Hub returns mixed precision (`...45Z` and `...35.286495Z`). Drop + // the fractional part so all timestamp columns share `YYYY-MM-DDTHH:MM:SSZ`. + const noFrac = s.replace(/\.\d+/, ''); + return noFrac.endsWith('Z') ? noFrac : `${noFrac}Z`; +} diff --git a/plugins/npm/test/npm.test.js b/plugins/npm/test/npm.test.js index 2c9f107c..4f921ea7 100644 --- a/plugins/npm/test/npm.test.js +++ b/plugins/npm/test/npm.test.js @@ -95,11 +95,13 @@ const SEARCH_PAYLOAD = { // Helpers // --------------------------------------------------------------------------- function fakeRequest(payload, { ok = true, status = 200 } = {}) { - const req = async (url, _opts) => { + const req = async (url, opts) => { req.calls.push(String(url)); + req.opts.push(opts); return { ok, status, json: async () => payload }; }; req.calls = []; + req.opts = []; return req; } @@ -269,13 +271,45 @@ test('npm versions sorts correctly when two versions share the same date', async }); }); +test('npm versions filters out prereleases by default and includes them with flag', async () => { + const prereleasePayload = { + name: 'exlib', + 'dist-tags': { latest: '2.1.0' }, + versions: { + '2.0.0': { description: 'v2.0.0' }, + '2.1.0': { description: 'v2.1.0' }, + '2.2.0-beta.0': { description: 'v2.2.0-beta.0' }, + }, + time: { + created: '2025-01-01T00:00:00.000Z', + modified: '2026-07-01T00:00:00.000Z', + '2.0.0': '2025-03-10T08:00:00.000Z', + '2.1.0': '2026-06-15T12:00:00.000Z', + '2.2.0-beta.0': '2026-07-01T00:00:00.000Z', + }, + }; + await withFetch(prereleasePayload, async () => { + // By default, prerelease (2.2.0-beta.0) is filtered out + const defaultRows = await versionsNpm({ name: 'exlib', limit: 10 }); + assert.equal(defaultRows.length, 2); + assert.equal(defaultRows[0].version, '2.1.0'); + assert.equal(defaultRows[1].version, '2.0.0'); + + // With prereleases: true flag, prereleases are returned + const allRows = await versionsNpm({ name: 'exlib', limit: 10, prereleases: true }); + assert.equal(allRows.length, 3); + assert.equal(allRows[0].version, '2.2.0-beta.0'); + assert.equal(allRows[1].version, '2.1.0'); + assert.equal(allRows[2].version, '2.0.0'); + }); +}); + test('npm versions rejects out-of-range limit', async () => { await assert.rejects( () => versionsNpm({ name: 'exlib', limit: 51 }), /50/, ); }); - // --------------------------------------------------------------------------- // npm downloads // --------------------------------------------------------------------------- @@ -339,3 +373,16 @@ test('all npm commands are browser-free', () => { assert.equal(cmd.browser, false, `${name} should not require a browser`); } }); + +test('npm commands pass a 10-second timeout AbortSignal to fetch requests', async () => { + const req = fakeRequest(REGISTRY_PAYLOAD); + const original = globalThis.fetch; + globalThis.fetch = req; + try { + await versionsNpm({ name: 'exlib' }); + assert.equal(req.opts.length, 1); + assert.ok(req.opts[0].signal instanceof AbortSignal); + } finally { + globalThis.fetch = original; + } +}); diff --git a/plugins/npm/utils.js b/plugins/npm/utils.js index fd2aaaed..8e0c4f2b 100644 --- a/plugins/npm/utils.js +++ b/plugins/npm/utils.js @@ -45,7 +45,10 @@ export function requireBoundedInt(value, defaultValue, maxValue, label = 'limit' export async function npmFetch(url, label) { let resp; try { - resp = await fetch(url, { headers: { 'user-agent': UA, accept: 'application/json' } }); + resp = await fetch(url, { + headers: { 'user-agent': UA, accept: 'application/json' }, + signal: AbortSignal.timeout(10_000), + }); } catch (err) { throw new CommandExecutionError( diff --git a/plugins/npm/versions.js b/plugins/npm/versions.js index 20bc8441..659a48d8 100644 --- a/plugins/npm/versions.js +++ b/plugins/npm/versions.js @@ -22,7 +22,8 @@ export async function versionsNpm(args) { // 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') - .filter(([version]) => !version.includes('-')) + // filter out prereleases by default (they contain a hyphen, e.g. 19.0.0-rc.0) + .filter(([version]) => args.prereleases || !version.includes('-')) // 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 ?? ''))) @@ -51,6 +52,7 @@ cli({ 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)' }, + { name: 'prereleases', type: 'boolean', default: false, help: 'Include prerelease and build versions (e.g. alpha, beta, rc, canary)' }, ], columns: ['version', 'publishedAt', 'isLatest', 'url'], func: (args) => versionsNpm(args), diff --git a/plugins/omnisearch/packages.js b/plugins/omnisearch/packages.js new file mode 100644 index 00000000..9f11b9f4 --- /dev/null +++ b/plugins/omnisearch/packages.js @@ -0,0 +1,197 @@ +/** + * omnisearch packages — aggregate library searches across package registries. + * + * Parallel-queries npm, crates.io, NuGet, RubyGems, Packagist, and Maven Central. + * Returns normalized rows. Useful for agents checking library support/availability. + */ +import { cli, Strategy } from '@agentrhq/webcmd/registry'; +import { ArgumentError, CommandExecutionError, EmptyResultError } from '@agentrhq/webcmd/errors'; + +const UA = 'webcmd-omnisearch-packages (+https://github.com/agentrhq/webcmd)'; + +async function get(url, init, { source } = {}) { + let res; + try { + res = await fetch(url, { + signal: AbortSignal.timeout(10_000), + ...init, + headers: { + 'user-agent': UA, + accept: 'application/json', + ...(init?.headers ?? {}), + }, + }); + } catch (err) { + throw new CommandExecutionError( + `OmniSearch: ${source} request failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + if (!res.ok) { + throw new CommandExecutionError(`OmniSearch: ${source} HTTP ${res.status}`); + } + return res; +} + +// Fetchers +async function npmSearch(query, limit) { + const url = `https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(query)}&size=${limit}`; + const res = await get(url, {}, { source: 'npm' }); + const body = await res.json(); + const list = Array.isArray(body?.objects) ? body.objects : []; + return list.slice(0, limit).map((item) => { + const pkg = item?.package ?? {}; + return { + registry: 'npm', + name: String(pkg.name ?? ''), + version: String(pkg.version ?? ''), + description: String(pkg.description ?? '').trim(), + url: pkg.links?.npm ? String(pkg.links.npm) : (pkg.name ? `https://www.npmjs.com/package/${pkg.name}` : ''), + }; + }); +} + +async function cratesSearch(query, limit) { + const url = `https://crates.io/api/v1/crates?q=${encodeURIComponent(query)}&per_page=${limit}`; + const res = await get(url, {}, { source: 'crates' }); + const body = await res.json(); + const list = Array.isArray(body?.crates) ? body.crates : []; + return list.slice(0, limit).map((c) => ({ + registry: 'crates', + name: String(c.name ?? c.id ?? ''), + version: String(c.newest_version ?? c.max_stable_version ?? c.max_version ?? ''), + description: String(c.description ?? '').trim(), + url: c.name ? `https://crates.io/crates/${c.name}` : '', + })); +} + +async function nugetSearch(query, limit) { + const url = `https://azuresearch-usnc.nuget.org/query?q=${encodeURIComponent(query)}&take=${limit}&prerelease=false`; + const res = await get(url, {}, { source: 'nuget' }); + const body = await res.json(); + const list = Array.isArray(body?.data) ? body.data : []; + return list.slice(0, limit).map((pkg) => ({ + registry: 'nuget', + name: String(pkg.id ?? ''), + version: String(pkg.version ?? ''), + description: String(pkg.description ?? '').trim(), + url: pkg.id ? `https://www.nuget.org/packages/${pkg.id}` : '', + })); +} + +async function rubygemsSearch(query, limit) { + const url = `https://rubygems.org/api/v1/search.json?query=${encodeURIComponent(query)}&page=1`; + const res = await get(url, {}, { source: 'rubygems' }); + const body = await res.json(); + const list = Array.isArray(body) ? body : []; + return list.slice(0, limit).map((g) => { + const name = String(g.name ?? '').trim(); + return { + registry: 'rubygems', + name, + version: String(g.version ?? '').trim(), + description: String(g.info ?? '').trim(), + url: name ? `https://rubygems.org/gems/${name}` : '', + }; + }); +} + +async function packagistSearch(query, limit) { + const url = `https://packagist.org/search.json?q=${encodeURIComponent(query)}&per_page=${limit}`; + const res = await get(url, {}, { source: 'packagist' }); + const body = await res.json(); + const list = Array.isArray(body?.results) ? body.results : []; + return list.slice(0, limit).map((row) => ({ + registry: 'packagist', + name: String(row.name ?? '').trim(), + version: '', + description: String(row.description ?? '').trim(), + url: String(row.url ?? '').trim(), + })); +} + +async function mavenSearch(query, limit) { + const url = `https://search.maven.org/solrsearch/select?q=${encodeURIComponent(query)}&rows=${limit}&wt=json`; + const res = await get(url, {}, { source: 'maven' }); + const body = await res.json(); + const list = Array.isArray(body?.response?.docs) ? body.response.docs : []; + return list.slice(0, limit).map((d) => { + const groupId = String(d.g ?? '').trim(); + const artifactId = String(d.a ?? '').trim(); + const coord = groupId && artifactId ? `${groupId}:${artifactId}` : ''; + return { + registry: 'maven', + name: coord, + version: String(d.latestVersion ?? '').trim(), + description: `${d.p ?? ''} package`, + url: coord ? `https://central.sonatype.com/artifact/${groupId}/${artifactId}` : '', + }; + }); +} + +function requireQuery(value) { + const s = String(value ?? '').trim(); + if (!s) throw new ArgumentError('a search query is required'); + return s; +} + +cli({ + site: 'omnisearch', + name: 'packages', + tags: ['search'], + access: 'read', + description: 'Search across 6 major package registries simultaneously (npm, Crates.io, NuGet, RubyGems, Packagist, Maven Central)', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'query', positional: true, required: true, help: 'Library name or search keyword' }, + { name: 'limit', type: 'int', default: 10, help: 'Maximum total results to return' }, + { + name: 'registries', + default: 'npm,crates,nuget,rubygems,packagist,maven', + help: 'Comma-separated registries to query (default: all)', + }, + ], + columns: ['registry', 'name', 'version', 'description', 'url'], + func: async (kwargs) => { + const query = requireQuery(kwargs.query); + const raw = Number(kwargs.limit ?? 10); + if (!Number.isInteger(raw) || raw <= 0) { + throw new ArgumentError('limit must be a positive integer'); + } + const limit = Math.min(raw, 50); + + const wanted = String(kwargs.registries ?? '') + .split(',') + .map((s) => s.trim().toLowerCase()) + .filter(Boolean); + + const fetchers = { + npm: (lim) => npmSearch(query, lim), + crates: (lim) => cratesSearch(query, lim), + nuget: (lim) => nugetSearch(query, lim), + rubygems: (lim) => rubygemsSearch(query, lim), + packagist: (lim) => packagistSearch(query, lim), + maven: (lim) => mavenSearch(query, lim), + }; + + const selected = wanted.length ? wanted.filter((s) => fetchers[s]) : Object.keys(fetchers); + const perRegistry = Math.ceil(limit / Math.max(selected.length, 1)); + + let rows = []; + try { + // Failure isolation: one rate-limited or erroring registry must not wipe out the others. + const outcomes = await Promise.allSettled(selected.map((key) => fetchers[key](perRegistry))); + rows = outcomes + .filter((o) => o.status === 'fulfilled') + .flatMap((o) => o.value); + } catch (err) { + throw new CommandExecutionError(`packages aggregation failed: ${err instanceof Error ? err.message : String(err)}`); + } + + if (!rows.length) { + throw new EmptyResultError('omnisearch/packages', `no packages found across registries for "${query}"`); + } + + return rows.slice(0, limit); + }, +}); diff --git a/plugins/omnisearch/research.js b/plugins/omnisearch/research.js index ef636f5f..684f91cb 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)', }, ], @@ -55,12 +56,13 @@ cli({ .filter(Boolean); const fetchers = { - hn: () => hnSearch(query, perPlatform), - lobsters: () => lobstersSearch(query, perPlatform), - stackoverflow: () => stackoverflowSearch(query, perPlatform), - devto: () => devtoSearch(query, perPlatform), - github: () => githubSearch(query, perPlatform), - arxiv: () => arxivSearch(query, perPlatform), + hn: (lim) => hnSearch(query, lim), + lobsters: (lim) => lobstersSearch(query, lim), + stackoverflow: (lim) => stackoverflowSearch(query, lim), + devto: (lim) => devtoSearch(query, lim), + github: (lim) => githubSearch(query, lim), + arxiv: (lim) => arxivSearch(query, lim), + reddit: (lim) => redditSearch(query, lim), }; const selected = wanted.length ? wanted.filter((s) => fetchers[s]) : Object.keys(fetchers); @@ -69,7 +71,7 @@ cli({ let rows; try { // Failure isolation: one rate-limited/erroring source must not wipe out the rest. - const outcomes = await Promise.allSettled(selected.map((key) => fetchers[key]())); + const outcomes = await Promise.allSettled(selected.map((key) => fetchers[key](perPlatform))); rows = outcomes .filter((o) => o.status === 'fulfilled') .flatMap((o) => o.value); diff --git a/plugins/omnisearch/sources.js b/plugins/omnisearch/sources.js index 5fedf25a..93d23095 100644 --- a/plugins/omnisearch/sources.js +++ b/plugins/omnisearch/sources.js @@ -10,7 +10,10 @@ import { CommandExecutionError } from '@agentrhq/webcmd/errors'; async function get(url, init, { source } = {}) { let res; try { - res = await fetch(url, init); + res = await fetch(url, { + signal: AbortSignal.timeout(10_000), + ...init, + }); } catch (err) { throw new CommandExecutionError( `OmniSearch: ${source} request failed: ${err instanceof Error ? err.message : String(err)}`, @@ -192,3 +195,36 @@ 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)' }, + signal: AbortSignal.timeout(10_000), + }, { source: 'Reddit' }); + 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' && !Array.isArray(child.data)) + .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: 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/packages.test.js b/plugins/omnisearch/test/packages.test.js new file mode 100644 index 00000000..c0e894ef --- /dev/null +++ b/plugins/omnisearch/test/packages.test.js @@ -0,0 +1,215 @@ +import assert from 'node:assert/strict'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { getRegistry } from '@agentrhq/webcmd/registry'; +import '../packages.js'; + +afterEach(() => vi.unstubAllGlobals()); + +// Mock payloads +const NPM_PAYLOAD = { + objects: [ + { + package: { + name: 'lodash', + version: '4.17.21', + description: 'Lodash utilities', + links: { npm: 'https://www.npmjs.com/package/lodash' }, + }, + }, + ], +}; + +const CRATES_PAYLOAD = { + crates: [ + { + name: 'serde', + max_version: '1.0.152', + description: 'A generic serialization/deserialization framework', + }, + ], +}; + +const NUGET_PAYLOAD = { + data: [ + { + id: 'Newtonsoft.Json', + version: '13.0.1', + description: 'Json.NET is a popular high-performance JSON framework', + }, + ], +}; + +const RUBYGEMS_PAYLOAD = [ + { + name: 'rails', + version: '7.0.4', + info: 'Ruby on Rails is a full-stack web framework', + }, +]; + +const PACKAGIST_PAYLOAD = { + results: [ + { + name: 'monolog/monolog', + description: 'Sends your logs to files, sockets, inboxes, databases', + url: 'https://packagist.org/packages/monolog/monolog', + }, + ], +}; + +const MAVEN_PAYLOAD = { + response: { + docs: [ + { + g: 'com.google.guava', + a: 'guava', + latestVersion: '31.1-jre', + p: 'jar', + }, + ], + }, +}; + +function stubRegistryFetch(handler) { + vi.stubGlobal('fetch', async (url) => { + const resBody = handler(String(url)); + if (!resBody) { + return new Response('Not Found', { status: 404 }); + } + return new Response(JSON.stringify(resBody), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }); +} + +describe('omnisearch packages integration', () => { + it('queries all registries and returns a unified schema by default', async () => { + stubRegistryFetch((url) => { + if (url.includes('registry.npmjs.org')) return NPM_PAYLOAD; + if (url.includes('crates.io')) return CRATES_PAYLOAD; + if (url.includes('nuget.org')) return NUGET_PAYLOAD; + if (url.includes('rubygems.org')) return RUBYGEMS_PAYLOAD; + if (url.includes('packagist.org')) return PACKAGIST_PAYLOAD; + if (url.includes('search.maven.org')) return MAVEN_PAYLOAD; + return null; + }); + + const command = getRegistry().get('omnisearch/packages'); + const rows = await command.func({ query: 'test', limit: 6 }); + + expect(rows).toHaveLength(6); + + // npm + const npmRow = rows.find((r) => r.registry === 'npm'); + expect(npmRow).toBeDefined(); + expect(npmRow.name).toBe('lodash'); + expect(npmRow.version).toBe('4.17.21'); + expect(npmRow.description).toBe('Lodash utilities'); + expect(npmRow.url).toBe('https://www.npmjs.com/package/lodash'); + + // crates + const cratesRow = rows.find((r) => r.registry === 'crates'); + expect(cratesRow).toBeDefined(); + expect(cratesRow.name).toBe('serde'); + expect(cratesRow.version).toBe('1.0.152'); + expect(cratesRow.description).toContain('serialization'); + expect(cratesRow.url).toBe('https://crates.io/crates/serde'); + + // nuget + const nugetRow = rows.find((r) => r.registry === 'nuget'); + expect(nugetRow).toBeDefined(); + expect(nugetRow.name).toBe('Newtonsoft.Json'); + expect(nugetRow.version).toBe('13.0.1'); + expect(nugetRow.url).toBe('https://www.nuget.org/packages/Newtonsoft.Json'); + + // rubygems + const rubygemsRow = rows.find((r) => r.registry === 'rubygems'); + expect(rubygemsRow).toBeDefined(); + expect(rubygemsRow.name).toBe('rails'); + expect(rubygemsRow.version).toBe('7.0.4'); + + // packagist + const packagistRow = rows.find((r) => r.registry === 'packagist'); + expect(packagistRow).toBeDefined(); + expect(packagistRow.name).toBe('monolog/monolog'); + expect(packagistRow.url).toBe('https://packagist.org/packages/monolog/monolog'); + + // maven + const mavenRow = rows.find((r) => r.registry === 'maven'); + expect(mavenRow).toBeDefined(); + expect(mavenRow.name).toBe('com.google.guava:guava'); + expect(mavenRow.version).toBe('31.1-jre'); + }); + + it('respects the registries filter', async () => { + stubRegistryFetch((url) => { + if (url.includes('registry.npmjs.org')) return NPM_PAYLOAD; + if (url.includes('crates.io')) return CRATES_PAYLOAD; + return null; + }); + + const command = getRegistry().get('omnisearch/packages'); + const rows = await command.func({ query: 'json', limit: 10, registries: 'npm,crates' }); + + expect(rows).toHaveLength(2); + expect(rows.map((r) => r.registry).sort()).toEqual(['crates', 'npm']); + }); + + it('respects total limit', async () => { + stubRegistryFetch((url) => { + if (url.includes('registry.npmjs.org')) return NPM_PAYLOAD; + if (url.includes('crates.io')) return CRATES_PAYLOAD; + return null; + }); + + const command = getRegistry().get('omnisearch/packages'); + const rows = await command.func({ query: 'json', limit: 1, registries: 'npm,crates' }); + + expect(rows).toHaveLength(1); + }); + + it('tolerates failure of one or more registries (failure isolation)', async () => { + vi.stubGlobal('fetch', async (url) => { + if (url.includes('registry.npmjs.org')) { + return new Response('Rate Limited', { status: 429 }); + } + if (url.includes('crates.io')) { + return new Response(JSON.stringify(CRATES_PAYLOAD), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + return new Response('Not Found', { status: 404 }); + }); + + const command = getRegistry().get('omnisearch/packages'); + // Even though npm failed (429), crates.io succeeded, so we still get results + const rows = await command.func({ query: 'json', limit: 5, registries: 'npm,crates' }); + expect(rows).toHaveLength(1); + expect(rows[0].registry).toBe('crates'); + expect(rows[0].name).toBe('serde'); + }); + + it('is registered as browser: false', () => { + const command = getRegistry().get('omnisearch/packages'); + expect(command).toBeDefined(); + expect(command.browser).toBe(false); + }); + + it('passes a 10-second timeout AbortSignal to fetch', async () => { + let passedSignal = null; + vi.stubGlobal('fetch', async (url, init) => { + passedSignal = init?.signal; + return new Response(JSON.stringify(NPM_PAYLOAD), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }); + + const command = getRegistry().get('omnisearch/packages'); + await command.func({ query: 'lodash', limit: 1, registries: 'npm' }); + + expect(passedSignal).toBeInstanceOf(AbortSignal); + }); +}); diff --git a/plugins/omnisearch/test/research.test.js b/plugins/omnisearch/test/research.test.js index 699fe6cb..bbf08302 100644 --- a/plugins/omnisearch/test/research.test.js +++ b/plugins/omnisearch/test/research.test.js @@ -1,17 +1,267 @@ 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('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('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('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 = []; + 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 +271,28 @@ 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']); }); + + it('passes a 10-second timeout AbortSignal to all fetch requests', async () => { + let passedSignals = []; + vi.stubGlobal('fetch', async (url, init) => { + passedSignals.push(init?.signal); + return new Response(JSON.stringify(hnResponse([])), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }); + + const command = getRegistry().get('omnisearch/research'); + try { + await command.func({ query: 'test', limit: 1, sources: 'hn' }); + } catch (err) { + // Ignore EmptyResultError if empty results returned + } + + expect(passedSignals.length).toBeGreaterThan(0); + expect(passedSignals[0]).toBeInstanceOf(AbortSignal); + }); }); 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;