← back to Gurisingh Scout
scripts/gh-repo-stats.js
124 lines
#!/usr/bin/env node
/**
* gh-repo-stats.js — turn a GitHub repo link into hard adoption signals.
*
* Guri Singh's posts almost always point at an open-source repo. Before scoring
* whether it's worth adopting, resolve the objective facts: popularity, license,
* how recently it was touched, primary language, archived/stale status, and —
* critically for us — whether it is literally a Claude Code skill/plugin
* (SKILL.md, .claude/, plugin manifest) versus a general CLI/tool.
*
* Usage:
* node gh-repo-stats.js <github-url-or-owner/repo>
*
* Auth: unauthenticated works (60 req/hr). Set GITHUB_TOKEN in env to raise the
* limit to 5000/hr — the script reads it automatically if present.
*
* Exit codes: 0 ok, 1 error (bad input / not found / network).
* Output: JSON summary on stdout.
*/
'use strict';
function parseRepo(arg) {
if (!arg) return null;
const m = String(arg).match(/github\.com\/([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)/);
if (m) return { owner: m[1], repo: m[2].replace(/\.git$/, '') };
const slug = String(arg).match(/^([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)$/);
if (slug) return { owner: slug[1], repo: slug[2] };
return null;
}
function ghHeaders() {
const h = {
'User-Agent': 'gurisingh-scout',
Accept: 'application/vnd.github+json',
};
const tok = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
if (tok) h.Authorization = `Bearer ${tok}`;
return h;
}
async function gh(url) {
const res = await fetch(url, { headers: ghHeaders() });
if (res.status === 404) return { __status: 404 };
if (res.status === 403) return { __status: 403, __rate: res.headers.get('x-ratelimit-remaining') };
if (!res.ok) throw new Error(`http ${res.status} ${url}`);
return res.json();
}
function daysSince(iso) {
if (!iso) return null;
return Math.round((Date.now() - new Date(iso).getTime()) / 86400000);
}
async function main() {
const parsed = parseRepo(process.argv[2]);
if (!parsed) {
console.error('usage: node gh-repo-stats.js <github-url-or-owner/repo>');
process.exit(1);
}
const { owner, repo } = parsed;
let meta;
try {
meta = await gh(`https://api.github.com/repos/${owner}/${repo}`);
} catch (e) {
console.error(`error: ${e.message}`);
process.exit(1);
}
if (meta.__status === 404) {
console.error(`not found: ${owner}/${repo}`);
process.exit(1);
}
if (meta.__status === 403) {
console.error(`rate-limited (remaining=${meta.__rate}). Set GITHUB_TOKEN to raise the limit.`);
process.exit(1);
}
// Root listing → detect "is this a Claude skill/plugin?" (one extra call).
let isClaudeSkill = false;
let claudeMarkers = [];
try {
const contents = await gh(`https://api.github.com/repos/${owner}/${repo}/contents`);
if (Array.isArray(contents)) {
const names = contents.map((c) => c.name.toLowerCase());
const markers = ['skill.md', '.claude', 'skills', 'plugin.json', '.claude-plugin', 'commands', 'agents'];
claudeMarkers = markers.filter((m) => names.includes(m));
isClaudeSkill = names.includes('skill.md') || names.includes('.claude') ||
names.includes('.claude-plugin') ||
(names.includes('skills') && (names.includes('plugin.json') || names.includes('.claude-plugin')));
}
} catch {
/* non-fatal — leave detection empty */
}
const daysStale = daysSince(meta.pushed_at);
const out = {
repo: `${owner}/${repo}`,
url: meta.html_url,
description: meta.description,
stars: meta.stargazers_count,
forks: meta.forks_count,
open_issues: meta.open_issues_count,
language: meta.language,
license: meta.license ? meta.license.spdx_id : 'NONE',
pushed_at: meta.pushed_at,
days_since_push: daysStale,
archived: meta.archived,
is_claude_skill: isClaudeSkill,
claude_markers: claudeMarkers,
// quick, opinionated flags to feed the adoption rubric
flags: {
unlicensed: !meta.license,
stale: daysStale != null && daysStale > 120,
archived: !!meta.archived,
low_traction: meta.stargazers_count < 50,
},
};
console.log(JSON.stringify(out, null, 2));
process.exit(0);
}
main();