import { readdirSync, readFileSync, statSync } from "node:fs"; import { basename, dirname, join } from "node:path"; import { DefaultPackageManager, getAgentDir, SettingsManager, type ResolvedResource } from "@mariozechner/pi-coding-agent"; import { getExtensionNameFromPath } from "./extension-enabled-toggle.js"; import type { ExtensionEntry, ExtensionsRegistry } from "./types.js"; interface PackageUpdate { source: string; displayName: string; type: "npm" | "git"; scope: "user" | "project"; } const UPDATE_CACHE_TTL_MS = 5 * 60 * 1000; const updateCache = new Map(); const updateRequests = new Map>(); const sizeCache = new Map(); const SIZE_IGNORED_DIRS = new Set(["node_modules", ".git"]); function compareExtensions(a: ExtensionEntry, b: ExtensionEntry): number { const scopeRank = (scope: ExtensionEntry["scope"]) => { switch (scope) { case "project": return 0; case "user": return 1; default: return 2; } }; const rankDiff = scopeRank(a.scope) - scopeRank(b.scope); if (rankDiff !== 0) return rankDiff; if (a.origin !== b.origin) return a.origin === "top-level" ? -1 : 1; return a.name.localeCompare(b.name); } interface PackageMetadata { name?: string; version?: string; description?: string; homepage?: string; repositoryUrl?: string; bugsUrl?: string; } function findPackageRoot(resource: ResolvedResource): string | undefined { let current = resource.metadata.baseDir ?? dirname(resource.path); for (let i = 0; i < 6; i += 1) { const packageJsonPath = join(current, "package.json"); try { statSync(packageJsonPath); return current; } catch { const parent = dirname(current); if (parent === current) break; current = parent; } } return undefined; } function normalizePackageUrl(url: string | undefined): string | undefined { if (!url) return undefined; const trimmed = url.trim(); if (!trimmed) return undefined; if (trimmed.startsWith("git+")) return trimmed.slice(4); return trimmed; } function readPackageMetadata(resource: ResolvedResource): PackageMetadata { const packageRoot = findPackageRoot(resource); if (!packageRoot) return {}; try { const raw = readFileSync(join(packageRoot, "package.json"), "utf8"); const parsed = JSON.parse(raw) as { name?: unknown; version?: unknown; description?: unknown; homepage?: unknown; repository?: unknown; bugs?: unknown; }; const repositoryUrl = typeof parsed.repository === "string" ? parsed.repository : parsed.repository && typeof parsed.repository === "object" && typeof (parsed.repository as { url?: unknown }).url === "string" ? (parsed.repository as { url: string }).url : undefined; const bugsUrl = parsed.bugs && typeof parsed.bugs === "object" && typeof (parsed.bugs as { url?: unknown }).url === "string" ? (parsed.bugs as { url: string }).url : undefined; return { name: typeof parsed.name === "string" && parsed.name.trim() ? parsed.name.trim() : undefined, version: typeof parsed.version === "string" && parsed.version.trim() ? parsed.version.trim() : undefined, description: typeof parsed.description === "string" && parsed.description.trim() ? parsed.description.trim() : undefined, homepage: typeof parsed.homepage === "string" ? normalizePackageUrl(parsed.homepage) : undefined, repositoryUrl: normalizePackageUrl(repositoryUrl), bugsUrl: normalizePackageUrl(bugsUrl), }; } catch { return {}; } } function getPathSizeBytes(path: string): number { const cached = sizeCache.get(path); if (cached !== undefined) return cached; let size = 0; try { const stat = statSync(path); if (stat.isDirectory()) { for (const entry of readdirSync(path, { withFileTypes: true })) { if (entry.isDirectory() && SIZE_IGNORED_DIRS.has(entry.name)) { continue; } size += getPathSizeBytes(join(path, entry.name)); } } else { size = stat.size; } } catch { size = 0; } sizeCache.set(path, size); return size; } function getExtensionSize(path: string): { sizeKb: number; sizeLabel: string } { const sizeBytes = getPathSizeBytes(path); const sizeKb = Math.max(1, Math.round(sizeBytes / 1024)); const sizeMb = sizeBytes / (1024 * 1024); return { sizeKb, sizeLabel: `${sizeMb.toFixed(2)} MB`, }; } function toDisplayName(source: string): string { const npmMatch = source.match(/^npm:([^@]+\/[^@]+|[^@]+)(?:@.+)?$/); if (npmMatch) return npmMatch[1] ?? source; const gitMatch = source.match(/([^/:]+\/[^/@]+)(?:@.+)?$/); if (gitMatch) return gitMatch[1] ?? source; return basename(source); } function getLocalRootName(resource: ResolvedResource): string | undefined { const packageRoot = findPackageRoot(resource); if (packageRoot) return basename(packageRoot); if (resource.metadata.origin === "package") return undefined; if (resource.metadata.baseDir) return basename(resource.metadata.baseDir); return basename(dirname(resource.path)); } function toExtensionEntry(resource: ResolvedResource, packageUpdates: Map): ExtensionEntry { const update = packageUpdates.get(resource.metadata.source); const packageMetadata = readPackageMetadata(resource); const name = getExtensionNameFromPath(resource.path); const packageName = packageMetadata.name ?? (resource.metadata.origin === "package" ? toDisplayName(resource.metadata.source) : undefined) ?? getLocalRootName(resource); const version = packageMetadata.version; const preferredName = packageName ?? name; const displayName = version ? `${preferredName}@${version}` : preferredName; const size = getExtensionSize(findPackageRoot(resource) ?? resource.metadata.baseDir ?? resource.path); return { name, version, displayName, description: packageMetadata.description, link: packageMetadata.homepage ?? packageMetadata.repositoryUrl ?? packageMetadata.bugsUrl, sizeKb: size.sizeKb, sizeLabel: size.sizeLabel, path: resource.path, relativePath: resource.metadata.baseDir ? resource.path.slice(resource.metadata.baseDir.length).replace(/^\//, "") : basename(resource.path), scope: resource.metadata.scope, origin: resource.metadata.origin, source: resource.metadata.source, baseDir: resource.metadata.baseDir, enabled: resource.enabled, packageDisplayName: packageName, hasUpdate: Boolean(update), updateTarget: update?.displayName, }; } function dedupeByPath(extensions: ExtensionEntry[]): ExtensionEntry[] { const seen = new Set(); const deduped: ExtensionEntry[] = []; for (const extension of extensions) { if (seen.has(extension.path)) continue; seen.add(extension.path); deduped.push(extension); } return deduped; } function getTemporaryExtensionSourcesFromArgv(argv: string[]): string[] { const sources: string[] = []; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; if (!arg) continue; if (arg === "-e" || arg === "--extension") { const value = argv[i + 1]; if (value) { sources.push(value); i += 1; } continue; } if (arg.startsWith("--extension=")) { const value = arg.slice("--extension=".length); if (value) sources.push(value); } } return sources; } function getCachedUpdates(cwd: string): PackageUpdate[] { const cached = updateCache.get(cwd); if (!cached) return []; if (cached.expiresAt < Date.now()) { updateCache.delete(cwd); return []; } return cached.updates; } export async function warmExtensionsUpdateCache(cwd: string): Promise { const cached = updateCache.get(cwd); if (cached && cached.expiresAt >= Date.now()) { return false; } const existingRequest = updateRequests.get(cwd); if (existingRequest) { return await existingRequest; } const request = (async () => { try { const settingsManager = SettingsManager.create(cwd, getAgentDir()); const packageManager = new DefaultPackageManager({ cwd, agentDir: getAgentDir(), settingsManager }); const updates = typeof (packageManager as DefaultPackageManager & { checkForAvailableUpdates?: () => Promise }).checkForAvailableUpdates === "function" ? await (packageManager as DefaultPackageManager & { checkForAvailableUpdates: () => Promise }).checkForAvailableUpdates() : []; const previous = updateCache.get(cwd)?.updates ?? []; updateCache.set(cwd, { expiresAt: Date.now() + UPDATE_CACHE_TTL_MS, updates }); return JSON.stringify(previous) !== JSON.stringify(updates); } catch { return false; } finally { updateRequests.delete(cwd); } })(); updateRequests.set(cwd, request); return await request; } export function clearExtensionsUpdateCache(cwd: string): void { updateCache.delete(cwd); } export async function loadExtensionsRegistry(cwd: string): Promise { const settingsManager = SettingsManager.create(cwd, getAgentDir()); const packageManager = new DefaultPackageManager({ cwd, agentDir: getAgentDir(), settingsManager }); const resolved = await packageManager.resolve(); const temporarySources = getTemporaryExtensionSourcesFromArgv(process.argv); const temporaryResolved = temporarySources.length > 0 ? await packageManager.resolveExtensionSources(temporarySources, { temporary: true }) : { extensions: [], skills: [], prompts: [], themes: [] }; const updateMap = new Map(getCachedUpdates(cwd).map((item) => [item.source, item])); const allExtensions = dedupeByPath([...resolved.extensions, ...temporaryResolved.extensions].map((entry) => toExtensionEntry(entry, updateMap))).sort(compareExtensions); const enabledExtensions = allExtensions.filter((entry) => entry.enabled); return { extensions: enabledExtensions, allExtensions, byName: new Map(enabledExtensions.map((entry) => [entry.name, entry])), }; }