feat: 13 read adapters across 6 new sites (round 4) (#1347)

Six new public-API sites — package registries + Docker images + OpenAlex
scholarly works — all unauthenticated, no browser required.

  dockerhub  search image
  rubygems   search gem
  homebrew   formula cask popular
  packagist  search package
  maven      search artifact
  openalex   search work

Conventions held:
  - access: 'read' on every command
  - typed errors (ArgumentError / EmptyResultError / CommandExecutionError)
    instead of generic CliError or silent fallback
  - input validators per site (image slugs, gem names, Composer names,
    Maven coordinates, OpenAlex work-id / DOI normalization)
  - listing rows carry an id-shaped column (image / gem / token / package /
    coordinate / id) that round-trips into the corresponding detail command
  - HTTP 429 surfaces with retry hint, 404 → EmptyResultError

Audits:
  - check:typed-error-lint   → no new violations (baseline 196)
  - check:silent-column-drop → no new violations (baseline 103)
  - advise:listing-id-pairing → unchanged at 13
This commit is contained in:
jakevin
2026-05-06 13:38:43 +08:00
committed by GitHub
parent 55088bbb28
commit 498ad3930c
27 changed files with 2204 additions and 0 deletions
+482
View File
@@ -6470,6 +6470,76 @@
"sourceFile": "discord-app/status.js",
"navigateBefore": true
},
{
"site": "dockerhub",
"name": "image",
"description": "Fetch a Docker Hub repository's public metadata (stars, pulls, last updated, status)",
"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\")"
}
],
"columns": [
"image",
"official",
"stars",
"pulls",
"description",
"lastUpdated",
"lastModified",
"registered",
"status",
"url"
],
"type": "js",
"modulePath": "dockerhub/image.js",
"sourceFile": "dockerhub/image.js"
},
{
"site": "dockerhub",
"name": "search",
"description": "Search Docker Hub repositories by keyword",
"access": "read",
"domain": "hub.docker.com",
"strategy": "public",
"browser": false,
"args": [
{
"name": "query",
"type": "str",
"required": true,
"positional": true,
"help": "Search keyword (e.g. \"nginx\", \"bitnami redis\")"
},
{
"name": "limit",
"type": "int",
"default": 25,
"required": false,
"help": "Max repositories (1-100, single Docker Hub page)"
}
],
"columns": [
"rank",
"image",
"official",
"stars",
"pulls",
"description",
"url"
],
"type": "js",
"modulePath": "dockerhub/search.js",
"sourceFile": "dockerhub/search.js"
},
{
"site": "douban",
"name": "book-hot",
@@ -9999,6 +10069,117 @@
"modulePath": "hf/top.js",
"sourceFile": "hf/top.js"
},
{
"site": "homebrew",
"name": "cask",
"description": "Fetch a Homebrew cask's metadata (version, homepage, deprecation, download URL)",
"access": "read",
"domain": "formulae.brew.sh",
"strategy": "public",
"browser": false,
"args": [
{
"name": "token",
"type": "str",
"required": true,
"positional": true,
"help": "Cask token (e.g. \"firefox\", \"visual-studio-code\", \"google-chrome\")"
}
],
"columns": [
"cask",
"tap",
"name",
"version",
"description",
"homepage",
"deprecated",
"disabled",
"download",
"url"
],
"type": "js",
"modulePath": "homebrew/cask.js",
"sourceFile": "homebrew/cask.js"
},
{
"site": "homebrew",
"name": "formula",
"description": "Fetch a Homebrew formula's metadata (version, license, deps, deprecation, source)",
"access": "read",
"domain": "formulae.brew.sh",
"strategy": "public",
"browser": false,
"args": [
{
"name": "name",
"type": "str",
"required": true,
"positional": true,
"help": "Formula name (e.g. \"wget\", \"gcc@13\", \"imagemagick\")"
}
],
"columns": [
"formula",
"tap",
"version",
"license",
"description",
"homepage",
"dependencies",
"deprecated",
"disabled",
"source",
"url"
],
"type": "js",
"modulePath": "homebrew/formula.js",
"sourceFile": "homebrew/formula.js"
},
{
"site": "homebrew",
"name": "popular",
"description": "List most-installed Homebrew formulae or casks (Homebrew's analytics ranking)",
"access": "read",
"domain": "formulae.brew.sh",
"strategy": "public",
"browser": false,
"args": [
{
"name": "type",
"type": "str",
"default": "formula",
"required": false,
"help": "Package type (formula / cask)"
},
{
"name": "window",
"type": "str",
"default": "30d",
"required": false,
"help": "Time window (30d / 90d / 365d)"
},
{
"name": "limit",
"type": "int",
"default": 30,
"required": false,
"help": "Max rows (1-500)"
}
],
"columns": [
"rank",
"token",
"type",
"installs",
"percent",
"window",
"url"
],
"type": "js",
"modulePath": "homebrew/popular.js",
"sourceFile": "homebrew/popular.js"
},
{
"site": "hupu",
"name": "detail",
@@ -13553,6 +13734,83 @@
"sourceFile": "maimai/search-talents.js",
"navigateBefore": "https://maimai.cn"
},
{
"site": "maven",
"name": "artifact",
"description": "Fetch a Maven Central artifact's version history (groupId:artifactId[:version])",
"access": "read",
"domain": "search.maven.org",
"strategy": "public",
"browser": false,
"args": [
{
"name": "coordinate",
"type": "str",
"required": true,
"positional": true,
"help": "Maven coord \"groupId:artifactId\" or \"groupId:artifactId:version\""
},
{
"name": "limit",
"type": "int",
"default": 20,
"required": false,
"help": "Max versions (1-200, ignored when version is pinned)"
}
],
"columns": [
"groupId",
"artifactId",
"version",
"packaging",
"publishedAt",
"tags",
"url"
],
"type": "js",
"modulePath": "maven/artifact.js",
"sourceFile": "maven/artifact.js"
},
{
"site": "maven",
"name": "search",
"description": "Search Maven Central by keyword (artifact name, groupId, tag)",
"access": "read",
"domain": "search.maven.org",
"strategy": "public",
"browser": false,
"args": [
{
"name": "query",
"type": "str",
"required": true,
"positional": true,
"help": "Search keyword (e.g. \"jackson\", \"guava\", \"ai.koog\")"
},
{
"name": "limit",
"type": "int",
"default": 30,
"required": false,
"help": "Max artifacts (1-200)"
}
],
"columns": [
"rank",
"coordinate",
"groupId",
"artifactId",
"latestVersion",
"packaging",
"versions",
"lastPublished",
"repository",
"url"
],
"type": "js",
"modulePath": "maven/search.js",
"sourceFile": "maven/search.js"
},
{
"site": "mdn",
"name": "search",
@@ -15401,6 +15659,85 @@
"sourceFile": "ones/worklog.js",
"navigateBefore": false
},
{
"site": "openalex",
"name": "search",
"description": "Search OpenAlex Works (papers, books, preprints) by keyword",
"access": "read",
"domain": "api.openalex.org",
"strategy": "public",
"browser": false,
"args": [
{
"name": "query",
"type": "str",
"required": true,
"positional": true,
"help": "Search text (e.g. \"transformers\", \"open access scholarly\")"
},
{
"name": "limit",
"type": "int",
"default": 20,
"required": false,
"help": "Max works (1-200, single OpenAlex page)"
}
],
"columns": [
"rank",
"id",
"title",
"year",
"citations",
"firstAuthor",
"venue",
"openAccess",
"type",
"doi",
"url"
],
"type": "js",
"modulePath": "openalex/search.js",
"sourceFile": "openalex/search.js"
},
{
"site": "openalex",
"name": "work",
"description": "Fetch a single OpenAlex Work (paper / preprint / book) — metadata + abstract",
"access": "read",
"domain": "api.openalex.org",
"strategy": "public",
"browser": false,
"args": [
{
"name": "id",
"type": "str",
"required": true,
"positional": true,
"help": "OpenAlex Work id (\"W2741809807\"), DOI (\"10.7717/peerj.4375\"), or full URL"
}
],
"columns": [
"id",
"title",
"type",
"year",
"date",
"language",
"authors",
"venue",
"citations",
"openAccess",
"openAccessUrl",
"referencedCount",
"doi",
"abstract",
"url"
],
"type": "js",
"modulePath": "openalex/work.js",
"sourceFile": "openalex/work.js"
},
{
"site": "openreview",
"name": "paper",
@@ -15553,6 +15890,78 @@
"modulePath": "openreview/venue.js",
"sourceFile": "openreview/venue.js"
},
{
"site": "packagist",
"name": "package",
"description": "Fetch a Packagist package's metadata (version, downloads, license, repo, GitHub stars)",
"access": "read",
"domain": "packagist.org",
"strategy": "public",
"browser": false,
"args": [
{
"name": "name",
"type": "str",
"required": true,
"positional": true,
"help": "Composer package \"<vendor>/<package>\" (e.g. \"symfony/console\", \"monolog/monolog\")"
}
],
"columns": [
"package",
"version",
"releasedAt",
"license",
"description",
"repository",
"githubStars",
"favers",
"downloads",
"monthlyDownloads",
"dailyDownloads",
"url"
],
"type": "js",
"modulePath": "packagist/package.js",
"sourceFile": "packagist/package.js"
},
{
"site": "packagist",
"name": "search",
"description": "Search Packagist (PHP / Composer) packages by keyword",
"access": "read",
"domain": "packagist.org",
"strategy": "public",
"browser": false,
"args": [
{
"name": "query",
"type": "str",
"required": true,
"positional": true,
"help": "Search keyword (e.g. \"symfony\", \"laravel http\")"
},
{
"name": "limit",
"type": "int",
"default": 30,
"required": false,
"help": "Max packages (1-100, single Packagist page)"
}
],
"columns": [
"rank",
"package",
"description",
"downloads",
"favers",
"repository",
"url"
],
"type": "js",
"modulePath": "packagist/search.js",
"sourceFile": "packagist/search.js"
},
{
"site": "paperreview",
"name": "feedback",
@@ -17643,6 +18052,79 @@
"sourceFile": "reuters/search.js",
"navigateBefore": "https://www.reuters.com"
},
{
"site": "rubygems",
"name": "gem",
"description": "Fetch a RubyGems.org gem's metadata (version, downloads, license, links)",
"access": "read",
"domain": "rubygems.org",
"strategy": "public",
"browser": false,
"args": [
{
"name": "name",
"type": "str",
"required": true,
"positional": true,
"help": "Gem name (e.g. \"rails\", \"sidekiq\")"
}
],
"columns": [
"gem",
"version",
"releasedAt",
"downloads",
"versionDownloads",
"license",
"authors",
"homepage",
"source",
"bugs",
"info",
"url"
],
"type": "js",
"modulePath": "rubygems/gem.js",
"sourceFile": "rubygems/gem.js"
},
{
"site": "rubygems",
"name": "search",
"description": "Search RubyGems.org gems by keyword",
"access": "read",
"domain": "rubygems.org",
"strategy": "public",
"browser": false,
"args": [
{
"name": "query",
"type": "str",
"required": true,
"positional": true,
"help": "Search keyword (e.g. \"rails\", \"redis\")"
},
{
"name": "limit",
"type": "int",
"default": 30,
"required": false,
"help": "Max gems (1-100, single RubyGems page)"
}
],
"columns": [
"rank",
"gem",
"version",
"downloads",
"license",
"authors",
"info",
"url"
],
"type": "js",
"modulePath": "rubygems/search.js",
"sourceFile": "rubygems/search.js"
},
{
"site": "sinablog",
"name": "article",
+52
View File
@@ -0,0 +1,52 @@
// dockerhub image — fetch a single Docker Hub repository's metadata.
//
// Hits `https://hub.docker.com/v2/repositories/<owner>/<name>`. Bare names
// resolve to the implicit `library` owner used for Docker official images
// (so `dockerhub image nginx` ≡ `dockerhub image library/nginx`). Returns a
// one-row projection: official-flag, star / pull counters, last-updated /
// registered timestamps, repo status, short description, hub URL.
import { cli, Strategy } from '@jackwener/opencli/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`;
}
cli({
site: 'dockerhub',
name: 'image',
access: 'read',
description: 'Fetch a Docker Hub repository\'s public metadata (stars, pulls, last updated, status)',
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")' },
],
columns: ['image', 'official', 'stars', 'pulls', 'description', 'lastUpdated', 'lastModified', 'registered', 'status', 'url'],
func: async (args) => {
const { owner, name } = parseImage(args.image);
const url = `${HUB_BASE}/repositories/${owner}/${name}/`;
const body = await hubFetch(url, 'dockerhub image');
const namespace = String(body?.namespace ?? owner).trim();
const isOfficial = namespace === 'library' || namespace === '_';
const image = isOfficial ? `library/${name}` : `${namespace}/${name}`;
return [{
image,
official: isOfficial,
stars: body?.star_count != null ? Number(body.star_count) : null,
pulls: body?.pull_count != null ? Number(body.pull_count) : null,
description: String(body?.description ?? '').trim(),
lastUpdated: trimDate(body?.last_updated),
lastModified: trimDate(body?.last_modified),
registered: trimDate(body?.date_registered),
status: String(body?.status_description ?? '').trim(),
url: `https://hub.docker.com/r/${image}`,
}];
},
});
+47
View File
@@ -0,0 +1,47 @@
// dockerhub search — search the public Docker Hub repository index.
//
// Hits `https://hub.docker.com/v2/search/repositories/?query=…`. Returns the
// agent-useful projection: official-flag, owner/name (round-trips into
// `dockerhub image`), star count, pull count, short description.
import { cli, Strategy } from '@jackwener/opencli/registry';
import { EmptyResultError } from '@jackwener/opencli/errors';
import { HUB_BASE, hubFetch, requireBoundedInt, requireString } from './utils.js';
cli({
site: 'dockerhub',
name: 'search',
access: 'read',
description: 'Search Docker Hub repositories by keyword',
domain: 'hub.docker.com',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'query', positional: true, required: true, help: 'Search keyword (e.g. "nginx", "bitnami redis")' },
{ name: 'limit', type: 'int', default: 25, help: 'Max repositories (1-100, single Docker Hub page)' },
],
columns: ['rank', 'image', 'official', 'stars', 'pulls', 'description', 'url'],
func: async (args) => {
const query = requireString(args.query, 'query');
const limit = requireBoundedInt(args.limit, 25, 100);
const url = `${HUB_BASE}/search/repositories/?query=${encodeURIComponent(query)}&page_size=${limit}`;
const body = await hubFetch(url, 'dockerhub search');
const list = Array.isArray(body?.results) ? body.results : [];
if (!list.length) {
throw new EmptyResultError('dockerhub search', `No Docker Hub repositories matched "${query}".`);
}
return list.slice(0, limit).map((r, i) => {
const owner = String(r.repo_owner ?? '').trim();
const name = String(r.repo_name ?? '').trim();
const image = owner ? `${owner}/${name}` : (r.is_official ? `library/${name}` : name);
return {
rank: i + 1,
image,
official: Boolean(r.is_official),
stars: r.star_count != null ? Number(r.star_count) : null,
pulls: r.pull_count != null ? Number(r.pull_count) : null,
description: String(r.short_description ?? '').trim(),
url: image ? `https://hub.docker.com/r/${image}` : '',
};
});
},
});
+100
View File
@@ -0,0 +1,100 @@
// Shared helpers for the Docker Hub adapters.
//
// Hits the public, unauthenticated `hub.docker.com/v2` REST endpoints. Anonymous
// pulls are throttled but search / metadata reads are friendly enough for
// ad-hoc CLI use. Image names follow `[<owner>/]<name>` with `library` as the
// implicit owner for Docker official images.
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
export const HUB_BASE = 'https://hub.docker.com/v2';
const UA = 'opencli-dockerhub-adapter (+https://github.com/jackwener/opencli)';
// Docker Hub repository slugs are 2-255 chars, lowercase alphanumerics + `_.-`,
// optionally prefixed with a Docker Hub user/org of the same charset.
const SLUG = /^[a-z0-9][a-z0-9._-]*$/;
export function requireString(value, label) {
const s = String(value ?? '').trim();
if (!s) throw new ArgumentError(`dockerhub ${label} cannot be empty`);
return s;
}
export function requireBoundedInt(value, defaultValue, maxValue, label = 'limit') {
const raw = value ?? defaultValue;
const n = typeof raw === 'number' ? raw : Number(raw);
if (!Number.isInteger(n) || n <= 0) {
throw new ArgumentError(`dockerhub ${label} must be a positive integer`);
}
if (n > maxValue) {
throw new ArgumentError(`dockerhub ${label} must be <= ${maxValue}`);
}
return n;
}
/**
* Split an image identifier into `{owner, name}`. Bare names use the implicit
* `library` owner that Docker Hub uses for official images (`nginx` →
* `library/nginx`).
*/
export function parseImage(input) {
const raw = String(input ?? '').trim().toLowerCase();
if (!raw) {
throw new ArgumentError('dockerhub image name is required (e.g. "nginx", "library/nginx", "bitnami/redis")');
}
const slash = raw.indexOf('/');
let owner;
let name;
if (slash >= 0) {
owner = raw.slice(0, slash);
name = raw.slice(slash + 1);
}
else {
owner = 'library';
name = raw;
}
if (!SLUG.test(owner) || !SLUG.test(name)) {
throw new ArgumentError(
`dockerhub image "${input}" is not a valid repository slug`,
'Use lowercase letters / digits / "._-", optionally prefixed with "<owner>/".',
);
}
if (name.length < 2 || name.length > 255) {
throw new ArgumentError(
`dockerhub image "${input}" name must be 2-255 chars`,
);
}
return { owner, name };
}
export async function hubFetch(url, label) {
let resp;
try {
resp = await fetch(url, { headers: { 'user-agent': UA, accept: 'application/json' } });
}
catch (err) {
throw new CommandExecutionError(
`${label} request failed: ${err?.message ?? err}`,
'Check that hub.docker.com is reachable from this network.',
);
}
if (resp.status === 404) {
throw new EmptyResultError(label, `Docker Hub returned 404 for ${url}.`);
}
if (resp.status === 429) {
throw new CommandExecutionError(
`${label} returned HTTP 429 (rate limited)`,
'Docker Hub throttles anonymous traffic; wait a few seconds and retry.',
);
}
if (!resp.ok) {
throw new CommandExecutionError(`${label} returned HTTP ${resp.status}`);
}
let body;
try {
body = await resp.json();
}
catch (err) {
throw new CommandExecutionError(`${label} returned malformed JSON: ${err?.message ?? err}`);
}
return body;
}
+39
View File
@@ -0,0 +1,39 @@
// homebrew cask — fetch a single Homebrew cask's metadata.
//
// Hits `https://formulae.brew.sh/api/cask/<token>.json`. Returns one row for
// the macOS/.dmg-style package: canonical token, friendly name, version,
// homepage, deprecated / disabled flags, download URL.
import { cli, Strategy } from '@jackwener/opencli/registry';
import { BREW_BASE, brewFetch, requireToken } from './utils.js';
cli({
site: 'homebrew',
name: 'cask',
access: 'read',
description: 'Fetch a Homebrew cask\'s metadata (version, homepage, deprecation, download URL)',
domain: 'formulae.brew.sh',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'token', positional: true, required: true, help: 'Cask token (e.g. "firefox", "visual-studio-code", "google-chrome")' },
],
columns: ['cask', 'tap', 'name', 'version', 'description', 'homepage', 'deprecated', 'disabled', 'download', 'url'],
func: async (args) => {
const token = requireToken(args.token, 'token');
const url = `${BREW_BASE}/cask/${encodeURIComponent(token)}.json`;
const body = await brewFetch(url, 'homebrew cask');
const friendly = Array.isArray(body?.name) ? body.name.filter(Boolean).join(', ') : String(body?.name ?? '').trim();
return [{
cask: String(body?.token ?? token).trim(),
tap: String(body?.tap ?? '').trim(),
name: friendly,
version: String(body?.version ?? '').trim(),
description: String(body?.desc ?? '').trim(),
homepage: String(body?.homepage ?? '').trim(),
deprecated: Boolean(body?.deprecated),
disabled: Boolean(body?.disabled),
download: String(body?.url ?? '').trim(),
url: `https://formulae.brew.sh/cask/${encodeURIComponent(token)}`,
}];
},
});
+41
View File
@@ -0,0 +1,41 @@
// homebrew formula — fetch a single Homebrew core formula's metadata.
//
// Hits `https://formulae.brew.sh/api/formula/<name>.json`. Returns one row:
// canonical name, latest stable version, license, dependencies, deprecated /
// disabled flags, homepage, source tarball URL.
import { cli, Strategy } from '@jackwener/opencli/registry';
import { BREW_BASE, brewFetch, requireToken } from './utils.js';
cli({
site: 'homebrew',
name: 'formula',
access: 'read',
description: 'Fetch a Homebrew formula\'s metadata (version, license, deps, deprecation, source)',
domain: 'formulae.brew.sh',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'name', positional: true, required: true, help: 'Formula name (e.g. "wget", "gcc@13", "imagemagick")' },
],
columns: ['formula', 'tap', 'version', 'license', 'description', 'homepage', 'dependencies', 'deprecated', 'disabled', 'source', 'url'],
func: async (args) => {
const name = requireToken(args.name, 'formula');
const url = `${BREW_BASE}/formula/${encodeURIComponent(name)}.json`;
const body = await brewFetch(url, 'homebrew formula');
const deps = Array.isArray(body?.dependencies) ? body.dependencies.filter(Boolean) : [];
const stableUrl = String(body?.urls?.stable?.url ?? '').trim();
return [{
formula: String(body?.name ?? name).trim(),
tap: String(body?.tap ?? '').trim(),
version: String(body?.versions?.stable ?? '').trim(),
license: String(body?.license ?? '').trim(),
description: String(body?.desc ?? '').trim(),
homepage: String(body?.homepage ?? '').trim(),
dependencies: deps.join(', '),
deprecated: Boolean(body?.deprecated),
disabled: Boolean(body?.disabled),
source: stableUrl,
url: `https://formulae.brew.sh/formula/${encodeURIComponent(name)}`,
}];
},
});
+54
View File
@@ -0,0 +1,54 @@
// homebrew popular — list the most-installed Homebrew formulae or casks.
//
// Hits `https://formulae.brew.sh/api/analytics/(install|cask-install)/<window>.json`.
// Anonymous-aggregated install counts published by Homebrew themselves;
// rows round-trip into `homebrew formula` / `homebrew cask` via the `token`
// column. The 30/90/365-day windows are the only ones the analytics endpoint
// publishes — anything else 404s upstream.
import { cli, Strategy } from '@jackwener/opencli/registry';
import { EmptyResultError } from '@jackwener/opencli/errors';
import { BREW_BASE, brewFetch, parseInstallCount, requireBoundedInt, requireOneOf } from './utils.js';
const TYPES = ['formula', 'cask'];
const WINDOWS = ['30d', '90d', '365d'];
cli({
site: 'homebrew',
name: 'popular',
access: 'read',
description: 'List most-installed Homebrew formulae or casks (Homebrew\'s analytics ranking)',
domain: 'formulae.brew.sh',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'type', default: 'formula', help: `Package type (${TYPES.join(' / ')})` },
{ name: 'window', default: '30d', help: `Time window (${WINDOWS.join(' / ')})` },
{ name: 'limit', type: 'int', default: 30, help: 'Max rows (1-500)' },
],
columns: ['rank', 'token', 'type', 'installs', 'percent', 'window', 'url'],
func: async (args) => {
const type = requireOneOf(args.type, TYPES, 'type');
const window = requireOneOf(args.window, WINDOWS, 'window');
const limit = requireBoundedInt(args.limit, 30, 500);
const path = type === 'cask' ? 'cask-install' : 'install';
const url = `${BREW_BASE}/analytics/${path}/${window}.json`;
const body = await brewFetch(url, 'homebrew popular');
const items = Array.isArray(body?.items) ? body.items : [];
if (!items.length) {
throw new EmptyResultError('homebrew popular', `Homebrew analytics returned no items for ${type}/${window}.`);
}
return items.slice(0, limit).map((row, i) => {
const token = String(type === 'cask' ? row.cask : row.formula ?? '').trim();
const detailPath = type === 'cask' ? 'cask' : 'formula';
return {
rank: row.number != null ? Number(row.number) : i + 1,
token,
type,
installs: parseInstallCount(row.count),
percent: row.percent != null ? Number(row.percent) : null,
window,
url: token ? `https://formulae.brew.sh/${detailPath}/${encodeURIComponent(token)}` : '',
};
});
},
});
+100
View File
@@ -0,0 +1,100 @@
// Shared helpers for the Homebrew adapters.
//
// Hits the public, unauthenticated `formulae.brew.sh/api` JSON endpoints
// (served as static files from GitHub Pages, regenerated daily). No auth.
// Formula / cask tokens are lowercase ASCII + `-_.+@` per Homebrew's own
// validation; they round-trip into `homebrew formula` / `homebrew cask`.
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
export const BREW_BASE = 'https://formulae.brew.sh/api';
const UA = 'opencli-homebrew-adapter (+https://github.com/jackwener/opencli)';
// Homebrew formula / cask tokens — letters / digits / `_-.+@` (`gcc@13`,
// `imagemagick@6`, `c++`, `0-ad`, `php-cs-fixer`).
const TOKEN = /^[A-Za-z0-9][A-Za-z0-9._+@-]*$/;
export function requireString(value, label) {
const s = String(value ?? '').trim();
if (!s) throw new ArgumentError(`homebrew ${label} cannot be empty`);
return s;
}
export function requireBoundedInt(value, defaultValue, maxValue, label = 'limit') {
const raw = value ?? defaultValue;
const n = typeof raw === 'number' ? raw : Number(raw);
if (!Number.isInteger(n) || n <= 0) {
throw new ArgumentError(`homebrew ${label} must be a positive integer`);
}
if (n > maxValue) {
throw new ArgumentError(`homebrew ${label} must be <= ${maxValue}`);
}
return n;
}
export function requireToken(value, label) {
const s = String(value ?? '').trim();
if (!s) {
throw new ArgumentError(`homebrew ${label} is required (e.g. "wget", "gcc@13", "firefox")`);
}
if (s.length > 100 || !TOKEN.test(s)) {
throw new ArgumentError(
`homebrew ${label} "${value}" is not a valid token`,
'Use letters / digits / "_-.+@", starting with a letter or digit (max 100 chars).',
);
}
return s;
}
export function requireOneOf(value, allowed, label) {
const s = String(value ?? '').trim().toLowerCase();
if (!s) throw new ArgumentError(`homebrew ${label} is required`);
if (!allowed.includes(s)) {
throw new ArgumentError(
`homebrew ${label} "${value}" is not supported`,
`Allowed: ${allowed.join(', ')}.`,
);
}
return s;
}
export async function brewFetch(url, label) {
let resp;
try {
resp = await fetch(url, { headers: { 'user-agent': UA, accept: 'application/json' } });
}
catch (err) {
throw new CommandExecutionError(
`${label} request failed: ${err?.message ?? err}`,
'Check that formulae.brew.sh is reachable from this network.',
);
}
if (resp.status === 404) {
throw new EmptyResultError(label, `Homebrew API returned 404 for ${url}.`);
}
if (resp.status === 429) {
throw new CommandExecutionError(
`${label} returned HTTP 429 (rate limited)`,
'Homebrew throttles bursts; wait a few seconds and retry.',
);
}
if (!resp.ok) {
throw new CommandExecutionError(`${label} returned HTTP ${resp.status}`);
}
let body;
try {
body = await resp.json();
}
catch (err) {
throw new CommandExecutionError(`${label} returned malformed JSON: ${err?.message ?? err}`);
}
return body;
}
/** Coerce a count value (which Homebrew analytics serves as `"139,972"`) to a plain number. */
export function parseInstallCount(value) {
if (value == null) return null;
const s = String(value).replace(/,/g, '').trim();
if (!s) return null;
const n = Number(s);
return Number.isFinite(n) ? n : null;
}
+49
View File
@@ -0,0 +1,49 @@
// maven artifact — fetch a Maven Central artifact's recent version history.
//
// Hits Solr's `gav` core (`q=g:<groupId>+AND+a:<artifactId>` with
// `core=gav`) which returns one row per published version, newest first.
// Returns the agent-useful projection: each version + publish timestamp +
// packaging. If a specific `:version` is supplied, only that version is
// returned.
import { cli, Strategy } from '@jackwener/opencli/registry';
import { EmptyResultError } from '@jackwener/opencli/errors';
import { MAVEN_BASE, mavenFetch, epochMsToIso, requireBoundedInt, requireCoord } from './utils.js';
cli({
site: 'maven',
name: 'artifact',
access: 'read',
description: 'Fetch a Maven Central artifact\'s version history (groupId:artifactId[:version])',
domain: 'search.maven.org',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'coordinate', positional: true, required: true, help: 'Maven coord "groupId:artifactId" or "groupId:artifactId:version"' },
{ name: 'limit', type: 'int', default: 20, help: 'Max versions (1-200, ignored when version is pinned)' },
],
columns: ['groupId', 'artifactId', 'version', 'packaging', 'publishedAt', 'tags', 'url'],
func: async (args) => {
const { groupId, artifactId, version } = requireCoord(args.coordinate);
const limit = requireBoundedInt(args.limit, 20, 200);
const filters = [`g:${groupId}`, `a:${artifactId}`];
if (version) filters.push(`v:${version}`);
const q = filters.join(' AND ');
const rows = version ? 1 : limit;
const url = `${MAVEN_BASE}?q=${encodeURIComponent(q)}&core=gav&rows=${rows}&wt=json`;
const body = await mavenFetch(url, 'maven artifact');
const docs = Array.isArray(body?.response?.docs) ? body.response.docs : [];
const coordLabel = version ? `${groupId}:${artifactId}:${version}` : `${groupId}:${artifactId}`;
if (!docs.length) {
throw new EmptyResultError('maven artifact', `Maven Central has no published versions for ${coordLabel}.`);
}
return docs.map((d) => ({
groupId: String(d.g ?? groupId).trim(),
artifactId: String(d.a ?? artifactId).trim(),
version: String(d.v ?? '').trim(),
packaging: String(d.p ?? '').trim(),
publishedAt: epochMsToIso(d.timestamp),
tags: Array.isArray(d.tags) ? d.tags.filter(Boolean).join(', ') : '',
url: `https://central.sonatype.com/artifact/${groupId}/${artifactId}/${d.v ?? ''}`.replace(/\/$/, ''),
}));
},
});
+51
View File
@@ -0,0 +1,51 @@
// maven search — search Maven Central by free-text keyword.
//
// Hits the Solr endpoint at `https://search.maven.org/solrsearch/select`.
// Returns the agent-useful projection: `groupId:artifactId` (round-trips
// into `maven artifact`), latest version, packaging, version count, last
// publish timestamp, repository.
import { cli, Strategy } from '@jackwener/opencli/registry';
import { EmptyResultError } from '@jackwener/opencli/errors';
import { MAVEN_BASE, mavenFetch, epochMsToIso, requireBoundedInt, requireString } from './utils.js';
cli({
site: 'maven',
name: 'search',
access: 'read',
description: 'Search Maven Central by keyword (artifact name, groupId, tag)',
domain: 'search.maven.org',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'query', positional: true, required: true, help: 'Search keyword (e.g. "jackson", "guava", "ai.koog")' },
{ name: 'limit', type: 'int', default: 30, help: 'Max artifacts (1-200)' },
],
columns: ['rank', 'coordinate', 'groupId', 'artifactId', 'latestVersion', 'packaging', 'versions', 'lastPublished', 'repository', 'url'],
func: async (args) => {
const query = requireString(args.query, 'query');
const limit = requireBoundedInt(args.limit, 30, 200);
const url = `${MAVEN_BASE}?q=${encodeURIComponent(query)}&rows=${limit}&wt=json`;
const body = await mavenFetch(url, 'maven search');
const docs = Array.isArray(body?.response?.docs) ? body.response.docs : [];
if (!docs.length) {
throw new EmptyResultError('maven search', `No Maven Central artifacts matched "${query}".`);
}
return docs.slice(0, limit).map((d, i) => {
const groupId = String(d.g ?? '').trim();
const artifactId = String(d.a ?? '').trim();
const coord = groupId && artifactId ? `${groupId}:${artifactId}` : '';
return {
rank: i + 1,
coordinate: coord,
groupId,
artifactId,
latestVersion: String(d.latestVersion ?? '').trim(),
packaging: String(d.p ?? '').trim(),
versions: d.versionCount != null ? Number(d.versionCount) : null,
lastPublished: epochMsToIso(d.timestamp),
repository: String(d.repositoryId ?? '').trim(),
url: coord ? `https://central.sonatype.com/artifact/${groupId}/${artifactId}` : '',
};
});
},
});
+110
View File
@@ -0,0 +1,110 @@
// Shared helpers for the Maven Central (search.maven.org) adapter.
//
// Hits the public, unauthenticated `search.maven.org/solrsearch/select` Solr
// endpoint that powers the Maven Central search UI. No auth required for
// read-only queries.
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
export const MAVEN_BASE = 'https://search.maven.org/solrsearch/select';
export const MAVEN_REPO_BASE = 'https://repo1.maven.org/maven2';
const UA = 'opencli-maven-adapter (+https://github.com/jackwener/opencli)';
// Maven groupId / artifactId tokens — Java-package-ish (letters / digits /
// `_-.`), 1-200 chars; reverse-DNS dots are allowed in groupId.
const COORD_TOKEN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
export function requireString(value, label) {
const s = String(value ?? '').trim();
if (!s) throw new ArgumentError(`maven ${label} cannot be empty`);
return s;
}
export function requireBoundedInt(value, defaultValue, maxValue, label = 'limit') {
const raw = value ?? defaultValue;
const n = typeof raw === 'number' ? raw : Number(raw);
if (!Number.isInteger(n) || n <= 0) {
throw new ArgumentError(`maven ${label} must be a positive integer`);
}
if (n > maxValue) {
throw new ArgumentError(`maven ${label} must be <= ${maxValue}`);
}
return n;
}
/**
* Parse a Maven coordinate `groupId:artifactId[:version]` into segments.
* groupId / artifactId are required; version is optional.
*/
export function requireCoord(value) {
const raw = String(value ?? '').trim();
if (!raw) {
throw new ArgumentError('maven coordinate is required (e.g. "com.fasterxml.jackson.core:jackson-databind")');
}
const parts = raw.split(':');
if (parts.length < 2 || parts.length > 3) {
throw new ArgumentError(
`maven coordinate "${value}" must be "groupId:artifactId" or "groupId:artifactId:version"`,
);
}
const [groupId, artifactId, version] = parts;
if (!groupId || !artifactId) {
throw new ArgumentError(`maven coordinate "${value}" is missing groupId or artifactId`);
}
if (groupId.length > 200 || !COORD_TOKEN.test(groupId)) {
throw new ArgumentError(
`maven groupId "${groupId}" is not a valid token`,
'Use letters / digits / "_-." (max 200 chars), starting with a letter or digit.',
);
}
if (artifactId.length > 200 || !COORD_TOKEN.test(artifactId)) {
throw new ArgumentError(
`maven artifactId "${artifactId}" is not a valid token`,
'Use letters / digits / "_-." (max 200 chars), starting with a letter or digit.',
);
}
if (version != null && version.length > 200) {
throw new ArgumentError(`maven version "${version}" is too long (max 200 chars).`);
}
return { groupId, artifactId, version: version ?? null };
}
export async function mavenFetch(url, label) {
let resp;
try {
resp = await fetch(url, { headers: { 'user-agent': UA, accept: 'application/json' } });
}
catch (err) {
throw new CommandExecutionError(
`${label} request failed: ${err?.message ?? err}`,
'Check that search.maven.org is reachable from this network.',
);
}
if (resp.status === 404) {
throw new EmptyResultError(label, `Maven Central returned 404 for ${url}.`);
}
if (resp.status === 429) {
throw new CommandExecutionError(
`${label} returned HTTP 429 (rate limited)`,
'Maven Central throttles bursts; wait a few seconds and retry.',
);
}
if (!resp.ok) {
throw new CommandExecutionError(`${label} returned HTTP ${resp.status}`);
}
let body;
try {
body = await resp.json();
}
catch (err) {
throw new CommandExecutionError(`${label} returned malformed JSON: ${err?.message ?? err}`);
}
return body;
}
/** Convert epoch-ms (Maven Solr `timestamp`) to ISO-8601 UTC. Returns null for falsy/invalid. */
export function epochMsToIso(value) {
if (value == null) return null;
const n = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(n) || n <= 0) return null;
return new Date(n).toISOString().replace(/\.\d+Z$/, 'Z');
}
+69
View File
@@ -0,0 +1,69 @@
// openalex search — search OpenAlex's Works index by free text.
//
// Hits `https://api.openalex.org/works?search=…&per-page=…`. Returns the
// agent-useful projection: OpenAlex Work id (round-trips into `openalex
// work`), DOI, title, year, citation count, first author, primary venue,
// open-access status.
import { cli, Strategy } from '@jackwener/opencli/registry';
import { EmptyResultError } from '@jackwener/opencli/errors';
import {
OPENALEX_BASE,
appendMailto,
bareDoi,
bareId,
openalexFetch,
requireBoundedInt,
requireString,
} from './utils.js';
const SELECT_FIELDS = [
'id', 'doi', 'title', 'publication_year', 'publication_date',
'cited_by_count', 'authorships', 'primary_location', 'open_access', 'type',
].join(',');
cli({
site: 'openalex',
name: 'search',
access: 'read',
description: 'Search OpenAlex Works (papers, books, preprints) by keyword',
domain: 'api.openalex.org',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'query', positional: true, required: true, help: 'Search text (e.g. "transformers", "open access scholarly")' },
{ name: 'limit', type: 'int', default: 20, help: 'Max works (1-200, single OpenAlex page)' },
],
columns: ['rank', 'id', 'title', 'year', 'citations', 'firstAuthor', 'venue', 'openAccess', 'type', 'doi', 'url'],
func: async (args) => {
const query = requireString(args.query, 'query');
const limit = requireBoundedInt(args.limit, 20, 200);
const url = appendMailto(
`${OPENALEX_BASE}/works?search=${encodeURIComponent(query)}&per-page=${limit}&select=${SELECT_FIELDS}`,
);
const body = await openalexFetch(url, 'openalex search');
const list = Array.isArray(body?.results) ? body.results : [];
if (!list.length) {
throw new EmptyResultError('openalex search', `No OpenAlex works matched "${query}".`);
}
return list.slice(0, limit).map((w, i) => {
const firstAuthor = Array.isArray(w.authorships) && w.authorships.length
? String(w.authorships[0]?.author?.display_name ?? '').trim()
: '';
const venue = String(w.primary_location?.source?.display_name ?? '').trim();
const id = bareId(w.id);
return {
rank: i + 1,
id,
title: String(w.title ?? '').trim(),
year: w.publication_year != null ? Number(w.publication_year) : null,
citations: w.cited_by_count != null ? Number(w.cited_by_count) : null,
firstAuthor,
venue,
openAccess: Boolean(w.open_access?.is_oa),
type: String(w.type ?? '').trim(),
doi: bareDoi(w.doi),
url: id ? `https://openalex.org/${id}` : '',
};
});
},
});
+160
View File
@@ -0,0 +1,160 @@
// Shared helpers for the OpenAlex (`api.openalex.org`) adapter.
//
// OpenAlex is a free, open scholarly works database. The REST API is
// unauthenticated; passing an email via `mailto=` opts into the polite pool
// (faster). Work IDs are `W` followed by digits (`W2741809807`) and
// round-trip via `https://api.openalex.org/works/<id>` or
// `https://openalex.org/W…`.
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
export const OPENALEX_BASE = 'https://api.openalex.org';
const UA = 'opencli-openalex-adapter (+https://github.com/jackwener/opencli)';
// OpenAlex stable IDs: a single-letter prefix (`W` works, `A` authors, `S`
// sources, `I` institutions…) + at least 4 digits. We accept just `W` here.
const WORK_ID = /^W\d{4,}$/;
// DOIs are loose — accept anything starting with "10." after the optional
// `doi.org/` prefix; OpenAlex itself does the normalization.
const DOI_BARE = /^10\.\S+$/;
export function requireString(value, label) {
const s = String(value ?? '').trim();
if (!s) throw new ArgumentError(`openalex ${label} cannot be empty`);
return s;
}
export function requireBoundedInt(value, defaultValue, maxValue, label = 'limit') {
const raw = value ?? defaultValue;
const n = typeof raw === 'number' ? raw : Number(raw);
if (!Number.isInteger(n) || n <= 0) {
throw new ArgumentError(`openalex ${label} must be a positive integer`);
}
if (n > maxValue) {
throw new ArgumentError(`openalex ${label} must be <= ${maxValue}`);
}
return n;
}
/**
* Resolve a user-supplied work identifier to OpenAlex's canonical path
* segment. Accepts `W…` IDs, `doi:10.…`, raw DOIs, or full
* `https://doi.org/…` / `https://openalex.org/W…` URLs.
*/
export function requireWorkRef(value) {
const raw = String(value ?? '').trim();
if (!raw) {
throw new ArgumentError('openalex work id is required (e.g. "W2741809807", "10.7717/peerj.4375")');
}
// 1) full openalex URL
const oaUrl = raw.match(/^https?:\/\/(?:api\.)?openalex\.org\/(?:works\/)?([WAaSCFwIPwT]\d+)/i);
if (oaUrl) {
const id = oaUrl[1].toUpperCase();
if (id[0] !== 'W') {
throw new ArgumentError(`openalex work id "${value}" must be a Work (W…) ID, got "${id[0]}…"`);
}
return id;
}
// 2) bare W… id
if (WORK_ID.test(raw.toUpperCase())) {
return raw.toUpperCase();
}
// 3) doi:… prefix
if (/^doi:/i.test(raw)) {
const doi = raw.replace(/^doi:/i, '').trim();
if (DOI_BARE.test(doi)) return `doi:${doi}`;
}
// 4) full doi URL
const doiUrl = raw.match(/^https?:\/\/(?:dx\.)?doi\.org\/(.+)$/i);
if (doiUrl && DOI_BARE.test(doiUrl[1])) {
return `doi:${doiUrl[1]}`;
}
// 5) bare 10.xxxx/yyy DOI
if (DOI_BARE.test(raw)) {
return `doi:${raw}`;
}
throw new ArgumentError(
`openalex work id "${value}" is not recognised`,
'Use a Work id ("W2741809807"), a DOI ("10.7717/peerj.4375"), or a full openalex.org / doi.org URL.',
);
}
export async function openalexFetch(url, label) {
let resp;
try {
resp = await fetch(url, { headers: { 'user-agent': UA, accept: 'application/json' } });
}
catch (err) {
throw new CommandExecutionError(
`${label} request failed: ${err?.message ?? err}`,
'Check that api.openalex.org is reachable from this network.',
);
}
if (resp.status === 404) {
throw new EmptyResultError(label, `OpenAlex returned 404 for ${url}.`);
}
if (resp.status === 429) {
throw new CommandExecutionError(
`${label} returned HTTP 429 (rate limited)`,
'OpenAlex throttles unauthenticated traffic; wait a few seconds and retry, or set OPENALEX_MAILTO.',
);
}
if (!resp.ok) {
let detail = '';
try {
const text = await resp.text();
const match = text.match(/"message"\s*:\s*"([^"]+)"/);
if (match) detail = ` (${match[1]})`;
}
catch { /* ignore */ }
throw new CommandExecutionError(`${label} returned HTTP ${resp.status}${detail}`);
}
let body;
try {
body = await resp.json();
}
catch (err) {
throw new CommandExecutionError(`${label} returned malformed JSON: ${err?.message ?? err}`);
}
return body;
}
/** Strip the `https://openalex.org/` prefix if present so columns surface just the bare id. */
export function bareId(value) {
const s = String(value ?? '').trim();
if (!s) return '';
return s.replace(/^https?:\/\/(?:api\.)?openalex\.org\//i, '').replace(/^works\//i, '');
}
/** Strip the `https://doi.org/` prefix so DOIs render as plain `10.…/…` strings. */
export function bareDoi(value) {
const s = String(value ?? '').trim();
if (!s) return '';
return s.replace(/^https?:\/\/(?:dx\.)?doi\.org\//i, '');
}
/**
* Reconstruct a plain-text abstract from OpenAlex's
* `abstract_inverted_index` (token → [positions]). OpenAlex returns the
* abstract this way for licensing reasons.
*/
export function reconstructAbstract(invertedIndex) {
if (!invertedIndex || typeof invertedIndex !== 'object') return '';
const positions = [];
for (const [token, idxs] of Object.entries(invertedIndex)) {
if (!Array.isArray(idxs)) continue;
for (const i of idxs) {
if (Number.isInteger(i) && i >= 0 && i < 100000) {
positions[i] = token;
}
}
}
return positions.filter(Boolean).join(' ').replace(/\s+/g, ' ').trim();
}
/** Append the polite-pool `mailto` query param if the env var is set. */
export function appendMailto(url) {
const mailto = process.env.OPENALEX_MAILTO?.trim();
if (!mailto) return url;
const sep = url.includes('?') ? '&' : '?';
return `${url}${sep}mailto=${encodeURIComponent(mailto)}`;
}
+65
View File
@@ -0,0 +1,65 @@
// openalex work — fetch a single Work's record from OpenAlex.
//
// Hits `https://api.openalex.org/works/<id-or-doi>`. Accepts an OpenAlex
// Work id (`W2741809807`), a raw DOI (`10.7717/peerj.4375`), or a full
// `doi.org` / `openalex.org` URL. Returns one row plus the (decoded)
// abstract — OpenAlex stores abstracts as `abstract_inverted_index` so we
// reconstruct it for downstream readers.
import { cli, Strategy } from '@jackwener/opencli/registry';
import {
OPENALEX_BASE,
appendMailto,
bareDoi,
bareId,
openalexFetch,
reconstructAbstract,
requireWorkRef,
} from './utils.js';
const SELECT_FIELDS = [
'id', 'doi', 'title', 'publication_year', 'publication_date',
'cited_by_count', 'authorships', 'primary_location', 'open_access', 'type',
'referenced_works', 'related_works', 'language', 'abstract_inverted_index',
].join(',');
cli({
site: 'openalex',
name: 'work',
access: 'read',
description: 'Fetch a single OpenAlex Work (paper / preprint / book) — metadata + abstract',
domain: 'api.openalex.org',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'id', positional: true, required: true, help: 'OpenAlex Work id ("W2741809807"), DOI ("10.7717/peerj.4375"), or full URL' },
],
columns: ['id', 'title', 'type', 'year', 'date', 'language', 'authors', 'venue', 'citations', 'openAccess', 'openAccessUrl', 'referencedCount', 'doi', 'abstract', 'url'],
func: async (args) => {
const ref = requireWorkRef(args.id);
const url = appendMailto(`${OPENALEX_BASE}/works/${encodeURIComponent(ref)}?select=${SELECT_FIELDS}`);
const w = await openalexFetch(url, 'openalex work');
const authors = Array.isArray(w.authorships)
? w.authorships.map((a) => String(a?.author?.display_name ?? '').trim()).filter(Boolean).join(', ')
: '';
const venue = String(w.primary_location?.source?.display_name ?? '').trim();
const id = bareId(w.id);
const oaUrl = String(w.open_access?.oa_url ?? '').trim();
return [{
id,
title: String(w.title ?? '').trim(),
type: String(w.type ?? '').trim(),
year: w.publication_year != null ? Number(w.publication_year) : null,
date: String(w.publication_date ?? '').trim(),
language: String(w.language ?? '').trim(),
authors,
venue,
citations: w.cited_by_count != null ? Number(w.cited_by_count) : null,
openAccess: Boolean(w.open_access?.is_oa),
openAccessUrl: oaUrl,
referencedCount: Array.isArray(w.referenced_works) ? w.referenced_works.length : null,
doi: bareDoi(w.doi),
abstract: reconstructAbstract(w.abstract_inverted_index),
url: id ? `https://openalex.org/${id}` : '',
}];
},
});
+49
View File
@@ -0,0 +1,49 @@
// packagist package — fetch a single Packagist package's metadata.
//
// Hits `https://packagist.org/packages/<vendor>/<package>.json`. Returns
// one row: latest stable version + release time, license, repository,
// description, lifetime / monthly / daily downloads, github stars, favers.
import { cli, Strategy } from '@jackwener/opencli/registry';
import { CommandExecutionError } from '@jackwener/opencli/errors';
import { PACKAGIST_BASE, packagistFetch, pickStableVersion, requirePackageName, trimDate } from './utils.js';
cli({
site: 'packagist',
name: 'package',
access: 'read',
description: 'Fetch a Packagist package\'s metadata (version, downloads, license, repo, GitHub stars)',
domain: 'packagist.org',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'name', positional: true, required: true, help: 'Composer package "<vendor>/<package>" (e.g. "symfony/console", "monolog/monolog")' },
],
columns: ['package', 'version', 'releasedAt', 'license', 'description', 'repository', 'githubStars', 'favers', 'downloads', 'monthlyDownloads', 'dailyDownloads', 'url'],
func: async (args) => {
const { full } = requirePackageName(args.name);
const url = `${PACKAGIST_BASE}/packages/${full}.json`;
const body = await packagistFetch(url, 'packagist package');
const pkg = body?.package;
if (!pkg || typeof pkg !== 'object') {
throw new CommandExecutionError(`packagist package returned no "package" object for ${full}.`);
}
const versionKey = pickStableVersion(pkg.versions);
const versionEntry = versionKey ? pkg.versions?.[versionKey] : null;
const license = Array.isArray(versionEntry?.license) ? versionEntry.license.filter(Boolean).join(', ') : '';
const downloads = pkg.downloads ?? {};
return [{
package: String(pkg.name ?? full).trim(),
version: versionKey ? String(versionKey) : '',
releasedAt: trimDate(versionEntry?.time),
license,
description: String(pkg.description ?? '').trim(),
repository: String(pkg.repository ?? '').trim(),
githubStars: pkg.github_stars != null ? Number(pkg.github_stars) : null,
favers: pkg.favers != null ? Number(pkg.favers) : null,
downloads: downloads.total != null ? Number(downloads.total) : null,
monthlyDownloads: downloads.monthly != null ? Number(downloads.monthly) : null,
dailyDownloads: downloads.daily != null ? Number(downloads.daily) : null,
url: `https://packagist.org/packages/${full}`,
}];
},
});
+43
View File
@@ -0,0 +1,43 @@
// packagist search — search Packagist's PHP / Composer package registry.
//
// Hits `https://packagist.org/search.json?q=…&per_page=…`. Returns the
// agent-useful projection: vendor/package (round-trips into `packagist
// package`), description, lifetime download count, GitHub-stars-style favers,
// repository URL.
import { cli, Strategy } from '@jackwener/opencli/registry';
import { EmptyResultError } from '@jackwener/opencli/errors';
import { PACKAGIST_BASE, packagistFetch, requireBoundedInt, requireString } from './utils.js';
cli({
site: 'packagist',
name: 'search',
access: 'read',
description: 'Search Packagist (PHP / Composer) packages by keyword',
domain: 'packagist.org',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'query', positional: true, required: true, help: 'Search keyword (e.g. "symfony", "laravel http")' },
{ name: 'limit', type: 'int', default: 30, help: 'Max packages (1-100, single Packagist page)' },
],
columns: ['rank', 'package', 'description', 'downloads', 'favers', 'repository', 'url'],
func: async (args) => {
const query = requireString(args.query, 'query');
const limit = requireBoundedInt(args.limit, 30, 100);
const url = `${PACKAGIST_BASE}/search.json?q=${encodeURIComponent(query)}&per_page=${limit}`;
const body = await packagistFetch(url, 'packagist search');
const list = Array.isArray(body?.results) ? body.results : [];
if (!list.length) {
throw new EmptyResultError('packagist search', `No Packagist packages matched "${query}".`);
}
return list.slice(0, limit).map((row, i) => ({
rank: i + 1,
package: String(row.name ?? '').trim(),
description: String(row.description ?? '').trim(),
downloads: row.downloads != null ? Number(row.downloads) : null,
favers: row.favers != null ? Number(row.favers) : null,
repository: String(row.repository ?? '').trim(),
url: String(row.url ?? '').trim(),
}));
},
});
+113
View File
@@ -0,0 +1,113 @@
// Shared helpers for the Packagist (PHP / Composer) adapters.
//
// Hits the public, unauthenticated `packagist.org` JSON endpoints. Composer's
// canonical package registry. Package names are `<vendor>/<package>`,
// lowercase letters / digits / `_-.`, with each segment 1-100 chars.
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
export const PACKAGIST_BASE = 'https://packagist.org';
const UA = 'opencli-packagist-adapter (+https://github.com/jackwener/opencli)';
// Each segment of a Composer package name (`vendor` and `package`).
const SEGMENT = /^[a-z0-9]([_.-]?[a-z0-9]+)*$/;
export function requireString(value, label) {
const s = String(value ?? '').trim();
if (!s) throw new ArgumentError(`packagist ${label} cannot be empty`);
return s;
}
export function requireBoundedInt(value, defaultValue, maxValue, label = 'limit') {
const raw = value ?? defaultValue;
const n = typeof raw === 'number' ? raw : Number(raw);
if (!Number.isInteger(n) || n <= 0) {
throw new ArgumentError(`packagist ${label} must be a positive integer`);
}
if (n > maxValue) {
throw new ArgumentError(`packagist ${label} must be <= ${maxValue}`);
}
return n;
}
export function requirePackageName(value) {
const raw = String(value ?? '').trim().toLowerCase();
if (!raw) {
throw new ArgumentError('packagist package name is required (e.g. "symfony/console", "monolog/monolog")');
}
const slash = raw.indexOf('/');
if (slash <= 0 || slash === raw.length - 1) {
throw new ArgumentError(
`packagist package "${value}" must be "<vendor>/<package>"`,
'Both segments are required (Composer convention).',
);
}
const vendor = raw.slice(0, slash);
const pkg = raw.slice(slash + 1);
if (vendor.length > 100 || pkg.length > 100 || !SEGMENT.test(vendor) || !SEGMENT.test(pkg)) {
throw new ArgumentError(
`packagist package "${value}" is not a valid Composer name`,
'Use lowercase letters / digits / "_-.", segments separated by single "_-." chars (max 100 chars each).',
);
}
return { vendor, package: pkg, full: `${vendor}/${pkg}` };
}
export async function packagistFetch(url, label) {
let resp;
try {
resp = await fetch(url, { headers: { 'user-agent': UA, accept: 'application/json' } });
}
catch (err) {
throw new CommandExecutionError(
`${label} request failed: ${err?.message ?? err}`,
'Check that packagist.org is reachable from this network.',
);
}
if (resp.status === 404) {
throw new EmptyResultError(label, `Packagist returned 404 for ${url}.`);
}
if (resp.status === 429) {
throw new CommandExecutionError(
`${label} returned HTTP 429 (rate limited)`,
'Packagist throttles bursts; wait a few seconds and retry.',
);
}
if (!resp.ok) {
throw new CommandExecutionError(`${label} returned HTTP ${resp.status}`);
}
let body;
try {
body = await resp.json();
}
catch (err) {
throw new CommandExecutionError(`${label} returned malformed JSON: ${err?.message ?? err}`);
}
return body;
}
/** Trim "2026-05-05T17:32:01+00:00" → "2026-05-05T17:32:01Z" so timestamps are uniform. */
export function trimDate(value) {
const s = String(value ?? '').trim();
if (!s) return null;
const noFrac = s.replace(/\.\d+/, '');
return noFrac.replace(/(?:[+-]\d{2}:?\d{2}|Z)?$/, 'Z');
}
/**
* Pick the newest stable (non-dev / non-prerelease) version key from a
* Packagist `versions` map. Packagist returns keys ordered newest-first.
* Falls back to the first key if no stable found.
*/
export function pickStableVersion(versions) {
if (!versions || typeof versions !== 'object') return null;
const keys = Object.keys(versions);
if (!keys.length) return null;
const PRE = /(?:^|[._\-+])(?:dev|alpha|beta|rc|pre|nightly)(?:[._\-+\d]|$)/i;
const DEV_SUFFIX = /\.x-dev$|-dev$/i;
for (const k of keys) {
if (DEV_SUFFIX.test(k)) continue;
if (PRE.test(k)) continue;
return k;
}
return keys[0];
}
+42
View File
@@ -0,0 +1,42 @@
// rubygems gem — fetch a single gem's metadata from RubyGems.org.
//
// Hits `https://rubygems.org/api/v1/gems/<name>.json`. Returns a one-row
// projection: latest version + release date, lifetime / version downloads,
// license(s), author(s), homepage, source, bug tracker, short info.
import { cli, Strategy } from '@jackwener/opencli/registry';
import { GEMS_BASE, gemsFetch, requireGemName, trimDate } from './utils.js';
cli({
site: 'rubygems',
name: 'gem',
access: 'read',
description: 'Fetch a RubyGems.org gem\'s metadata (version, downloads, license, links)',
domain: 'rubygems.org',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'name', positional: true, required: true, help: 'Gem name (e.g. "rails", "sidekiq")' },
],
columns: ['gem', 'version', 'releasedAt', 'downloads', 'versionDownloads', 'license', 'authors', 'homepage', 'source', 'bugs', 'info', 'url'],
func: async (args) => {
const name = requireGemName(args.name);
const url = `${GEMS_BASE}/gems/${encodeURIComponent(name)}.json`;
const body = await gemsFetch(url, 'rubygems gem');
const licenses = Array.isArray(body?.licenses) ? body.licenses.filter(Boolean).join(', ') : '';
const meta = body?.metadata ?? {};
return [{
gem: String(body?.name ?? name).trim(),
version: String(body?.version ?? '').trim(),
releasedAt: trimDate(body?.version_created_at),
downloads: body?.downloads != null ? Number(body.downloads) : null,
versionDownloads: body?.version_downloads != null ? Number(body.version_downloads) : null,
license: licenses,
authors: String(body?.authors ?? '').trim(),
homepage: String(body?.homepage_uri ?? '').trim(),
source: String(body?.source_code_uri ?? meta.source_code_uri ?? '').trim(),
bugs: String(body?.bug_tracker_uri ?? meta.bug_tracker_uri ?? '').trim(),
info: String(body?.info ?? '').trim(),
url: String(body?.project_uri ?? `https://rubygems.org/gems/${name}`).trim(),
}];
},
});
+47
View File
@@ -0,0 +1,47 @@
// rubygems search — search the RubyGems.org public index.
//
// Hits `https://rubygems.org/api/v1/search.json?query=…&page=1`. Returns an
// agent-useful projection: gem name (round-trips into `rubygems gem`), latest
// version, lifetime downloads, license(s), author(s), short info, project URL.
import { cli, Strategy } from '@jackwener/opencli/registry';
import { EmptyResultError } from '@jackwener/opencli/errors';
import { GEMS_BASE, gemsFetch, requireBoundedInt, requireString } from './utils.js';
cli({
site: 'rubygems',
name: 'search',
access: 'read',
description: 'Search RubyGems.org gems by keyword',
domain: 'rubygems.org',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'query', positional: true, required: true, help: 'Search keyword (e.g. "rails", "redis")' },
{ name: 'limit', type: 'int', default: 30, help: 'Max gems (1-100, single RubyGems page)' },
],
columns: ['rank', 'gem', 'version', 'downloads', 'license', 'authors', 'info', 'url'],
func: async (args) => {
const query = requireString(args.query, 'query');
const limit = requireBoundedInt(args.limit, 30, 100);
const url = `${GEMS_BASE}/search.json?query=${encodeURIComponent(query)}&page=1`;
const body = await gemsFetch(url, 'rubygems search');
const list = Array.isArray(body) ? body : [];
if (!list.length) {
throw new EmptyResultError('rubygems search', `No gems matched "${query}".`);
}
return list.slice(0, limit).map((g, i) => {
const name = String(g.name ?? '').trim();
const licenses = Array.isArray(g.licenses) ? g.licenses.filter(Boolean).join(', ') : '';
return {
rank: i + 1,
gem: name,
version: String(g.version ?? '').trim(),
downloads: g.downloads != null ? Number(g.downloads) : null,
license: licenses,
authors: String(g.authors ?? '').trim(),
info: String(g.info ?? '').trim(),
url: name ? `https://rubygems.org/gems/${name}` : '',
};
});
},
});
+86
View File
@@ -0,0 +1,86 @@
// Shared helpers for the RubyGems.org adapters.
//
// Hits the public, unauthenticated `rubygems.org/api/v1` REST endpoints. No
// auth required for read-only metadata; the API is friendly to anonymous CLI
// traffic. Gem names follow the RubyGems convention: lowercase ASCII +
// `-_.`, 1-100 chars, must start with a letter or digit.
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
export const GEMS_BASE = 'https://rubygems.org/api/v1';
const UA = 'opencli-rubygems-adapter (+https://github.com/jackwener/opencli)';
// RubyGems gem name pattern (mirrors the rubygems-server validation).
const GEM_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
export function requireString(value, label) {
const s = String(value ?? '').trim();
if (!s) throw new ArgumentError(`rubygems ${label} cannot be empty`);
return s;
}
export function requireBoundedInt(value, defaultValue, maxValue, label = 'limit') {
const raw = value ?? defaultValue;
const n = typeof raw === 'number' ? raw : Number(raw);
if (!Number.isInteger(n) || n <= 0) {
throw new ArgumentError(`rubygems ${label} must be a positive integer`);
}
if (n > maxValue) {
throw new ArgumentError(`rubygems ${label} must be <= ${maxValue}`);
}
return n;
}
export function requireGemName(value) {
const s = String(value ?? '').trim();
if (!s) {
throw new ArgumentError('rubygems gem name is required (e.g. "rails", "sidekiq")');
}
if (s.length > 100 || !GEM_NAME.test(s)) {
throw new ArgumentError(
`rubygems gem "${value}" is not a valid gem name`,
'Use letters / digits / "._-", starting with a letter or digit (max 100 chars).',
);
}
return s;
}
export async function gemsFetch(url, label) {
let resp;
try {
resp = await fetch(url, { headers: { 'user-agent': UA, accept: 'application/json' } });
}
catch (err) {
throw new CommandExecutionError(
`${label} request failed: ${err?.message ?? err}`,
'Check that rubygems.org is reachable from this network.',
);
}
if (resp.status === 404) {
throw new EmptyResultError(label, `RubyGems returned 404 for ${url}.`);
}
if (resp.status === 429) {
throw new CommandExecutionError(
`${label} returned HTTP 429 (rate limited)`,
'RubyGems throttles bursts; wait a few seconds and retry.',
);
}
if (!resp.ok) {
throw new CommandExecutionError(`${label} returned HTTP ${resp.status}`);
}
let body;
try {
body = await resp.json();
}
catch (err) {
throw new CommandExecutionError(`${label} returned malformed JSON: ${err?.message ?? err}`);
}
return body;
}
/** Trim "2026-03-24T20:27:42.098Z" → "2026-03-24T20:27:42Z" so timestamps share a uniform precision. */
export function trimDate(value) {
const s = String(value ?? '').trim();
if (!s) return null;
const noFrac = s.replace(/\.\d+/, '');
return noFrac.endsWith('Z') ? noFrac : `${noFrac}Z`;
}
+63
View File
@@ -0,0 +1,63 @@
# Docker Hub
**Mode**: 🌐 Public · **Domain**: `hub.docker.com`
Search and inspect public Docker Hub repositories without auth or browser. Two commands cover discovery and per-repository metadata.
## Commands
| Command | Description |
|---------|-------------|
| `opencli dockerhub search <query>` | Search Docker Hub repositories by keyword |
| `opencli dockerhub image <name>` | Repository metadata (stars, pulls, last updated, status) |
## Usage Examples
```bash
# Search repositories
opencli dockerhub search nginx --limit 10
opencli dockerhub search "bitnami redis" --limit 5
# Single repository metadata (use `image` from search rows)
opencli dockerhub image nginx # implicit `library/nginx`
opencli dockerhub image library/nginx
opencli dockerhub image bitnami/redis
# JSON output
opencli dockerhub search nginx -f json
opencli dockerhub image nginx -f json
```
## Output Columns
| Command | Columns |
|---------|---------|
| `search` | `rank, image, official, stars, pulls, description, url` |
| `image` | `image, official, stars, pulls, description, lastUpdated, lastModified, registered, status, url` |
The `image` column from `search` round-trips into `image` exactly. Bare repository names (e.g. `nginx`) resolve to the implicit `library` owner that Docker Hub uses for official images.
## Options
### `dockerhub search`
| Option | Description |
|--------|-------------|
| `query` (positional) | Search keyword |
| `--limit` | Max repositories (1-100, default: 25) |
### `dockerhub image`
| Option | Description |
|--------|-------------|
| `image` (positional) | Repository slug (`nginx`, `library/nginx`, `bitnami/redis`) |
## Caveats
- Image slugs are validated upfront against Docker Hub's `[a-z0-9][a-z0-9._-]*` pattern (2-255 chars). Bad input raises `ArgumentError`.
- Anonymous traffic is throttled. `HTTP 429` surfaces as a typed `CommandExecutionError` with a retry hint.
- Timestamp columns (`lastUpdated`, `lastModified`, `registered`) are normalized to second-precision `YYYY-MM-DDTHH:MM:SSZ`.
## Prerequisites
- No browser required — uses `hub.docker.com/v2/search/repositories/` and `hub.docker.com/v2/repositories/<owner>/<name>/`.
+77
View File
@@ -0,0 +1,77 @@
# Homebrew
**Mode**: 🌐 Public · **Domain**: `formulae.brew.sh`
Inspect Homebrew formulae and casks, plus the official install-rank analytics, without auth or browser. Three commands.
## Commands
| Command | Description |
|---------|-------------|
| `opencli homebrew formula <name>` | Single Homebrew core formula's metadata |
| `opencli homebrew cask <token>` | Single Homebrew cask's (macOS app) metadata |
| `opencli homebrew popular` | Most-installed formulae or casks (Homebrew analytics ranking) |
## Usage Examples
```bash
# Inspect a formula
opencli homebrew formula wget
opencli homebrew formula gcc@13
opencli homebrew formula imagemagick
# Inspect a cask (macOS package)
opencli homebrew cask firefox
opencli homebrew cask visual-studio-code
# Most popular installs (defaults to formula / 30d / top 30)
opencli homebrew popular
opencli homebrew popular --type cask --window 90d --limit 50
opencli homebrew popular --type formula --window 365d --limit 100
# JSON output
opencli homebrew popular -f json
```
## Output Columns
| Command | Columns |
|---------|---------|
| `formula` | `formula, tap, version, license, description, homepage, dependencies, deprecated, disabled, source, url` |
| `cask` | `cask, tap, name, version, description, homepage, deprecated, disabled, download, url` |
| `popular` | `rank, token, type, installs, percent, window, url` |
The `token` column from `popular` round-trips into `formula` (when `type=formula`) or `cask` (when `type=cask`).
## Options
### `homebrew formula`
| Option | Description |
|--------|-------------|
| `name` (positional) | Formula name (`wget`, `gcc@13`, `imagemagick`) |
### `homebrew cask`
| Option | Description |
|--------|-------------|
| `token` (positional) | Cask token (`firefox`, `visual-studio-code`) |
### `homebrew popular`
| Option | Description |
|--------|-------------|
| `--type` | `formula` (default) or `cask` |
| `--window` | `30d` (default) / `90d` / `365d` |
| `--limit` | Max rows (1-500, default: 30) |
## Caveats
- Formula / cask tokens are validated against Homebrew's `[A-Za-z0-9][A-Za-z0-9._+@-]*` pattern (max 100 chars). Bad input raises `ArgumentError`.
- `--type` and `--window` are validated against the only values Homebrew analytics actually publishes. Anything else raises `ArgumentError`.
- Homebrew analytics serves install counts as comma-formatted strings (`"139,972"`); we coerce them to plain numbers.
- The endpoints are static GitHub Pages JSON regenerated daily, so timestamps lag by up to 24 h.
## Prerequisites
- No browser required — uses `formulae.brew.sh/api/formula/<name>.json`, `formulae.brew.sh/api/cask/<token>.json`, and `formulae.brew.sh/api/analytics/(install|cask-install)/<window>.json`.
+66
View File
@@ -0,0 +1,66 @@
# Maven Central
**Mode**: 🌐 Public · **Domain**: `search.maven.org`
Search Maven Central artifacts and pull per-artifact version histories without auth or browser. Two commands.
## Commands
| Command | Description |
|---------|-------------|
| `opencli maven search <query>` | Search Maven Central by keyword (artifact name, groupId, tag) |
| `opencli maven artifact <coordinate>` | Version history for `groupId:artifactId[:version]` |
## Usage Examples
```bash
# Free-text search
opencli maven search jackson --limit 10
opencli maven search "ai.koog" --limit 5
# Version history for a specific artifact (use `coordinate` from search rows)
opencli maven artifact com.fasterxml.jackson.core:jackson-databind --limit 10
opencli maven artifact com.google.guava:guava --limit 5
# Pin to a specific version
opencli maven artifact com.google.guava:guava:33.0.0-jre
# JSON output
opencli maven search jackson -f json
```
## Output Columns
| Command | Columns |
|---------|---------|
| `search` | `rank, coordinate, groupId, artifactId, latestVersion, packaging, versions, lastPublished, repository, url` |
| `artifact` | `groupId, artifactId, version, packaging, publishedAt, tags, url` |
The `coordinate` column from `search` round-trips into `artifact` exactly. To pin a single version, append `:<version>`.
## Options
### `maven search`
| Option | Description |
|--------|-------------|
| `query` (positional) | Free-text query |
| `--limit` | Max artifacts (1-200, default: 30) |
### `maven artifact`
| Option | Description |
|--------|-------------|
| `coordinate` (positional) | `groupId:artifactId` or `groupId:artifactId:version` |
| `--limit` | Max versions (1-200, default: 20). Ignored when `version` is pinned. |
## Caveats
- Coordinates are validated upfront — `groupId` and `artifactId` must be `[A-Za-z0-9][A-Za-z0-9._-]*` (max 200 chars each). Bad input raises `ArgumentError`.
- `lastPublished` / `publishedAt` are derived from Solr's epoch-ms `timestamp` and rendered as second-precision `YYYY-MM-DDTHH:MM:SSZ`.
- Maven Central throttles bursts; `HTTP 429` surfaces as a typed `CommandExecutionError` with a retry hint.
- `versions` (in `search`) is the count Solr reports — it includes pre-releases as well as stable versions.
## Prerequisites
- No browser required — uses `search.maven.org/solrsearch/select` (Solr endpoint).
+67
View File
@@ -0,0 +1,67 @@
# OpenAlex
**Mode**: 🌐 Public · **Domain**: `api.openalex.org`
Search and inspect scholarly Works (papers, preprints, books) on OpenAlex without auth or browser. Two commands.
## Commands
| Command | Description |
|---------|-------------|
| `opencli openalex search <query>` | Search OpenAlex Works by keyword |
| `opencli openalex work <id>` | Single Work — metadata + reconstructed abstract |
## Usage Examples
```bash
# Free-text search
opencli openalex search transformers --limit 10
opencli openalex search "open access scholarly" --limit 5
# Single Work by OpenAlex id (use `id` from search rows)
opencli openalex work W2741809807
# Single Work by DOI (raw or full URL)
opencli openalex work 10.7717/peerj.4375
opencli openalex work https://doi.org/10.7717/peerj.4375
# JSON output
opencli openalex search transformers -f json
opencli openalex work W2741809807 -f json
```
## Output Columns
| Command | Columns |
|---------|---------|
| `search` | `rank, id, title, year, citations, firstAuthor, venue, openAccess, type, doi, url` |
| `work` | `id, title, type, year, date, language, authors, venue, citations, openAccess, openAccessUrl, referencedCount, doi, abstract, url` |
The `id` column from `search` round-trips into `work` exactly. `work` accepts an OpenAlex Work id, a raw DOI, or any `openalex.org` / `doi.org` URL.
## Options
### `openalex search`
| Option | Description |
|--------|-------------|
| `query` (positional) | Search text |
| `--limit` | Max Works (1-200, default: 20) |
### `openalex work`
| Option | Description |
|--------|-------------|
| `id` (positional) | OpenAlex Work id (`W2741809807`), DOI (`10.7717/peerj.4375`), or full URL |
## Caveats
- Work id input is validated upfront — only `W…` IDs / DOIs / `openalex.org` / `doi.org` URLs are accepted; OpenAlex itself does the canonicalization for DOIs. Bad input raises `ArgumentError`.
- The `abstract` column is reconstructed from OpenAlex's `abstract_inverted_index` (token → positions) — this is how OpenAlex distributes abstracts for licensing reasons. It's the verbatim abstract text.
- Set `OPENALEX_MAILTO=you@example.com` to opt into the OpenAlex polite pool (faster + more reliable). Optional — anonymous requests still work.
- OpenAlex `select=` rejects unknown fields. The adapter pins a vetted field list (`primary_location`, `open_access`, `authorships`, etc.) to avoid passing aliases that 400.
- OpenAlex throttles unauthenticated traffic; `HTTP 429` surfaces as a typed `CommandExecutionError` with a retry hint.
## Prerequisites
- No browser required — uses `api.openalex.org/works`.
+64
View File
@@ -0,0 +1,64 @@
# Packagist
**Mode**: 🌐 Public · **Domain**: `packagist.org`
Search and inspect PHP / Composer packages on Packagist without auth or browser. Two commands.
## Commands
| Command | Description |
|---------|-------------|
| `opencli packagist search <query>` | Search Packagist (PHP / Composer) packages by keyword |
| `opencli packagist package <name>` | Single-package metadata (version, downloads, license, repo, GitHub stars) |
## Usage Examples
```bash
# Search packages
opencli packagist search symfony --limit 10
opencli packagist search "laravel http" --limit 5
# Single-package metadata (use `package` from search rows; vendor/package required)
opencli packagist package symfony/console
opencli packagist package laravel/framework
opencli packagist package monolog/monolog
# JSON output
opencli packagist search symfony -f json
opencli packagist package symfony/console -f json
```
## Output Columns
| Command | Columns |
|---------|---------|
| `search` | `rank, package, description, downloads, favers, repository, url` |
| `package` | `package, version, releasedAt, license, description, repository, githubStars, favers, downloads, monthlyDownloads, dailyDownloads, url` |
The `package` column from `search` round-trips into `package` exactly.
## Options
### `packagist search`
| Option | Description |
|--------|-------------|
| `query` (positional) | Search keyword |
| `--limit` | Max packages (1-100, default: 30) |
### `packagist package`
| Option | Description |
|--------|-------------|
| `name` (positional) | Composer package `<vendor>/<package>` (`symfony/console`, `monolog/monolog`) |
## Caveats
- Composer names are validated upfront — both `vendor` and `package` segments are required, lowercase letters / digits / `_-.` only, max 100 chars per segment. Bad input raises `ArgumentError`.
- `version` is the newest stable release (skipping `*-dev`, `*-rc*`, `*-beta*`, `*-alpha*`). Falls back to the newest available version if no stable exists.
- `releasedAt` is normalized to second-precision `YYYY-MM-DDTHH:MM:SSZ`.
- Packagist throttles bursts; `HTTP 429` surfaces as a typed `CommandExecutionError` with a retry hint.
## Prerequisites
- No browser required — uses `packagist.org/search.json` and `packagist.org/packages/<vendor>/<package>.json`.
+62
View File
@@ -0,0 +1,62 @@
# RubyGems
**Mode**: 🌐 Public · **Domain**: `rubygems.org`
Search and inspect Ruby gems on the public RubyGems.org index without auth or browser. Two commands cover discovery and per-gem metadata.
## Commands
| Command | Description |
|---------|-------------|
| `opencli rubygems search <query>` | Search RubyGems.org gems by keyword |
| `opencli rubygems gem <name>` | Single-gem metadata (version, downloads, license, links) |
## Usage Examples
```bash
# Search gems
opencli rubygems search rails --limit 10
opencli rubygems search redis --limit 5
# Single-gem metadata (use `gem` from search rows)
opencli rubygems gem rails
opencli rubygems gem sidekiq
# JSON output
opencli rubygems search rails -f json
opencli rubygems gem rails -f json
```
## Output Columns
| Command | Columns |
|---------|---------|
| `search` | `rank, gem, version, downloads, license, authors, info, url` |
| `gem` | `gem, version, releasedAt, downloads, versionDownloads, license, authors, homepage, source, bugs, info, url` |
The `gem` column from `search` round-trips into `gem` exactly.
## Options
### `rubygems search`
| Option | Description |
|--------|-------------|
| `query` (positional) | Search keyword |
| `--limit` | Max gems (1-100, default: 30) |
### `rubygems gem`
| Option | Description |
|--------|-------------|
| `name` (positional) | Gem name (`rails`, `sidekiq`, `pundit`) |
## Caveats
- Gem names are validated against RubyGems' own `[A-Za-z0-9][A-Za-z0-9._-]*` pattern (max 100 chars). Bad input raises `ArgumentError`.
- `releasedAt` is normalized to second-precision `YYYY-MM-DDTHH:MM:SSZ`.
- RubyGems throttles bursts; `HTTP 429` surfaces as a typed `CommandExecutionError` with a retry hint.
## Prerequisites
- No browser required — uses `rubygems.org/api/v1/search.json` and `rubygems.org/api/v1/gems/<name>.json`.
+6
View File
@@ -116,6 +116,12 @@ Run `opencli list` for the live registry.
| **[crates](./browser/crates.md)** | `search` `crate` | 🌐 Public |
| **[mdn](./browser/mdn.md)** | `search` | 🌐 Public |
| **[nvd](./browser/nvd.md)** | `cve` | 🌐 Public |
| **[dockerhub](./browser/dockerhub.md)** | `search` `image` | 🌐 Public |
| **[rubygems](./browser/rubygems.md)** | `search` `gem` | 🌐 Public |
| **[homebrew](./browser/homebrew.md)** | `formula` `cask` `popular` | 🌐 Public |
| **[packagist](./browser/packagist.md)** | `search` `package` | 🌐 Public |
| **[maven](./browser/maven.md)** | `search` `artifact` | 🌐 Public |
| **[openalex](./browser/openalex.md)** | `search` `work` | 🌐 Public |
## Desktop Adapters