← 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();