source dump of claude code
at main 292 lines 8.3 kB view raw
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 11import axios from 'axios' 12import { randomBytes } from 'crypto' 13import { readFile, rename, unlink, writeFile } from 'fs/promises' 14import { join } from 'path' 15import { logForDebugging } from '../debug.js' 16import { errorMessage, getErrnoCode } from '../errors.js' 17import { getFsImplementation } from '../fsOperations.js' 18import { logError } from '../log.js' 19import { jsonParse, jsonStringify } from '../slowOperations.js' 20import { classifyFetchError, logPluginFetch } from './fetchTelemetry.js' 21import { getPluginsDirectory } from './pluginDirectories.js' 22 23const INSTALL_COUNTS_CACHE_VERSION = 1 24const INSTALL_COUNTS_CACHE_FILENAME = 'install-counts-cache.json' 25const INSTALL_COUNTS_URL = 26 'https://raw.githubusercontent.com/anthropics/claude-plugins-official/refs/heads/stats/stats/plugin-installs.json' 27const CACHE_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours in milliseconds 28 29/** 30 * Structure of the install counts cache file 31 */ 32type 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 */ 44type 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 */ 54function 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 */ 62async 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 */ 149async 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 */ 184async 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 */ 225export 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 */ 273export 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}