/ utils / plugins / installCounts.ts
installCounts.ts
  1  /**
  2   * Plugin install counts data layer
  3   *
  4   * This module fetches and caches plugin install counts from the official
  5   * Claude plugins statistics repository. The cache is refreshed if older
  6   * than 24 hours.
  7   *
  8   * Cache location: ~/.claude/plugins/install-counts-cache.json
  9   */
 10  
 11  import axios from 'axios'
 12  import { randomBytes } from 'crypto'
 13  import { readFile, rename, unlink, writeFile } from 'fs/promises'
 14  import { join } from 'path'
 15  import { logForDebugging } from '../debug.js'
 16  import { errorMessage, getErrnoCode } from '../errors.js'
 17  import { getFsImplementation } from '../fsOperations.js'
 18  import { logError } from '../log.js'
 19  import { jsonParse, jsonStringify } from '../slowOperations.js'
 20  import { classifyFetchError, logPluginFetch } from './fetchTelemetry.js'
 21  import { getPluginsDirectory } from './pluginDirectories.js'
 22  
 23  const INSTALL_COUNTS_CACHE_VERSION = 1
 24  const INSTALL_COUNTS_CACHE_FILENAME = 'install-counts-cache.json'
 25  const INSTALL_COUNTS_URL =
 26    'https://raw.githubusercontent.com/anthropics/claude-plugins-official/refs/heads/stats/stats/plugin-installs.json'
 27  const CACHE_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours in milliseconds
 28  
 29  /**
 30   * Structure of the install counts cache file
 31   */
 32  type InstallCountsCache = {
 33    version: number
 34    fetchedAt: string // ISO timestamp
 35    counts: Array<{
 36      plugin: string // "pluginName@marketplace"
 37      unique_installs: number
 38    }>
 39  }
 40  
 41  /**
 42   * Expected structure of the GitHub stats response
 43   */
 44  type GitHubStatsResponse = {
 45    plugins: Array<{
 46      plugin: string
 47      unique_installs: number
 48    }>
 49  }
 50  
 51  /**
 52   * Get the path to the install counts cache file
 53   */
 54  function getInstallCountsCachePath(): string {
 55    return join(getPluginsDirectory(), INSTALL_COUNTS_CACHE_FILENAME)
 56  }
 57  
 58  /**
 59   * Load the install counts cache from disk.
 60   * Returns null if the file doesn't exist, is invalid, or is stale (>24h old).
 61   */
 62  async function loadInstallCountsCache(): Promise<InstallCountsCache | null> {
 63    const cachePath = getInstallCountsCachePath()
 64  
 65    try {
 66      const content = await readFile(cachePath, { encoding: 'utf-8' })
 67      const parsed = jsonParse(content) as unknown
 68  
 69      // Validate basic structure
 70      if (
 71        typeof parsed !== 'object' ||
 72        parsed === null ||
 73        !('version' in parsed) ||
 74        !('fetchedAt' in parsed) ||
 75        !('counts' in parsed)
 76      ) {
 77        logForDebugging('Install counts cache has invalid structure')
 78        return null
 79      }
 80  
 81      const cache = parsed as {
 82        version: unknown
 83        fetchedAt: unknown
 84        counts: unknown
 85      }
 86  
 87      // Validate version
 88      if (cache.version !== INSTALL_COUNTS_CACHE_VERSION) {
 89        logForDebugging(
 90          `Install counts cache version mismatch (got ${cache.version}, expected ${INSTALL_COUNTS_CACHE_VERSION})`,
 91        )
 92        return null
 93      }
 94  
 95      // Validate fetchedAt and counts
 96      if (typeof cache.fetchedAt !== 'string' || !Array.isArray(cache.counts)) {
 97        logForDebugging('Install counts cache has invalid structure')
 98        return null
 99      }
100  
101      // Validate fetchedAt is a valid date
102      const fetchedAt = new Date(cache.fetchedAt).getTime()
103      if (Number.isNaN(fetchedAt)) {
104        logForDebugging('Install counts cache has invalid fetchedAt timestamp')
105        return null
106      }
107  
108      // Validate count entries have required fields
109      const validCounts = cache.counts.every(
110        (entry): entry is { plugin: string; unique_installs: number } =>
111          typeof entry === 'object' &&
112          entry !== null &&
113          typeof entry.plugin === 'string' &&
114          typeof entry.unique_installs === 'number',
115      )
116      if (!validCounts) {
117        logForDebugging('Install counts cache has malformed entries')
118        return null
119      }
120  
121      // Check if cache is stale (>24 hours old)
122      const now = Date.now()
123      if (now - fetchedAt > CACHE_TTL_MS) {
124        logForDebugging('Install counts cache is stale (>24h old)')
125        return null
126      }
127  
128      // Return validated cache
129      return {
130        version: cache.version as number,
131        fetchedAt: cache.fetchedAt,
132        counts: cache.counts,
133      }
134    } catch (error) {
135      const code = getErrnoCode(error)
136      if (code !== 'ENOENT') {
137        logForDebugging(
138          `Failed to load install counts cache: ${errorMessage(error)}`,
139        )
140      }
141      return null
142    }
143  }
144  
145  /**
146   * Save the install counts cache to disk atomically.
147   * Uses a temp file + rename pattern to prevent corruption.
148   */
149  async function saveInstallCountsCache(
150    cache: InstallCountsCache,
151  ): Promise<void> {
152    const cachePath = getInstallCountsCachePath()
153    const tempPath = `${cachePath}.${randomBytes(8).toString('hex')}.tmp`
154  
155    try {
156      // Ensure the plugins directory exists
157      const pluginsDir = getPluginsDirectory()
158      await getFsImplementation().mkdir(pluginsDir)
159  
160      // Write to temp file
161      const content = jsonStringify(cache, null, 2)
162      await writeFile(tempPath, content, {
163        encoding: 'utf-8',
164        mode: 0o600,
165      })
166  
167      // Atomic rename
168      await rename(tempPath, cachePath)
169      logForDebugging('Install counts cache saved successfully')
170    } catch (error) {
171      logError(error)
172      // Clean up temp file if it exists
173      try {
174        await unlink(tempPath)
175      } catch {
176        // Ignore cleanup errors
177      }
178    }
179  }
180  
181  /**
182   * Fetch install counts from GitHub stats repository
183   */
184  async function fetchInstallCountsFromGitHub(): Promise<
185    Array<{ plugin: string; unique_installs: number }>
186  > {
187    logForDebugging(`Fetching install counts from ${INSTALL_COUNTS_URL}`)
188  
189    const started = performance.now()
190    try {
191      const response = await axios.get<GitHubStatsResponse>(INSTALL_COUNTS_URL, {
192        timeout: 10000,
193      })
194  
195      if (!response.data?.plugins || !Array.isArray(response.data.plugins)) {
196        throw new Error('Invalid response format from install counts API')
197      }
198  
199      logPluginFetch(
200        'install_counts',
201        INSTALL_COUNTS_URL,
202        'success',
203        performance.now() - started,
204      )
205      return response.data.plugins
206    } catch (error) {
207      logPluginFetch(
208        'install_counts',
209        INSTALL_COUNTS_URL,
210        'failure',
211        performance.now() - started,
212        classifyFetchError(error),
213      )
214      throw error
215    }
216  }
217  
218  /**
219   * Get plugin install counts as a Map.
220   * Uses cached data if available and less than 24 hours old.
221   * Returns null on errors so UI can hide counts rather than show misleading zeros.
222   *
223   * @returns Map of plugin ID (name@marketplace) to install count, or null if unavailable
224   */
225  export async function getInstallCounts(): Promise<Map<string, number> | null> {
226    // Try to load from cache first
227    const cache = await loadInstallCountsCache()
228    if (cache) {
229      logForDebugging('Using cached install counts')
230      logPluginFetch('install_counts', INSTALL_COUNTS_URL, 'cache_hit', 0)
231      const map = new Map<string, number>()
232      for (const entry of cache.counts) {
233        map.set(entry.plugin, entry.unique_installs)
234      }
235      return map
236    }
237  
238    // Cache miss or stale - fetch from GitHub
239    try {
240      const counts = await fetchInstallCountsFromGitHub()
241  
242      // Save to cache
243      const newCache: InstallCountsCache = {
244        version: INSTALL_COUNTS_CACHE_VERSION,
245        fetchedAt: new Date().toISOString(),
246        counts,
247      }
248      await saveInstallCountsCache(newCache)
249  
250      // Convert to Map
251      const map = new Map<string, number>()
252      for (const entry of counts) {
253        map.set(entry.plugin, entry.unique_installs)
254      }
255      return map
256    } catch (error) {
257      // Log error and return null so UI can hide counts
258      logError(error)
259      logForDebugging(`Failed to fetch install counts: ${errorMessage(error)}`)
260      return null
261    }
262  }
263  
264  /**
265   * Format an install count for display.
266   *
267   * @param count - The raw install count
268   * @returns Formatted string:
269   *   - <1000: raw number (e.g., "42")
270   *   - >=1000: K suffix with 1 decimal (e.g., "1.2K", "36.2K")
271   *   - >=1000000: M suffix with 1 decimal (e.g., "1.2M")
272   */
273  export function formatInstallCount(count: number): string {
274    if (count < 1000) {
275      return String(count)
276    }
277  
278    if (count < 1000000) {
279      const k = count / 1000
280      // Use toFixed(1) but remove trailing .0
281      const formatted = k.toFixed(1)
282      return formatted.endsWith('.0')
283        ? `${formatted.slice(0, -2)}K`
284        : `${formatted}K`
285    }
286  
287    const m = count / 1000000
288    const formatted = m.toFixed(1)
289    return formatted.endsWith('.0')
290      ? `${formatted.slice(0, -2)}M`
291      : `${formatted}M`
292  }