Filemedium importancesource

installCounts.ts

utils/plugins/installCounts.ts

No strong subsystem tag
293
Lines
8315
Bytes
2
Exports
11
Imports
10
Keywords

What this is

This page documents one file from the repository and includes its full source so you can read it without leaving the docs site.

Beginner explanation

This file is one piece of the larger system. Its name, directory, imports, and exports show where it fits. Start by reading the exports and related files first.

How it is used

Start from the exports list and related files. Those are the easiest clues for where this file fits into the system.

Expert explanation

Architecturally, this file intersects with general runtime concerns. It contains 293 lines, 11 detected imports, and 2 detected exports.

Important relationships

Detected exports

  • getInstallCounts
  • formatInstallCount

Keywords

countscacheinstallfetchedatlogfordebuggingentryplugincountversionformatted

Detected imports

  • axios
  • crypto
  • fs/promises
  • path
  • ../debug.js
  • ../errors.js
  • ../fsOperations.js
  • ../log.js
  • ../slowOperations.js
  • ./fetchTelemetry.js
  • ./pluginDirectories.js

Source notes

This page embeds the full file contents. Small or leaf files are still indexed honestly instead of being over-explained.

Open parent directory

Full source

/**
 * Plugin install counts data layer
 *
 * This module fetches and caches plugin install counts from the official
 * Claude plugins statistics repository. The cache is refreshed if older
 * than 24 hours.
 *
 * Cache location: ~/.claude/plugins/install-counts-cache.json
 */

import axios from 'axios'
import { randomBytes } from 'crypto'
import { readFile, rename, unlink, writeFile } from 'fs/promises'
import { join } from 'path'
import { logForDebugging } from '../debug.js'
import { errorMessage, getErrnoCode } from '../errors.js'
import { getFsImplementation } from '../fsOperations.js'
import { logError } from '../log.js'
import { jsonParse, jsonStringify } from '../slowOperations.js'
import { classifyFetchError, logPluginFetch } from './fetchTelemetry.js'
import { getPluginsDirectory } from './pluginDirectories.js'

const INSTALL_COUNTS_CACHE_VERSION = 1
const INSTALL_COUNTS_CACHE_FILENAME = 'install-counts-cache.json'
const INSTALL_COUNTS_URL =
  'https://raw.githubusercontent.com/anthropics/claude-plugins-official/refs/heads/stats/stats/plugin-installs.json'
const CACHE_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours in milliseconds

/**
 * Structure of the install counts cache file
 */
type InstallCountsCache = {
  version: number
  fetchedAt: string // ISO timestamp
  counts: Array<{
    plugin: string // "pluginName@marketplace"
    unique_installs: number
  }>
}

/**
 * Expected structure of the GitHub stats response
 */
type GitHubStatsResponse = {
  plugins: Array<{
    plugin: string
    unique_installs: number
  }>
}

/**
 * Get the path to the install counts cache file
 */
function getInstallCountsCachePath(): string {
  return join(getPluginsDirectory(), INSTALL_COUNTS_CACHE_FILENAME)
}

/**
 * Load the install counts cache from disk.
 * Returns null if the file doesn't exist, is invalid, or is stale (>24h old).
 */
async function loadInstallCountsCache(): Promise<InstallCountsCache | null> {
  const cachePath = getInstallCountsCachePath()

  try {
    const content = await readFile(cachePath, { encoding: 'utf-8' })
    const parsed = jsonParse(content) as unknown

    // Validate basic structure
    if (
      typeof parsed !== 'object' ||
      parsed === null ||
      !('version' in parsed) ||
      !('fetchedAt' in parsed) ||
      !('counts' in parsed)
    ) {
      logForDebugging('Install counts cache has invalid structure')
      return null
    }

    const cache = parsed as {
      version: unknown
      fetchedAt: unknown
      counts: unknown
    }

    // Validate version
    if (cache.version !== INSTALL_COUNTS_CACHE_VERSION) {
      logForDebugging(
        `Install counts cache version mismatch (got ${cache.version}, expected ${INSTALL_COUNTS_CACHE_VERSION})`,
      )
      return null
    }

    // Validate fetchedAt and counts
    if (typeof cache.fetchedAt !== 'string' || !Array.isArray(cache.counts)) {
      logForDebugging('Install counts cache has invalid structure')
      return null
    }

    // Validate fetchedAt is a valid date
    const fetchedAt = new Date(cache.fetchedAt).getTime()
    if (Number.isNaN(fetchedAt)) {
      logForDebugging('Install counts cache has invalid fetchedAt timestamp')
      return null
    }

    // Validate count entries have required fields
    const validCounts = cache.counts.every(
      (entry): entry is { plugin: string; unique_installs: number } =>
        typeof entry === 'object' &&
        entry !== null &&
        typeof entry.plugin === 'string' &&
        typeof entry.unique_installs === 'number',
    )
    if (!validCounts) {
      logForDebugging('Install counts cache has malformed entries')
      return null
    }

    // Check if cache is stale (>24 hours old)
    const now = Date.now()
    if (now - fetchedAt > CACHE_TTL_MS) {
      logForDebugging('Install counts cache is stale (>24h old)')
      return null
    }

    // Return validated cache
    return {
      version: cache.version as number,
      fetchedAt: cache.fetchedAt,
      counts: cache.counts,
    }
  } catch (error) {
    const code = getErrnoCode(error)
    if (code !== 'ENOENT') {
      logForDebugging(
        `Failed to load install counts cache: ${errorMessage(error)}`,
      )
    }
    return null
  }
}

/**
 * Save the install counts cache to disk atomically.
 * Uses a temp file + rename pattern to prevent corruption.
 */
async function saveInstallCountsCache(
  cache: InstallCountsCache,
): Promise<void> {
  const cachePath = getInstallCountsCachePath()
  const tempPath = `${cachePath}.${randomBytes(8).toString('hex')}.tmp`

  try {
    // Ensure the plugins directory exists
    const pluginsDir = getPluginsDirectory()
    await getFsImplementation().mkdir(pluginsDir)

    // Write to temp file
    const content = jsonStringify(cache, null, 2)
    await writeFile(tempPath, content, {
      encoding: 'utf-8',
      mode: 0o600,
    })

    // Atomic rename
    await rename(tempPath, cachePath)
    logForDebugging('Install counts cache saved successfully')
  } catch (error) {
    logError(error)
    // Clean up temp file if it exists
    try {
      await unlink(tempPath)
    } catch {
      // Ignore cleanup errors
    }
  }
}

/**
 * Fetch install counts from GitHub stats repository
 */
async function fetchInstallCountsFromGitHub(): Promise<
  Array<{ plugin: string; unique_installs: number }>
> {
  logForDebugging(`Fetching install counts from ${INSTALL_COUNTS_URL}`)

  const started = performance.now()
  try {
    const response = await axios.get<GitHubStatsResponse>(INSTALL_COUNTS_URL, {
      timeout: 10000,
    })

    if (!response.data?.plugins || !Array.isArray(response.data.plugins)) {
      throw new Error('Invalid response format from install counts API')
    }

    logPluginFetch(
      'install_counts',
      INSTALL_COUNTS_URL,
      'success',
      performance.now() - started,
    )
    return response.data.plugins
  } catch (error) {
    logPluginFetch(
      'install_counts',
      INSTALL_COUNTS_URL,
      'failure',
      performance.now() - started,
      classifyFetchError(error),
    )
    throw error
  }
}

/**
 * Get plugin install counts as a Map.
 * Uses cached data if available and less than 24 hours old.
 * Returns null on errors so UI can hide counts rather than show misleading zeros.
 *
 * @returns Map of plugin ID (name@marketplace) to install count, or null if unavailable
 */
export async function getInstallCounts(): Promise<Map<string, number> | null> {
  // Try to load from cache first
  const cache = await loadInstallCountsCache()
  if (cache) {
    logForDebugging('Using cached install counts')
    logPluginFetch('install_counts', INSTALL_COUNTS_URL, 'cache_hit', 0)
    const map = new Map<string, number>()
    for (const entry of cache.counts) {
      map.set(entry.plugin, entry.unique_installs)
    }
    return map
  }

  // Cache miss or stale - fetch from GitHub
  try {
    const counts = await fetchInstallCountsFromGitHub()

    // Save to cache
    const newCache: InstallCountsCache = {
      version: INSTALL_COUNTS_CACHE_VERSION,
      fetchedAt: new Date().toISOString(),
      counts,
    }
    await saveInstallCountsCache(newCache)

    // Convert to Map
    const map = new Map<string, number>()
    for (const entry of counts) {
      map.set(entry.plugin, entry.unique_installs)
    }
    return map
  } catch (error) {
    // Log error and return null so UI can hide counts
    logError(error)
    logForDebugging(`Failed to fetch install counts: ${errorMessage(error)}`)
    return null
  }
}

/**
 * Format an install count for display.
 *
 * @param count - The raw install count
 * @returns Formatted string:
 *   - <1000: raw number (e.g., "42")
 *   - >=1000: K suffix with 1 decimal (e.g., "1.2K", "36.2K")
 *   - >=1000000: M suffix with 1 decimal (e.g., "1.2M")
 */
export function formatInstallCount(count: number): string {
  if (count < 1000) {
    return String(count)
  }

  if (count < 1000000) {
    const k = count / 1000
    // Use toFixed(1) but remove trailing .0
    const formatted = k.toFixed(1)
    return formatted.endsWith('.0')
      ? `${formatted.slice(0, -2)}K`
      : `${formatted}K`
  }

  const m = count / 1000000
  const formatted = m.toFixed(1)
  return formatted.endsWith('.0')
    ? `${formatted.slice(0, -2)}M`
    : `${formatted}M`
}