diff --git a/cli-manifest.json b/cli-manifest.json
index c5344bfff..d2a4461aa 100644
--- a/cli-manifest.json
+++ b/cli-manifest.json
@@ -13729,6 +13729,51 @@
"siteSession": "persistent",
"defaultWindowMode": "foreground"
},
+ {
+ "site": "github",
+ "name": "trending",
+ "description": "GitHub Trending repositories (public, no login). Filter by --language and --since.",
+ "access": "read",
+ "domain": "github.com",
+ "strategy": "public",
+ "browser": false,
+ "args": [
+ {
+ "name": "since",
+ "type": "string",
+ "default": "daily",
+ "required": false,
+ "help": "Time range: daily / weekly / monthly"
+ },
+ {
+ "name": "language",
+ "type": "string",
+ "default": "",
+ "required": false,
+ "help": "Filter by programming language slug, e.g. python, rust, \"c++\""
+ },
+ {
+ "name": "limit",
+ "type": "int",
+ "default": 25,
+ "required": false,
+ "help": "Number of repositories to return (max 25)"
+ }
+ ],
+ "columns": [
+ "rank",
+ "repo",
+ "description",
+ "language",
+ "stars",
+ "forks",
+ "starsSince",
+ "url"
+ ],
+ "type": "js",
+ "modulePath": "github/trending.js",
+ "sourceFile": "github/trending.js"
+ },
{
"site": "github",
"name": "whoami",
diff --git a/clis/github/trending.js b/clis/github/trending.js
new file mode 100644
index 000000000..50fd4c249
--- /dev/null
+++ b/clis/github/trending.js
@@ -0,0 +1,139 @@
+// github trending — repositories from https://github.com/trending (public HTML, no auth).
+import { cli, Strategy } from '@jackwener/opencli/registry';
+import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
+
+const SINCE = {
+ daily: 'daily',
+ weekly: 'weekly',
+ monthly: 'monthly',
+};
+
+function decodeHtmlEntities(value) {
+ return String(value ?? '')
+ .replace(/&/g, '&')
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/"/g, '"')
+ .replace(/?39;/g, "'")
+ .replace(/'/gi, "'")
+ .replace(/ /g, ' ');
+}
+
+function stripTags(value) {
+ return String(value ?? '').replace(/<[^>]*>/g, '');
+}
+
+function parseCount(value) {
+ if (value == null) return null;
+ const digits = String(value).replace(/[,\s]/g, '');
+ if (!/^\d+$/.test(digits)) return null;
+ return Number(digits);
+}
+
+function parseTrendingHtml(html, limit) {
+ const blocks = html.split('').slice(1);
+ const rows = [];
+ for (const raw of blocks) {
+ const block = raw.split('')[0];
+
+ const nameMatch = block.match(/
([\s\S]*?)<\/p>/);
+ const description = descMatch
+ ? decodeHtmlEntities(stripTags(descMatch[1]).replace(/\s+/g, ' ')).trim()
+ : '';
+
+ const langMatch = block.match(/([\s\S]*?)<\/span>/);
+ const language = langMatch ? decodeHtmlEntities(stripTags(langMatch[1])).trim() : null;
+
+ const starsMatch = block.match(/\/stargazers"[\s\S]*?>\s*([\d,]+)\s*<\/a>/);
+ const forksMatch = block.match(/\/forks"[\s\S]*?>\s*([\d,]+)\s*<\/a>/);
+ const sinceMatch = block.match(/([\d,]+)\s+stars\s+(?:today|this week|this month)/i);
+
+ rows.push({
+ repo,
+ description,
+ language,
+ stars: parseCount(starsMatch?.[1]),
+ forks: parseCount(forksMatch?.[1]),
+ starsSince: parseCount(sinceMatch?.[1]),
+ url: `https://github.com/${repo}`,
+ });
+ if (rows.length >= limit) break;
+ }
+ return rows;
+}
+
+cli({
+ site: 'github',
+ name: 'trending',
+ access: 'read',
+ description: 'GitHub Trending repositories (public, no login). Filter by --language and --since.',
+ domain: 'github.com',
+ strategy: Strategy.PUBLIC,
+ browser: false,
+ args: [
+ { name: 'since', type: 'string', default: 'daily', help: 'Time range: daily / weekly / monthly' },
+ { name: 'language', type: 'string', default: '', help: 'Filter by programming language slug, e.g. python, rust, "c++"' },
+ { name: 'limit', type: 'int', default: 25, help: 'Number of repositories to return (max 25)' },
+ ],
+ columns: ['rank', 'repo', 'description', 'language', 'stars', 'forks', 'starsSince', 'url'],
+ func: async (args) => {
+ const sinceKey = String(args.since ?? 'daily').toLowerCase();
+ const since = SINCE[sinceKey];
+ if (!since) {
+ throw new ArgumentError(`Unknown --since "${sinceKey}". Valid: ${Object.keys(SINCE).join(', ')}`);
+ }
+
+ const n = Number(args.limit ?? 25);
+ if (!Number.isInteger(n) || n <= 0) {
+ throw new ArgumentError('--limit must be a positive integer');
+ }
+ if (n > 25) {
+ throw new ArgumentError('--limit must be <= 25 (GitHub Trending lists at most 25 repositories)');
+ }
+ const limit = n;
+
+ const language = String(args.language ?? '').trim();
+ const path = language ? `/trending/${encodeURIComponent(language)}` : '/trending';
+ const url = new URL(`https://github.com${path}`);
+ url.searchParams.set('since', since);
+
+ let resp;
+ try {
+ resp = await fetch(url, {
+ headers: {
+ 'User-Agent': 'Mozilla/5.0 (compatible; opencli/github-trending)',
+ Accept: 'text/html',
+ },
+ });
+ } catch (error) {
+ throw new CommandExecutionError(`github trending request failed: ${error?.message || error}`);
+ }
+ if (!resp.ok) {
+ throw new CommandExecutionError(`github trending request failed: HTTP ${resp.status}`);
+ }
+
+ const html = await resp.text();
+ const rows = parseTrendingHtml(html, limit);
+ if (rows.length === 0) {
+ throw new EmptyResultError('github trending', language
+ ? `no trending repositories for language "${language}" (${since})`
+ : `no trending repositories (${since})`);
+ }
+
+ return rows.map((row, index) => ({
+ rank: index + 1,
+ repo: row.repo,
+ description: row.description,
+ language: row.language,
+ stars: row.stars,
+ forks: row.forks,
+ starsSince: row.starsSince,
+ url: row.url,
+ }));
+ },
+});
diff --git a/clis/github/trending.test.js b/clis/github/trending.test.js
new file mode 100644
index 000000000..c5411581b
--- /dev/null
+++ b/clis/github/trending.test.js
@@ -0,0 +1,118 @@
+import { getRegistry } from '@jackwener/opencli/registry';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import './trending.js';
+
+function loadCommand() {
+ const cmd = getRegistry().get('github/trending');
+ if (!cmd?.func) throw new Error('github/trending not found or has no func');
+ return cmd;
+}
+
+function article({ repo, desc, lang, stars, forks, since }) {
+ const langSpan = lang ? `${lang}` : '';
+ const descP = desc == null ? '' : `\n ${desc}\n
`;
+ return `
+
+ ${descP}
+
+ `;
+}
+
+function pageHtml(articles) {
+ return `${articles.join('\n')}
`;
+}
+
+function mockHtmlOnce(html, { ok = true, status = 200 } = {}) {
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
+ ok,
+ status,
+ statusText: 'OK',
+ text: vi.fn().mockResolvedValue(html),
+ }));
+}
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+});
+
+describe('github/trending', () => {
+ it('parses repos, stars, forks, language, description and url', async () => {
+ const html = pageHtml([
+ article({ repo: 'owner-a/repo-a', desc: 'Tools & toys for the web', lang: 'Rust', stars: '1,234', forks: '56', since: '78' }),
+ article({ repo: 'owner-b/repo-b', desc: null, lang: null, stars: '9', forks: '0', since: '3' }),
+ ]);
+ mockHtmlOnce(html);
+
+ const rows = await loadCommand().func({ since: 'daily', language: '', limit: 25 });
+
+ expect(rows).toEqual([
+ { rank: 1, repo: 'owner-a/repo-a', description: 'Tools & toys for the web', language: 'Rust', stars: 1234, forks: 56, starsSince: 78, url: 'https://github.com/owner-a/repo-a' },
+ { rank: 2, repo: 'owner-b/repo-b', description: '', language: null, stars: 9, forks: 0, starsSince: 3, url: 'https://github.com/owner-b/repo-b' },
+ ]);
+ });
+
+ it('honors --limit by truncating the result set', async () => {
+ const html = pageHtml([
+ article({ repo: 'a/a', desc: 'a', lang: 'Go', stars: '1', forks: '1', since: '1' }),
+ article({ repo: 'b/b', desc: 'b', lang: 'Go', stars: '2', forks: '2', since: '2' }),
+ article({ repo: 'c/c', desc: 'c', lang: 'Go', stars: '3', forks: '3', since: '3' }),
+ ]);
+ mockHtmlOnce(html);
+
+ const rows = await loadCommand().func({ since: 'daily', language: '', limit: 2 });
+ expect(rows.map((r) => r.repo)).toEqual(['a/a', 'b/b']);
+ });
+
+ it('builds the language + since URL', async () => {
+ const fetchFn = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ text: vi.fn().mockResolvedValue(pageHtml([
+ article({ repo: 'x/y', desc: 'z', lang: 'Rust', stars: '1', forks: '1', since: '1' }),
+ ])),
+ });
+ vi.stubGlobal('fetch', fetchFn);
+
+ await loadCommand().func({ since: 'weekly', language: 'rust', limit: 25 });
+
+ const calledUrl = String(fetchFn.mock.calls[0][0]);
+ expect(calledUrl).toBe('https://github.com/trending/rust?since=weekly');
+ });
+
+ it('rejects an unknown --since', async () => {
+ await expect(loadCommand().func({ since: 'yearly', language: '', limit: 25 }))
+ .rejects.toThrow(/since/i);
+ });
+
+ it('rejects a non-positive --limit', async () => {
+ await expect(loadCommand().func({ since: 'daily', language: '', limit: 0 }))
+ .rejects.toThrow(/limit/i);
+ });
+
+ it('rejects --limit above 25', async () => {
+ await expect(loadCommand().func({ since: 'daily', language: '', limit: 30 }))
+ .rejects.toThrow(/limit/i);
+ });
+
+ it('throws EmptyResultError when no repositories are present', async () => {
+ mockHtmlOnce(pageHtml([]));
+ await expect(loadCommand().func({ since: 'daily', language: '', limit: 25 }))
+ .rejects.toThrow(/no data|empty/i);
+ });
+
+ it('throws on a non-ok HTTP response', async () => {
+ mockHtmlOnce('', { ok: false, status: 503 });
+ await expect(loadCommand().func({ since: 'daily', language: '', limit: 25 }))
+ .rejects.toThrow(/HTTP 503/);
+ });
+});