DEV Community

Wilson Xu
Wilson Xu

Posted on

Build a GitHub Profile Analytics CLI with Node.js

Build a GitHub Profile Analytics CLI with Node.js

Ever wanted to instantly see how many total stars a developer has, what languages they work with most, or compare two GitHub profiles side by side -- all from your terminal? In this tutorial, we'll build ghprofile-stats, a CLI tool that queries the GitHub REST API, aggregates statistics across all of a user's repositories, and renders a colorful dashboard right in the terminal.

By the end, you'll have a publishable npm package that does real work. Let's get into it.


Why GitHub Profile Analytics Matter

GitHub profiles are more than commit histories. They are resumes, dependency trust signals, and community scorecards all rolled into one.

Hiring and recruiting. Engineering managers routinely review candidate GitHub profiles. A quick summary -- total stars, primary languages, contribution frequency -- saves time and surfaces signal that a polished README might obscure.

Dependency evaluation. Before you npm install a package, you want to know: is the maintainer active? Do they have a track record of maintaining projects? Aggregate stats like total repos, recent activity, and fork counts give you a fast health check.

Portfolio building. If you're a developer building your personal brand, understanding your own metrics helps you decide where to invest time. Maybe your Python projects get traction but your Go experiments don't. Data drives those decisions.

A CLI tool makes this instant. No browser tabs, no clicking through pages -- just ghstats user sindresorhus and you have the full picture.


Project Setup

Initialize the project and install dependencies:

mkdir ghstats-cli && cd ghstats-cli
npm init -y
npm install chalk@4 cli-table3 commander
npm install -D typescript @types/node
npx tsc --init
Enter fullscreen mode Exit fullscreen mode

We're using chalk v4 (the last CommonJS version) for terminal colors, cli-table3 for formatted tables, and commander for argument parsing. All three are stable, well-maintained, and have zero sub-dependencies worth worrying about.

In your tsconfig.json, set "target": "ES2020" and "module": "commonjs". Add a "bin" field to package.json:

{
  "bin": {
    "ghstats": "dist/index.js"
  }
}
Enter fullscreen mode Exit fullscreen mode

Querying the GitHub REST API Without Authentication

GitHub's REST API is generous with unauthenticated access -- 60 requests per hour per IP. That's plenty for analyzing a single profile. For heavier usage, a personal access token bumps you to 5,000 requests per hour.

Here's the core HTTP layer. We use Node's built-in https module to avoid adding axios or node-fetch as dependencies:

const API_BASE = 'https://api.github.com';
let rateLimitInfo: RateLimitInfo = { remaining: 60, limit: 60, reset: 0 };

function httpGet(url: string): Promise<{ data: any; headers: any; statusCode: number }> {
  return new Promise((resolve, reject) => {
    const parsedUrl = new URL(url);
    const options = {
      hostname: parsedUrl.hostname,
      path: parsedUrl.pathname + parsedUrl.search,
      method: 'GET',
      headers: {
        'User-Agent': 'ghstats-cli/1.0.0',
        'Accept': 'application/vnd.github.v3+json',
      },
    };

    const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
    if (token) {
      (options.headers as any)['Authorization'] = `Bearer ${token}`;
    }

    const req = https.request(options, (res) => {
      let body = '';
      res.on('data', (chunk) => (body += chunk));
      res.on('end', () => {
        const remaining = res.headers['x-ratelimit-remaining'];
        const limit = res.headers['x-ratelimit-limit'];
        const reset = res.headers['x-ratelimit-reset'];
        if (remaining) rateLimitInfo.remaining = parseInt(remaining as string, 10);
        if (limit) rateLimitInfo.limit = parseInt(limit as string, 10);
        if (reset) rateLimitInfo.reset = parseInt(reset as string, 10);

        try {
          const data = JSON.parse(body);
          resolve({ data, headers: res.headers, statusCode: res.statusCode || 0 });
        } catch {
          reject(new Error(`Failed to parse response from ${url}`));
        }
      });
    });

    req.on('error', reject);
    req.setTimeout(15000, () => {
      req.destroy();
      reject(new Error(`Request timed out: ${url}`));
    });
    req.end();
  });
}
Enter fullscreen mode Exit fullscreen mode

Two important details here:

  1. The User-Agent header is required. GitHub rejects requests without one.
  2. We track rate limit info from response headers. Every GitHub API response includes x-ratelimit-remaining, x-ratelimit-limit, and x-ratelimit-reset. We store these globally so we can warn users before they hit the wall.

The wrapper function apiGet adds error handling and rate limit checking:

async function apiGet<T>(path: string): Promise<T> {
  if (rateLimitInfo.remaining <= 1) {
    const resetTime = new Date(rateLimitInfo.reset * 1000);
    const waitSec = Math.max(0, Math.ceil((resetTime.getTime() - Date.now()) / 1000));
    throw new Error(
      `GitHub API rate limit exceeded. Resets at ${resetTime.toLocaleTimeString()} (${waitSec}s). ` +
      `Set GITHUB_TOKEN env var for 5000 req/hr instead of 60.`
    );
  }

  const { data, statusCode } = await httpGet(`${API_BASE}${path}`);

  if (statusCode === 404) throw new Error(`Not found: ${path}`);
  if (statusCode === 403 && data.message?.includes('rate limit')) {
    throw new Error(`GitHub API rate limit exceeded. Set GITHUB_TOKEN env var for higher limits.`);
  }
  if (statusCode && statusCode >= 400) {
    throw new Error(`GitHub API error (${statusCode}): ${data.message || 'Unknown error'}`);
  }

  return data as T;
}
Enter fullscreen mode Exit fullscreen mode

This pre-flight rate limit check prevents wasted requests. If you're at 1 remaining, the function throws immediately with a human-readable message including when the limit resets.


Fetching All Repos with Pagination

GitHub's /users/:username/repos endpoint returns at most 100 items per page. For prolific developers, we need pagination:

async function getAllRepos(username: string): Promise<GitHubRepo[]> {
  const allRepos: GitHubRepo[] = [];
  let page = 1;
  const perPage = 100;

  while (true) {
    const repos = await apiGet<GitHubRepo[]>(
      `/users/${username}/repos?per_page=${perPage}&page=${page}&sort=updated&type=owner`
    );
    if (!repos || repos.length === 0) break;
    allRepos.push(...repos);
    if (repos.length < perPage) break;
    page++;
    if (page > 10) break; // Safety cap: 1000 repos max
  }

  return allRepos;
}
Enter fullscreen mode Exit fullscreen mode

The type=owner parameter filters out repos the user has forked but doesn't own. The safety cap at 10 pages prevents runaway loops for bot accounts with thousands of repos.


Aggregating Stats Across All Repos

With all repos in hand, we compute the interesting metrics. Total stars and forks are simple reductions:

function computeTotalStars(repos: GitHubRepo[]): number {
  return repos.reduce((sum, r) => sum + r.stargazers_count, 0);
}

function computeTotalForks(repos: GitHubRepo[]): number {
  return repos.reduce((sum, r) => sum + r.forks_count, 0);
}
Enter fullscreen mode Exit fullscreen mode

Language breakdown is more nuanced. We count how many repos use each language (excluding forks to avoid skewing the data), then sort by frequency:

function computeLanguageBreakdown(repos: GitHubRepo[]): Map<string, number> {
  const langMap = new Map<string, number>();
  for (const repo of repos) {
    if (repo.language && !repo.fork) {
      langMap.set(repo.language, (langMap.get(repo.language) || 0) + 1);
    }
  }
  return new Map([...langMap.entries()].sort((a, b) => b[1] - a[1]));
}
Enter fullscreen mode Exit fullscreen mode

Why exclude forks? If someone forks 50 JavaScript repos to submit pull requests, that doesn't mean JavaScript is their primary language. Filtering on !repo.fork gives a more accurate picture.


Building a Visual CLI Dashboard

This is where the tool goes from functional to delightful. We use chalk for colors and cli-table3 for structured layouts.

The Profile Header

function printUserProfile(user: GitHubUser, totalStars: number, totalForks: number): void {
  const profileTable = new Table({
    chars: {
      top: '', 'top-mid': '', 'top-left': '', 'top-right': '',
      bottom: '', 'bottom-mid': '', 'bottom-left': '', 'bottom-right': '',
      left: '  ', 'left-mid': '', mid: '', 'mid-mid': '',
      right: '', 'right-mid': '', middle: '  ',
    },
    style: { 'padding-left': 0, 'padding-right': 1 },
  });

  profileTable.push(
    [chalk.cyan('Repos'), chalk.cyan('Stars'), chalk.cyan('Forks'),
     chalk.cyan('Followers'), chalk.cyan('Following')],
    [
      chalk.bold.white(formatNumber(user.public_repos)),
      chalk.bold.yellow(formatNumber(totalStars)),
      chalk.bold.green(formatNumber(totalForks)),
      chalk.bold.magenta(formatNumber(user.followers)),
      chalk.bold.white(formatNumber(user.following)),
    ]
  );
  console.log(profileTable.toString());
}
Enter fullscreen mode Exit fullscreen mode

The trick here is the chars configuration on the table. By setting all border characters to empty strings, we get a clean, borderless layout that feels more like a dashboard than a spreadsheet. The left: ' ' adds a consistent indent.

Language Bar Chart

Text-based bar charts bring life to the terminal. We use Unicode block characters and language-specific colors (matching GitHub's language colors):

function printLanguageBreakdown(langMap: Map<string, number>, totalRepos: number): void {
  const maxBarWidth = 30;
  const maxCount = Math.max(...langMap.values());

  for (const [lang, count] of langMap) {
    const pct = ((count / totalRepos) * 100).toFixed(1);
    const barLen = Math.max(1, Math.round((count / maxCount) * maxBarWidth));
    const bar = chalk.hex(languageColor(lang))('\u2588'.repeat(barLen));
    const label = chalk.hex(languageColor(lang))(lang.padEnd(14));
    console.log(`  ${label} ${bar} ${chalk.gray(count + ' repos')} ${chalk.gray('(' + pct + '%)')}`);
  }
}
Enter fullscreen mode Exit fullscreen mode

The languageColor function maps language names to their GitHub hex colors -- #f1e05a for JavaScript, #3178c6 for TypeScript, and so on. chalk.hex() renders these faithfully in terminals that support true color.

Number Formatting

Large numbers get abbreviated for readability:

function formatNumber(n: number): string {
  if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
  if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
  return n.toString();
}
Enter fullscreen mode Exit fullscreen mode

So sindresorhus shows 162.3K stars instead of 162347. Small detail, big UX improvement.


Adding a User Comparison Feature

This is the feature that makes the tool genuinely useful beyond what github.com offers natively. The compare command fetches multiple profiles in parallel and renders them side by side:

async function handleCompare(usernames: string[], options: { json?: boolean }): Promise<void> {
  const results = await Promise.all(
    usernames.map(async (u) => {
      const user = await apiGet<GitHubUser>(`/users/${u}`);
      const repos = await getAllRepos(u);
      const ownRepos = repos.filter(r => !r.fork);
      const totalStars = computeTotalStars(ownRepos);
      const totalForks = computeTotalForks(ownRepos);
      const topLang = computeLanguageBreakdown(repos);
      const primaryLang = topLang.size > 0 ? [...topLang.entries()][0][0] : 'N/A';
      return { user, totalStars, totalForks, primaryLang, repoCount: ownRepos.length };
    })
  );

  const table = new Table({
    head: [chalk.cyan('Metric'), ...results.map(r => chalk.bold.white('@' + r.user.login))],
    style: { head: [], border: ['gray'] },
  });

  table.push(
    [chalk.gray('Name'), ...results.map(r => r.user.name || chalk.gray('N/A'))],
    [chalk.gray('Repos'), ...results.map(r => chalk.white(r.repoCount.toString()))],
    [chalk.gray('Total Stars'), ...results.map(r => chalk.yellow(formatNumber(r.totalStars)))],
    [chalk.gray('Total Forks'), ...results.map(r => chalk.green(formatNumber(r.totalForks)))],
    [chalk.gray('Followers'), ...results.map(r => chalk.magenta(formatNumber(r.user.followers)))],
    [chalk.gray('Primary Lang'), ...results.map(r => r.primaryLang)],
    [chalk.gray('Joined'), ...results.map(r => new Date(r.user.created_at).getFullYear().toString())],
  );

  console.log(table.toString());
}
Enter fullscreen mode Exit fullscreen mode

Usage: ghstats compare sindresorhus tj gaearon

Promise.all fires all the API calls concurrently, which keeps total latency roughly equal to analyzing a single user. The dynamic table columns handle any number of users -- compare 2, 3, or 5 at once.

This is a feature GitHub itself doesn't offer. You can't natively compare two profiles side by side on github.com. That's the value proposition of building your own tooling.


Wiring Up the CLI with Commander

Commander gives us a clean subcommand structure with zero boilerplate:

const program = new Command();

program
  .name('ghstats')
  .description('GitHub profile and repository statistics analyzer')
  .version('1.0.0');

program
  .command('user <username>')
  .description('Analyze a GitHub user profile')
  .option('-t, --top <n>', 'Number of top repos to show', '10')
  .option('--json', 'Output raw JSON data')
  .action(handleUser);

program
  .command('repo <path>')
  .description('Analyze a GitHub repository (owner/repo or URL)')
  .option('--json', 'Output raw JSON data')
  .action(handleRepo);

program
  .command('compare <users...>')
  .description('Compare multiple GitHub profiles side by side')
  .option('--json', 'Output raw JSON data')
  .action(handleCompare);
Enter fullscreen mode Exit fullscreen mode

We also add a default action that auto-detects intent: if the argument contains a /, treat it as a repo lookup; otherwise, treat it as a username. This means ghstats sindresorhus and ghstats user sindresorhus do the same thing.

The --json flag on every command outputs raw JSON instead of the formatted dashboard. This is critical for composability -- pipe the output to jq, feed it into a script, or integrate it with other tools.


Publishing to npm

Publishing a TypeScript CLI to npm requires a few steps:

  1. Build the project:
npx tsc
Enter fullscreen mode Exit fullscreen mode
  1. Make the entry point executable. Your src/index.ts must start with the shebang:
#!/usr/bin/env node
Enter fullscreen mode Exit fullscreen mode
  1. Set up package.json properly:
{
  "name": "ghprofile-stats",
  "version": "1.2.0",
  "bin": {
    "ghstats": "dist/index.js"
  },
  "files": ["dist"],
  "keywords": ["github", "stats", "profile", "cli", "analytics"]
}
Enter fullscreen mode Exit fullscreen mode

The files array ensures only the compiled dist/ directory gets published -- no TypeScript source, no node_modules, no test fixtures.

  1. Publish:
npm login
npm publish
Enter fullscreen mode Exit fullscreen mode

After publishing, anyone can install it globally:

npm install -g ghprofile-stats
ghstats user octocat
Enter fullscreen mode Exit fullscreen mode

Quick Tip: Test Before You Publish

Always run npm pack before npm publish. It creates a .tgz file you can inspect to verify exactly what gets shipped. Accidentally publishing your .env or node_modules is a rite of passage, but it's avoidable.


What We Built

In about 750 lines of TypeScript, we built a tool that:

  • Fetches and aggregates GitHub profile data across all repositories
  • Renders a color-coded terminal dashboard with profile stats, top repos, language breakdown, and activity summary
  • Compares multiple GitHub profiles side by side
  • Handles rate limiting gracefully with clear error messages
  • Supports both formatted output and JSON for scripting
  • Works without authentication out of the box
  • Is published and installable from npm

The key lessons: GitHub's REST API is surprisingly powerful without authentication, chalk and cli-table3 turn raw data into something people actually want to look at, and a CLI tool with a --json flag is infinitely more useful than one without.

Install the finished tool: npm install -g ghprofile-stats

Top comments (0)