File size: 8,315 Bytes
064bfd6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 | /**
* 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`
}
|