@o4h8/oss-perks 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ozkan Hancioglu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,127 @@
1
+ # oss-perks
2
+
3
+ [![npm version](https://img.shields.io/npm/v/oss-perks.svg)](https://www.npmjs.com/package/oss-perks)
4
+ [![CI](https://github.com/o4h8/oss-perks/actions/workflows/ci.yml/badge.svg)](https://github.com/o4h8/oss-perks/actions)
5
+ [![license](https://img.shields.io/npm/l/oss-perks.svg)](./LICENSE)
6
+ [![node](https://img.shields.io/node/v/oss-perks.svg)](https://nodejs.org)
7
+
8
+ > **Am I eligible for open-source maintainer perks?** Check any GitHub user or package against the criteria for programs like [Claude for Open Source](https://claude.com/contact-sales/claude-for-oss) — in one command, right from your terminal.
9
+
10
+ Maintainer reward programs list eligibility criteria in prose ("500+ dependent repos", "100+ merged PRs", "OpenSSF criticality ≥ 0.4"). Figuring out whether *you* actually qualify means manually digging through npm stats, GitHub search, and OpenSSF data. `oss-perks` does it in one command — no signup, no config, no dependencies.
11
+
12
+ ## How it works
13
+
14
+ You give it a GitHub username (and optionally a package name). It then, in parallel:
15
+
16
+ 1. **Queries the [GitHub API](https://docs.github.com/rest)** for your merged-PR history, repos, and contributors.
17
+ 2. **Queries package registries** ([npm](https://api.npmjs.org) + [ecosyste.ms](https://ecosyste.ms)) for downloads and how many other packages/repos depend on yours.
18
+ 3. **Evaluates each program criterion** against those numbers and prints a clear ✓ / ✗ per rule, with the evidence behind every verdict.
19
+
20
+ Everything is read-only public data. Nothing is stored or sent anywhere except those public APIs. It's a single ~15 KB CLI with **zero runtime dependencies**, so `npx oss-perks` starts instantly.
21
+
22
+ ```
23
+ npx oss-perks sindresorhus -p npm:chalk
24
+ ```
25
+
26
+ ```
27
+ sindresorhus https://github.com/sindresorhus
28
+ ──────────────────────────────────────────────────────────
29
+ ✓ PASS Maintainer / library author
30
+ At least one package clears a maintainer threshold.
31
+ · npmjs:chalk — 1,795,989,333 monthly dl, 130085 dependent pkgs, 1291351 dependent repos ✓
32
+ ? n/a Core contributor (major foundation/project committer) ~manual
33
+ Not machine-verifiable — cite it in your application.
34
+ ✗ fail Active contributor (100+ merged PRs elsewhere / 12mo)
35
+ 11 merged PRs in the last 12 months in repos sindresorhus doesn't own (need 100).
36
+ ✓ PASS Community builder (20+ external contributors) ~approx
37
+ sindresorhus/awesome has 199+ external contributors (all-time approximation).
38
+ ✓ PASS Critical infrastructure (OpenSSF criticality >= 0.4) ~approx
39
+ ──────────────────────────────────────────────────────────
40
+ Likely eligible — passes 3 of 5 criteria.
41
+ ```
42
+
43
+ ## Install
44
+
45
+ Run without installing:
46
+
47
+ ```sh
48
+ npx oss-perks <github-username>
49
+ ```
50
+
51
+ Or install globally:
52
+
53
+ ```sh
54
+ npm install -g oss-perks
55
+ ```
56
+
57
+ **Set a GitHub token** to avoid rate limits (the GitHub Search API is 10 req/min unauthenticated):
58
+
59
+ ```sh
60
+ export GITHUB_TOKEN=ghp_xxx # a fine-grained token with public read scope is enough
61
+ ```
62
+
63
+ ## Usage
64
+
65
+ ```
66
+ oss-perks <github-username> [options]
67
+
68
+ -p, --package <spec> Package for the maintainer check. Repeatable.
69
+ Prefix with a registry: npm: pypi: crates: gem:
70
+ (a bare name defaults to npm).
71
+ --json Machine-readable JSON output.
72
+ --repos <n> Max owned repos to scan (default 100).
73
+ -h, --help Help.
74
+ -v, --version Version.
75
+ ```
76
+
77
+ Examples:
78
+
79
+ ```sh
80
+ oss-perks torvalds
81
+ oss-perks sindresorhus -p npm:chalk -p npm:got
82
+ oss-perks psf --package pypi:requests --json
83
+ ```
84
+
85
+ Exit code is `0` when at least one criterion passes, `1` otherwise — handy in CI.
86
+
87
+ ## The criteria
88
+
89
+ Rules mirror the **Claude for Open Source** program. You only need to meet **one**.
90
+
91
+ | # | Criterion | Threshold | How `oss-perks` checks it | Confidence |
92
+ |---|-----------|-----------|---------------------------|------------|
93
+ | 1 | Maintainer / library author | 500+ dependent repos, or 100+ dependent packages, or 200k+ monthly downloads | npm downloads API + [ecosyste.ms](https://ecosyste.ms) dependents | exact |
94
+ | 2 | Core contributor | Listed committer on a major foundation/project | Not machine-verifiable — flagged for manual citation | manual |
95
+ | 3 | Active contributor | 100+ merged PRs in repos you don't own (12 mo) | GitHub Search API (`is:merged -user:you`) | exact |
96
+ | 4 | Community builder | 20+ unique external contributors (1 yr) | GitHub contributors per repo, excluding owner | approximate¹ |
97
+ | 5 | Critical infrastructure | OpenSSF criticality score ≥ 0.4 | ecosyste.ms score, else a stars/forks/age estimate | approximate² |
98
+
99
+ ¹ GitHub's contributors endpoint isn't time-windowed, so this is an all-time count (an upper bound on "last year"). It's also capped at 200 per repo by the API.
100
+ ² When ecosyste.ms doesn't expose a real [OpenSSF criticality score](https://github.com/ossf/criticality_score), a rough estimate from stars/forks/issues/age is shown and clearly labeled — run the official tool to confirm a borderline case.
101
+
102
+ ## Data sources
103
+
104
+ - **GitHub REST API** — user, repos, contributors, merged-PR search.
105
+ - **npm registry** (`api.npmjs.org`) — monthly downloads.
106
+ - **[ecosyste.ms](https://ecosyste.ms)** — dependent-package/repo counts across npm, PyPI, crates.io, RubyGems, and OpenSSF criticality where available.
107
+
108
+ No data is stored or transmitted anywhere except these public read-only APIs.
109
+
110
+ ## Programmatic use
111
+
112
+ ```ts
113
+ import { check } from "oss-perks";
114
+
115
+ const report = await check("sindresorhus", {
116
+ packages: [{ registry: "npmjs.org", name: "chalk" }],
117
+ });
118
+ console.log(report.eligible, report.passedCount);
119
+ ```
120
+
121
+ ## Contributing
122
+
123
+ Issues and PRs welcome — especially adding new perk-program rulesets beyond Claude for OSS. See the criteria table above for where new checks plug in (`src/lib.ts`).
124
+
125
+ ## License
126
+
127
+ MIT
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,117 @@
1
+ #!/usr/bin/env node
2
+ // oss-perks — check open-source maintainer perk eligibility from the terminal.
3
+ import { parseArgs } from "node:util";
4
+ import { check, normalizeUser, parsePackageSpec } from "./lib.js";
5
+ import { c } from "./util.js";
6
+ const HELP = `${c.bold("oss-perks")} — check open-source maintainer perk eligibility
7
+
8
+ ${c.bold("USAGE")}
9
+ oss-perks <github-username> [options]
10
+
11
+ ${c.bold("OPTIONS")}
12
+ -p, --package <spec> Package to check for the maintainer criterion.
13
+ Repeatable. Prefix with a registry:
14
+ npm:express pypi:requests crates:serde gem:rails
15
+ A bare name defaults to npm.
16
+ --json Output machine-readable JSON.
17
+ --repos <n> Max owned repos to scan (default 100).
18
+ -h, --help Show this help.
19
+ -v, --version Show version.
20
+
21
+ ${c.bold("EXAMPLES")}
22
+ oss-perks torvalds
23
+ oss-perks sindresorhus -p npm:chalk -p npm:got
24
+ oss-perks psf --package pypi:requests --json
25
+
26
+ ${c.bold("NOTES")}
27
+ Set ${c.cyan("GITHUB_TOKEN")} to raise GitHub API rate limits (recommended).
28
+ Rules mirror the Claude for Open Source program; see the README for sources.`;
29
+ function statusBadge(s) {
30
+ if (s === "pass")
31
+ return c.green("✓ PASS");
32
+ if (s === "fail")
33
+ return c.red("✗ fail");
34
+ return c.yellow("? n/a ");
35
+ }
36
+ function confidenceTag(conf) {
37
+ if (conf === "exact")
38
+ return "";
39
+ if (conf === "approximate")
40
+ return c.dim(" ~approx");
41
+ return c.dim(" ~manual");
42
+ }
43
+ async function main() {
44
+ let parsed;
45
+ try {
46
+ parsed = parseArgs({
47
+ allowPositionals: true,
48
+ options: {
49
+ package: { type: "string", short: "p", multiple: true },
50
+ json: { type: "boolean" },
51
+ repos: { type: "string" },
52
+ help: { type: "boolean", short: "h" },
53
+ version: { type: "boolean", short: "v" },
54
+ },
55
+ });
56
+ }
57
+ catch (err) {
58
+ console.error(c.red(err.message));
59
+ console.error(`Run ${c.cyan("oss-perks --help")}.`);
60
+ process.exit(2);
61
+ }
62
+ const { values, positionals } = parsed;
63
+ if (values.version) {
64
+ const { createRequire } = await import("node:module");
65
+ const pkg = createRequire(import.meta.url)("../package.json");
66
+ console.log(pkg.version);
67
+ return;
68
+ }
69
+ if (values.help || positionals.length === 0) {
70
+ console.log(HELP);
71
+ process.exit(positionals.length === 0 && !values.help ? 1 : 0);
72
+ }
73
+ let username;
74
+ let packages;
75
+ try {
76
+ username = normalizeUser(positionals[0]);
77
+ packages = (values.package ?? []).map(parsePackageSpec);
78
+ }
79
+ catch (err) {
80
+ console.error(c.red(err.message));
81
+ process.exit(2);
82
+ }
83
+ const repoScan = values.repos ? Math.max(1, parseInt(values.repos, 10) || 100) : 100;
84
+ if (!values.json) {
85
+ process.stderr.write(c.dim(`Checking ${username}…\n`));
86
+ }
87
+ const report = await check(username, { packages, repoScan });
88
+ if (values.json) {
89
+ console.log(JSON.stringify(report, null, 2));
90
+ process.exit(report.eligible ? 0 : 1);
91
+ }
92
+ // Human-readable report
93
+ const line = c.gray("─".repeat(58));
94
+ console.log("");
95
+ console.log(` ${c.bold(report.user)}${report.profileUrl ? c.dim(" " + report.profileUrl) : ""}`);
96
+ console.log(line);
97
+ for (const cr of report.criteria) {
98
+ console.log(` ${statusBadge(cr.status)} ${c.bold(cr.title)}${confidenceTag(cr.confidence)}`);
99
+ console.log(` ${c.dim(cr.detail)}`);
100
+ for (const ev of cr.evidence ?? []) {
101
+ console.log(` ${c.gray("· " + ev)}`);
102
+ }
103
+ }
104
+ console.log(line);
105
+ const verdict = report.eligible
106
+ ? c.green(c.bold(`Likely eligible — passes ${report.passedCount} of 5 criteria.`))
107
+ : c.yellow(`No criterion met automatically (passed ${report.passedCount}/5).`);
108
+ console.log(` ${verdict}`);
109
+ console.log(c.dim(" You only need ONE criterion. The program also considers maintainers of\n" +
110
+ " quietly-critical projects outside these bars — apply and make the case."));
111
+ console.log(c.dim(" Apply: https://claude.com/contact-sales/claude-for-oss\n"));
112
+ process.exit(report.eligible ? 0 : 1);
113
+ }
114
+ main().catch((err) => {
115
+ console.error(c.red(`Unexpected error: ${err?.message ?? err}`));
116
+ process.exit(3);
117
+ });
package/dist/lib.d.ts ADDED
@@ -0,0 +1,45 @@
1
+ import { type Registry } from "./sources.js";
2
+ export type Status = "pass" | "fail" | "unknown";
3
+ export interface CriterionResult {
4
+ id: string;
5
+ title: string;
6
+ status: Status;
7
+ /** Human-readable one-liner explaining the verdict. */
8
+ detail: string;
9
+ /** How confident the automated check is: exact | approximate | manual. */
10
+ confidence: "exact" | "approximate" | "manual";
11
+ /** Optional supporting bullet points. */
12
+ evidence?: string[];
13
+ }
14
+ export interface CheckReport {
15
+ user: string;
16
+ profileUrl?: string;
17
+ packages: {
18
+ registry: Registry;
19
+ name: string;
20
+ }[];
21
+ criteria: CriterionResult[];
22
+ eligible: boolean;
23
+ passedCount: number;
24
+ }
25
+ export interface CheckOptions {
26
+ /** Packages to evaluate for the maintainer criterion, e.g. "npm:express". */
27
+ packages?: {
28
+ registry: Registry;
29
+ name: string;
30
+ }[];
31
+ /** Cap how many of the user's repos we scan for community/criticality checks. */
32
+ repoScan?: number;
33
+ }
34
+ /**
35
+ * Normalize user input into a bare GitHub login.
36
+ * Accepts "octocat", "@octocat", "github.com/octocat",
37
+ * "https://github.com/octocat/", etc.
38
+ */
39
+ export declare function normalizeUser(input: string): string;
40
+ /** Parse "npm:express" / "pypi:requests" / bare "express" (defaults to npm). */
41
+ export declare function parsePackageSpec(spec: string): {
42
+ registry: Registry;
43
+ name: string;
44
+ };
45
+ export declare function check(login: string, opts?: CheckOptions): Promise<CheckReport>;
package/dist/lib.js ADDED
@@ -0,0 +1,258 @@
1
+ // Public API + the "Claude for Open Source" eligibility ruleset.
2
+ //
3
+ // Criteria (from https://claude.com/contact-sales/claude-for-oss):
4
+ // 1. Maintainer / library author — a package with 500+ dependent repos,
5
+ // 100+ dependent packages, or 200,000+ combined monthly downloads.
6
+ // 2. Core contributor — listed committer on a major foundation/project.
7
+ // 3. Active contributor — 100+ merged PRs in repos you don't own (12 months).
8
+ // 4. Community builder — a repo with 20+ unique external contributors (1 year).
9
+ // 5. Critical infrastructure — a repo with OpenSSF criticality score >= 0.4.
10
+ //
11
+ // Some criteria are cleanly measurable from public APIs (2 is not machine
12
+ // verifiable and 4/5 use documented approximations — each result says so).
13
+ import { countMergedPRsElsewhere, getContributors, getCriticalityScore, getPackageStats, getUser, getUserRepos, } from "./sources.js";
14
+ const REGISTRY_ALIASES = {
15
+ npm: "npmjs.org",
16
+ "npmjs.org": "npmjs.org",
17
+ pypi: "pypi.org",
18
+ "pypi.org": "pypi.org",
19
+ pip: "pypi.org",
20
+ crates: "crates.io",
21
+ "crates.io": "crates.io",
22
+ cargo: "crates.io",
23
+ gem: "rubygems.org",
24
+ rubygems: "rubygems.org",
25
+ "rubygems.org": "rubygems.org",
26
+ };
27
+ /**
28
+ * Normalize user input into a bare GitHub login.
29
+ * Accepts "octocat", "@octocat", "github.com/octocat",
30
+ * "https://github.com/octocat/", etc.
31
+ */
32
+ export function normalizeUser(input) {
33
+ let s = input.trim();
34
+ s = s.replace(/^https?:\/\//i, "").replace(/^www\./i, "");
35
+ if (s.toLowerCase().startsWith("github.com/"))
36
+ s = s.slice("github.com/".length);
37
+ s = s.replace(/^@/, "").replace(/[/?#].*$/, "").trim();
38
+ if (!/^[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}$/i.test(s)) {
39
+ throw new Error(`"${input}" is not a valid GitHub username.`);
40
+ }
41
+ return s;
42
+ }
43
+ /** Parse "npm:express" / "pypi:requests" / bare "express" (defaults to npm). */
44
+ export function parsePackageSpec(spec) {
45
+ const idx = spec.indexOf(":");
46
+ if (idx === -1)
47
+ return { registry: "npmjs.org", name: spec };
48
+ const reg = REGISTRY_ALIASES[spec.slice(0, idx).toLowerCase()];
49
+ const name = spec.slice(idx + 1);
50
+ if (!reg)
51
+ throw new Error(`Unknown registry in "${spec}". Use npm:, pypi:, crates:, or gem:`);
52
+ return { registry: reg, name };
53
+ }
54
+ // --- individual criteria -------------------------------------------------
55
+ async function checkMaintainer(specs) {
56
+ const base = {
57
+ id: "maintainer",
58
+ title: "Maintainer / library author",
59
+ status: "unknown",
60
+ confidence: "exact",
61
+ detail: "No package supplied. Re-run with --package npm:<name> (or pypi:/crates:/gem:) to check.",
62
+ };
63
+ if (specs.length === 0)
64
+ return base;
65
+ const stats = [];
66
+ for (const s of specs) {
67
+ try {
68
+ stats.push(await getPackageStats(s.registry, s.name));
69
+ }
70
+ catch {
71
+ /* skip unresolvable packages */
72
+ }
73
+ }
74
+ if (stats.length === 0) {
75
+ return { ...base, status: "unknown", detail: "Could not resolve any supplied package." };
76
+ }
77
+ const evidence = [];
78
+ let pass = false;
79
+ for (const s of stats) {
80
+ const dl = s.monthlyDownloads;
81
+ const dp = s.dependentPackages;
82
+ const dr = s.dependentRepos;
83
+ const hit = (dr != null && dr >= 500) ||
84
+ (dp != null && dp >= 100) ||
85
+ (dl != null && dl >= 200_000);
86
+ if (hit)
87
+ pass = true;
88
+ evidence.push(`${s.registry.split(".")[0]}:${s.name} — ` +
89
+ `${dl != null ? dl.toLocaleString() : "?"} monthly dl, ` +
90
+ `${dp ?? "?"} dependent pkgs, ${dr ?? "?"} dependent repos` +
91
+ (hit ? " ✓" : ""));
92
+ }
93
+ const anyData = stats.some((s) => s.monthlyDownloads != null || s.dependentPackages != null || s.dependentRepos != null);
94
+ return {
95
+ ...base,
96
+ status: pass ? "pass" : anyData ? "fail" : "unknown",
97
+ detail: pass
98
+ ? "At least one package clears a maintainer threshold."
99
+ : anyData
100
+ ? "No supplied package reaches 500 dependent repos / 100 dependent packages / 200k monthly downloads."
101
+ : "Package stats unavailable from registries right now.",
102
+ evidence,
103
+ };
104
+ }
105
+ async function checkActiveContributor(login) {
106
+ const base = {
107
+ id: "active-contributor",
108
+ title: "Active contributor (100+ merged PRs elsewhere / 12mo)",
109
+ status: "unknown",
110
+ confidence: "exact",
111
+ detail: "",
112
+ };
113
+ try {
114
+ const n = await countMergedPRsElsewhere(login, 365);
115
+ return {
116
+ ...base,
117
+ status: n >= 100 ? "pass" : "fail",
118
+ detail: `${n} merged PR${n === 1 ? "" : "s"} in the last 12 months in repos ${login} doesn't own (need 100).`,
119
+ };
120
+ }
121
+ catch (err) {
122
+ return { ...base, detail: `GitHub search failed: ${err.message}` };
123
+ }
124
+ }
125
+ async function checkCommunityAndCriticality(login, repoScan) {
126
+ const community = {
127
+ id: "community-builder",
128
+ title: "Community builder (20+ external contributors)",
129
+ status: "unknown",
130
+ confidence: "approximate",
131
+ detail: "",
132
+ };
133
+ const criticality = {
134
+ id: "critical-infra",
135
+ title: "Critical infrastructure (OpenSSF criticality >= 0.4)",
136
+ status: "unknown",
137
+ confidence: "approximate",
138
+ detail: "",
139
+ };
140
+ let repos;
141
+ try {
142
+ repos = await getUserRepos(login, repoScan);
143
+ }
144
+ catch (err) {
145
+ const msg = `Could not list repos: ${err.message}`;
146
+ community.detail = msg;
147
+ criticality.detail = msg;
148
+ return { community, criticality };
149
+ }
150
+ if (repos.length === 0) {
151
+ community.detail = "No public non-fork repos found.";
152
+ community.status = "fail";
153
+ criticality.detail = "No public non-fork repos found.";
154
+ criticality.status = "fail";
155
+ return { community, criticality };
156
+ }
157
+ // Community: scan top repos by stars, count contributors excluding the owner.
158
+ const topForContributors = repos.slice(0, Math.min(8, repos.length));
159
+ let bestRepo = "";
160
+ let bestExternal = 0;
161
+ const communityEvidence = [];
162
+ for (const r of topForContributors) {
163
+ try {
164
+ const contribs = await getContributors(r.full_name);
165
+ const external = contribs.filter((c) => c.login && c.login.toLowerCase() !== login.toLowerCase() && c.type !== "Bot").length;
166
+ if (external > bestExternal) {
167
+ bestExternal = external;
168
+ bestRepo = r.full_name;
169
+ }
170
+ if (external > 0)
171
+ communityEvidence.push(`${r.full_name}: ${external} external contributors`);
172
+ }
173
+ catch {
174
+ /* skip repos we can't read */
175
+ }
176
+ }
177
+ community.status = bestExternal >= 20 ? "pass" : "fail";
178
+ community.detail =
179
+ bestExternal >= 20
180
+ ? `${bestRepo} has ${bestExternal}+ external contributors (all-time approximation).`
181
+ : `Best repo has ${bestExternal} external contributors (need 20). Note: all-time count, not strictly last 12 months.`;
182
+ community.evidence = communityEvidence.slice(0, 5);
183
+ // Criticality: try the real OpenSSF score for the top repo; else estimate.
184
+ const top = repos[0];
185
+ let score = await getCriticalityScore(top.full_name);
186
+ let confidence = "exact";
187
+ if (score == null) {
188
+ score = estimateCriticality(top);
189
+ confidence = "approximate";
190
+ }
191
+ criticality.confidence = confidence;
192
+ criticality.status = score >= 0.4 ? "pass" : "fail";
193
+ criticality.detail =
194
+ `${top.full_name}: criticality ${score.toFixed(2)} ` +
195
+ (confidence === "exact"
196
+ ? "(OpenSSF, need 0.4)."
197
+ : "(rough estimate from stars/forks/contributors — run the real openssf/criticality_score tool to confirm).");
198
+ return { community, criticality };
199
+ }
200
+ /**
201
+ * Very rough OpenSSF-criticality-style estimate in [0,1] from cheap signals.
202
+ * NOT the official algorithm — only a hint when the real score is unavailable.
203
+ */
204
+ function estimateCriticality(r) {
205
+ const norm = (v, max) => Math.min(1, Math.log10(1 + v) / Math.log10(1 + max));
206
+ const stars = norm(r.stargazers_count, 40000);
207
+ const forks = norm(r.forks_count, 8000);
208
+ const issues = norm(r.open_issues_count, 2000);
209
+ const ageDays = (Date.now() - Date.parse(r.created_at)) / 86_400_000;
210
+ const age = norm(ageDays, 3650);
211
+ return Number((stars * 0.45 + forks * 0.25 + issues * 0.15 + age * 0.15).toFixed(3));
212
+ }
213
+ function coreContributorPlaceholder(login) {
214
+ return {
215
+ id: "core-contributor",
216
+ title: "Core contributor (major foundation/project committer)",
217
+ status: "unknown",
218
+ confidence: "manual",
219
+ detail: "Not machine-verifiable. If you're a listed committer on a major project " +
220
+ "(CPython, Rust, Node.js, Apache, Kubernetes, Django, Rails, …), cite it in your application.",
221
+ evidence: [
222
+ `Check your commit access / CONTRIBUTORS / MAINTAINERS listing and link it for ${login}.`,
223
+ ],
224
+ };
225
+ }
226
+ // --- orchestrator --------------------------------------------------------
227
+ export async function check(login, opts = {}) {
228
+ const specs = opts.packages ?? [];
229
+ const repoScan = opts.repoScan ?? 100;
230
+ let profileUrl;
231
+ try {
232
+ profileUrl = (await getUser(login)).html_url;
233
+ }
234
+ catch {
235
+ /* user lookup is non-fatal */
236
+ }
237
+ const [maintainer, active, cc] = await Promise.all([
238
+ checkMaintainer(specs),
239
+ checkActiveContributor(login),
240
+ checkCommunityAndCriticality(login, repoScan),
241
+ ]);
242
+ const criteria = [
243
+ maintainer,
244
+ coreContributorPlaceholder(login),
245
+ active,
246
+ cc.community,
247
+ cc.criticality,
248
+ ];
249
+ const passedCount = criteria.filter((x) => x.status === "pass").length;
250
+ return {
251
+ user: login,
252
+ profileUrl,
253
+ packages: specs,
254
+ criteria,
255
+ eligible: passedCount > 0,
256
+ passedCount,
257
+ };
258
+ }
@@ -0,0 +1,54 @@
1
+ export interface GhRepo {
2
+ name: string;
3
+ full_name: string;
4
+ owner: {
5
+ login: string;
6
+ };
7
+ fork: boolean;
8
+ archived: boolean;
9
+ stargazers_count: number;
10
+ forks_count: number;
11
+ open_issues_count: number;
12
+ pushed_at: string;
13
+ created_at: string;
14
+ }
15
+ export interface GhUser {
16
+ login: string;
17
+ name: string | null;
18
+ public_repos: number;
19
+ followers: number;
20
+ created_at: string;
21
+ html_url: string;
22
+ }
23
+ export declare function getUser(login: string): Promise<GhUser>;
24
+ /** Public source repos owned by the user, sorted by stars desc. */
25
+ export declare function getUserRepos(login: string, max?: number): Promise<GhRepo[]>;
26
+ export interface SearchCount {
27
+ total_count: number;
28
+ }
29
+ /** Merged PRs authored by `login` in repos they do NOT own, in the last `days`. */
30
+ export declare function countMergedPRsElsewhere(login: string, days?: number): Promise<number>;
31
+ export interface Contributor {
32
+ login: string;
33
+ contributions: number;
34
+ type: string;
35
+ }
36
+ /** Contributors to a repo (paginated, capped). */
37
+ export declare function getContributors(fullName: string, cap?: number): Promise<Contributor[]>;
38
+ export type Registry = "npmjs.org" | "pypi.org" | "crates.io" | "rubygems.org";
39
+ export interface PackageStats {
40
+ registry: Registry;
41
+ name: string;
42
+ monthlyDownloads: number | null;
43
+ dependentPackages: number | null;
44
+ dependentRepos: number | null;
45
+ criticalityScore: number | null;
46
+ }
47
+ /** ecosyste.ms unifies dependent counts across registries. */
48
+ export declare function getPackageStats(registry: Registry, name: string): Promise<PackageStats>;
49
+ /**
50
+ * Best-effort OpenSSF criticality score for a GitHub repo.
51
+ * ecosyste.ms exposes repo metadata but not always a criticality field, so
52
+ * this returns null when unavailable and the caller falls back to an estimate.
53
+ */
54
+ export declare function getCriticalityScore(fullName: string): Promise<number | null>;
@@ -0,0 +1,143 @@
1
+ // Data sources: GitHub REST API + package registry stats (npm, ecosyste.ms).
2
+ // All functions are best-effort and throw HttpError on transport/API failures;
3
+ // callers decide how to degrade.
4
+ import { getJson, isoDaysAgo } from "./util.js";
5
+ const GH = "https://api.github.com";
6
+ function ghHeaders() {
7
+ const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
8
+ const headers = {
9
+ accept: "application/vnd.github+json",
10
+ "x-github-api-version": "2022-11-28",
11
+ };
12
+ if (token)
13
+ headers.authorization = `Bearer ${token}`;
14
+ return headers;
15
+ }
16
+ export async function getUser(login) {
17
+ return getJson(`${GH}/users/${encodeURIComponent(login)}`, {
18
+ headers: ghHeaders(),
19
+ });
20
+ }
21
+ /** Public source repos owned by the user, sorted by stars desc. */
22
+ export async function getUserRepos(login, max = 100) {
23
+ const perPage = 100;
24
+ const out = [];
25
+ for (let page = 1; out.length < max; page++) {
26
+ const batch = await getJson(`${GH}/users/${encodeURIComponent(login)}/repos?per_page=${perPage}&page=${page}&sort=pushed`, { headers: ghHeaders() });
27
+ out.push(...batch);
28
+ if (batch.length < perPage)
29
+ break;
30
+ if (page >= 5)
31
+ break; // safety cap: 500 repos
32
+ }
33
+ return out
34
+ .filter((r) => !r.fork && !r.archived)
35
+ .sort((a, b) => b.stargazers_count - a.stargazers_count)
36
+ .slice(0, max);
37
+ }
38
+ /** Merged PRs authored by `login` in repos they do NOT own, in the last `days`. */
39
+ export async function countMergedPRsElsewhere(login, days = 365) {
40
+ const q = [
41
+ "type:pr",
42
+ `author:${login}`,
43
+ "is:merged",
44
+ `-user:${login}`,
45
+ `created:>=${isoDaysAgo(days)}`,
46
+ ].join("+");
47
+ const res = await getJson(`${GH}/search/issues?q=${q}&per_page=1&advanced_search=true`, { headers: ghHeaders() });
48
+ return res.total_count;
49
+ }
50
+ /** Contributors to a repo (paginated, capped). */
51
+ export async function getContributors(fullName, cap = 200) {
52
+ const perPage = 100;
53
+ const out = [];
54
+ for (let page = 1; out.length < cap; page++) {
55
+ const batch = await getJson(`${GH}/repos/${fullName}/contributors?per_page=${perPage}&page=${page}&anon=false`, { headers: ghHeaders() });
56
+ out.push(...batch);
57
+ if (batch.length < perPage)
58
+ break;
59
+ if (page >= 3)
60
+ break;
61
+ }
62
+ return out;
63
+ }
64
+ /** npm's own download endpoint is the most reliable source for npm packages. */
65
+ async function npmMonthlyDownloads(name) {
66
+ try {
67
+ const res = await getJson(`https://api.npmjs.org/downloads/point/last-month/${encodeURIComponent(name)}`);
68
+ return typeof res.downloads === "number" ? res.downloads : null;
69
+ }
70
+ catch {
71
+ return null;
72
+ }
73
+ }
74
+ /** pypistats.org exposes accurate last-month PyPI download counts. */
75
+ async function pypiMonthlyDownloads(name) {
76
+ try {
77
+ const res = await getJson(`https://pypistats.org/api/packages/${encodeURIComponent(name.toLowerCase())}/recent`);
78
+ return res.data?.last_month ?? null;
79
+ }
80
+ catch {
81
+ return null;
82
+ }
83
+ }
84
+ /** crates.io reports ~90-day "recent" downloads; approximate a monthly figure. */
85
+ async function cratesMonthlyDownloads(name) {
86
+ try {
87
+ const res = await getJson(`https://crates.io/api/v1/crates/${encodeURIComponent(name)}`);
88
+ const recent = res.crate?.recent_downloads;
89
+ return typeof recent === "number" ? Math.round(recent / 3) : null;
90
+ }
91
+ catch {
92
+ return null;
93
+ }
94
+ }
95
+ /** ecosyste.ms unifies dependent counts across registries. */
96
+ export async function getPackageStats(registry, name) {
97
+ const stats = {
98
+ registry,
99
+ name,
100
+ monthlyDownloads: null,
101
+ dependentPackages: null,
102
+ dependentRepos: null,
103
+ criticalityScore: null,
104
+ };
105
+ try {
106
+ const pkg = await getJson(`https://packages.ecosyste.ms/api/v1/registries/${registry}/packages/${encodeURIComponent(name)}`);
107
+ stats.dependentPackages = pkg.dependent_packages_count ?? null;
108
+ stats.dependentRepos = pkg.dependent_repos_count ?? null;
109
+ // ecosyste.ms `downloads` period varies; only trust it as a monthly figure
110
+ // when explicitly labeled as such.
111
+ if (pkg.downloads != null && /month/i.test(pkg.downloads_period ?? "")) {
112
+ stats.monthlyDownloads = pkg.downloads;
113
+ }
114
+ }
115
+ catch {
116
+ /* fall through to per-registry sources */
117
+ }
118
+ // Prefer each registry's authoritative download source over ecosyste.ms.
119
+ if (stats.monthlyDownloads == null) {
120
+ if (registry === "npmjs.org")
121
+ stats.monthlyDownloads = await npmMonthlyDownloads(name);
122
+ else if (registry === "pypi.org")
123
+ stats.monthlyDownloads = await pypiMonthlyDownloads(name);
124
+ else if (registry === "crates.io")
125
+ stats.monthlyDownloads = await cratesMonthlyDownloads(name);
126
+ }
127
+ return stats;
128
+ }
129
+ /**
130
+ * Best-effort OpenSSF criticality score for a GitHub repo.
131
+ * ecosyste.ms exposes repo metadata but not always a criticality field, so
132
+ * this returns null when unavailable and the caller falls back to an estimate.
133
+ */
134
+ export async function getCriticalityScore(fullName) {
135
+ try {
136
+ const repo = await getJson(`https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/${fullName}`);
137
+ const score = repo.metadata?.criticality_score;
138
+ return typeof score === "number" ? score : null;
139
+ }
140
+ catch {
141
+ return null;
142
+ }
143
+ }
package/dist/util.d.ts ADDED
@@ -0,0 +1,30 @@
1
+ export declare const c: {
2
+ bold: (s: string | number) => string;
3
+ dim: (s: string | number) => string;
4
+ red: (s: string | number) => string;
5
+ green: (s: string | number) => string;
6
+ yellow: (s: string | number) => string;
7
+ blue: (s: string | number) => string;
8
+ magenta: (s: string | number) => string;
9
+ cyan: (s: string | number) => string;
10
+ gray: (s: string | number) => string;
11
+ };
12
+ export declare class HttpError extends Error {
13
+ readonly status: number;
14
+ readonly url: string;
15
+ /** True when the failure is a GitHub/registry rate limit (HTTP 403/429). */
16
+ readonly rateLimited: boolean;
17
+ constructor(message: string, status: number, url: string,
18
+ /** True when the failure is a GitHub/registry rate limit (HTTP 403/429). */
19
+ rateLimited?: boolean);
20
+ }
21
+ export interface FetchOptions {
22
+ headers?: Record<string, string>;
23
+ timeoutMs?: number;
24
+ }
25
+ /** fetch JSON with a timeout and a descriptive error on non-2xx. */
26
+ export declare function getJson<T = unknown>(url: string, opts?: FetchOptions): Promise<T>;
27
+ export declare function fmtNumber(n: number): string;
28
+ /** Compact number: 1234 -> 1.2k, 1200000 -> 1.2M */
29
+ export declare function compact(n: number): string;
30
+ export declare function isoDaysAgo(days: number): string;
package/dist/util.js ADDED
@@ -0,0 +1,77 @@
1
+ // Small zero-dependency helpers: ANSI colors, fetch-with-timeout, formatting.
2
+ const useColor = process.env.NO_COLOR === undefined &&
3
+ process.env.FORCE_COLOR !== "0" &&
4
+ (process.stdout.isTTY || process.env.FORCE_COLOR !== undefined);
5
+ const wrap = (open, close) => (s) => useColor ? `[${open}m${s}[${close}m` : String(s);
6
+ export const c = {
7
+ bold: wrap(1, 22),
8
+ dim: wrap(2, 22),
9
+ red: wrap(31, 39),
10
+ green: wrap(32, 39),
11
+ yellow: wrap(33, 39),
12
+ blue: wrap(34, 39),
13
+ magenta: wrap(35, 39),
14
+ cyan: wrap(36, 39),
15
+ gray: wrap(90, 39),
16
+ };
17
+ export class HttpError extends Error {
18
+ status;
19
+ url;
20
+ rateLimited;
21
+ constructor(message, status, url,
22
+ /** True when the failure is a GitHub/registry rate limit (HTTP 403/429). */
23
+ rateLimited = false) {
24
+ super(message);
25
+ this.status = status;
26
+ this.url = url;
27
+ this.rateLimited = rateLimited;
28
+ this.name = "HttpError";
29
+ }
30
+ }
31
+ /** fetch JSON with a timeout and a descriptive error on non-2xx. */
32
+ export async function getJson(url, opts = {}) {
33
+ const controller = new AbortController();
34
+ const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 15000);
35
+ try {
36
+ const res = await fetch(url, {
37
+ headers: { "user-agent": "oss-perks", accept: "application/json", ...opts.headers },
38
+ signal: controller.signal,
39
+ });
40
+ if (!res.ok) {
41
+ const remaining = res.headers.get("x-ratelimit-remaining");
42
+ const rateLimited = res.status === 429 || (res.status === 403 && remaining === "0");
43
+ const hint = rateLimited
44
+ ? " — rate limited. Set GITHUB_TOKEN to raise the limit (see README)."
45
+ : "";
46
+ throw new HttpError(`HTTP ${res.status} for ${url}${hint}`, res.status, url, rateLimited);
47
+ }
48
+ return (await res.json());
49
+ }
50
+ catch (err) {
51
+ if (err instanceof HttpError)
52
+ throw err;
53
+ if (err instanceof Error && err.name === "AbortError") {
54
+ throw new HttpError(`Request timed out: ${url}`, 0, url);
55
+ }
56
+ throw err;
57
+ }
58
+ finally {
59
+ clearTimeout(timer);
60
+ }
61
+ }
62
+ export function fmtNumber(n) {
63
+ return n.toLocaleString("en-US");
64
+ }
65
+ /** Compact number: 1234 -> 1.2k, 1200000 -> 1.2M */
66
+ export function compact(n) {
67
+ if (n < 1000)
68
+ return String(n);
69
+ if (n < 1_000_000)
70
+ return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`;
71
+ return `${(n / 1_000_000).toFixed(1)}M`;
72
+ }
73
+ export function isoDaysAgo(days) {
74
+ // Deterministic ISO date `days` before now (UTC, date only).
75
+ const d = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
76
+ return d.toISOString().slice(0, 10);
77
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@o4h8/oss-perks",
3
+ "version": "0.1.0",
4
+ "description": "Check whether a GitHub user or package qualifies for open-source maintainer perk programs (Claude for Open Source, and more). Zero-config CLI.",
5
+ "keywords": [
6
+ "open-source",
7
+ "oss",
8
+ "maintainer",
9
+ "eligibility",
10
+ "github",
11
+ "npm",
12
+ "criticality",
13
+ "cli",
14
+ "claude"
15
+ ],
16
+ "type": "module",
17
+ "bin": {
18
+ "oss-perks": "dist/index.js"
19
+ },
20
+ "exports": {
21
+ ".": "./dist/lib.js"
22
+ },
23
+ "files": [
24
+ "dist",
25
+ "README.md",
26
+ "LICENSE"
27
+ ],
28
+ "scripts": {
29
+ "build": "tsc",
30
+ "dev": "tsc --watch",
31
+ "start": "node dist/index.js",
32
+ "test": "npm run build && node --test",
33
+ "prepublishOnly": "npm run build && npm test"
34
+ },
35
+ "engines": {
36
+ "node": ">=18"
37
+ },
38
+ "license": "MIT",
39
+ "author": "Ozkan Hancioglu <hanciogluozkan@gmail.com>",
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/o4h8/oss-perks.git"
43
+ },
44
+ "homepage": "https://github.com/o4h8/oss-perks#readme",
45
+ "bugs": {
46
+ "url": "https://github.com/o4h8/oss-perks/issues"
47
+ },
48
+ "devDependencies": {
49
+ "@types/node": "^22.10.0",
50
+ "typescript": "^5.7.0"
51
+ }
52
+ }