forked from ultraworkers/claw-code
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathinstallCounts.ts
More file actions
292 lines (262 loc) · 8.12 KB
/
Copy pathinstallCounts.ts
File metadata and controls
292 lines (262 loc) · 8.12 KB
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
/**
* 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`
}