Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
affcad3
feat(npm): add versions command, test suite, and expanded README
sreeramakhil Aug 21, 2026
69faaaa
fix(npm): sort versions by full ISO timestamp before formatting
sreeramakhil Aug 21, 2026
5a2f02a
fix(npm): filter versions by body.versions to exclude time-only ghost…
sreeramakhil Aug 21, 2026
9722a44
Merge pull request #1 from sreeramakhil/feat/npm-plugin-versions-and-…
sreeramakhil Aug 21, 2026
c267318
feat(omnisearch): add Reddit as a 7th research source
sreeramakhil Aug 22, 2026
7934049
fix(omnisearch): guard against invalid created_utc in redditSearch
sreeramakhil Aug 22, 2026
9c33fd2
fix(omnisearch): drop null/data-less Reddit children before normaliza…
sreeramakhil Aug 22, 2026
89073d7
fix(omnisearch): reject array-shaped child.data in redditSearch
sreeramakhil Aug 22, 2026
8c93f2f
fix(omnisearch): bound Reddit requests with a 10-second AbortSignal t…
sreeramakhil Aug 22, 2026
5c143a2
Merge pull request #2 from sreeramakhil/feat/omnisearch-reddit-source
sreeramakhil Aug 22, 2026
eef96ea
fix(npm): address maintainer review feedback for npm versions command
sreeramakhil Aug 24, 2026
ad51874
feat(omnisearch): implement Universal Package Search (omnisearch pack…
sreeramakhil Aug 24, 2026
b38a832
merge: resolve conflicts and merge upstream/main into feat/npm-plugin…
sreeramakhil Aug 24, 2026
f601157
Merge remote-tracking branch 'origin/main' into feat/npm-plugin-versi…
sreeramakhil Aug 25, 2026
b19a190
fix(omnisearch,npm): add fetch timeouts and resolve closure initializ…
sreeramakhil Aug 25, 2026
ee64718
feat(dockerhub): add tags command and tests
sreeramakhil Aug 27, 2026
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
127 changes: 125 additions & 2 deletions plugin-command-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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": [
Expand Down Expand Up @@ -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,
Expand All @@ -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)"
}
Expand Down Expand Up @@ -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 <post-id-or-url> <text> --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",
Expand Down
11 changes: 1 addition & 10 deletions plugins/dockerhub/image.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
54 changes: 54 additions & 0 deletions plugins/dockerhub/tags.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// dockerhub tags — list public tags for a Docker Hub repository.
//
// Hits `https://hub.docker.com/v2/repositories/<owner>/<name>/tags/?page_size=<limit>`.
// 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)}`,
};
});
},
});
116 changes: 116 additions & 0 deletions plugins/dockerhub/test/tags.test.js
Original file line number Diff line number Diff line change
@@ -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/,
);
});
9 changes: 9 additions & 0 deletions plugins/dockerhub/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
}
Loading
Loading