/** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import * as fs from 'node:fs/promises'; import { fetchWithTimeout, resolveToRealPath, isPrivateIp, } from '@google/gemini-cli-core'; import { AsyncFzf } from 'fzf'; export interface RegistryExtension { id: string; rank: number; url: string; fullName: string; repoDescription: string; stars: number; lastUpdated: string; extensionName: string; extensionVersion: string; extensionDescription: string; avatarUrl: string; hasMCP: boolean; hasContext: boolean; hasHooks: boolean; hasSkills: boolean; hasCustomCommands: boolean; isGoogleOwned: boolean; licenseKey: string; } export class ExtensionRegistryClient { static readonly DEFAULT_REGISTRY_URL = 'https://geminicli.com/extensions.json'; private static readonly FETCH_TIMEOUT_MS = 10000; // 10 seconds private static fetchPromise: Promise | null = null; private readonly registryURI: string; constructor(registryURI?: string) { this.registryURI = registryURI || ExtensionRegistryClient.DEFAULT_REGISTRY_URL; } /** @internal */ static resetCache() { ExtensionRegistryClient.fetchPromise = null; } async getExtensions( page: number = 1, limit: number = 10, orderBy: 'ranking' | 'alphabetical' = 'ranking', ): Promise<{ extensions: RegistryExtension[]; total: number }> { const allExtensions = [...(await this.fetchAllExtensions())]; switch (orderBy) { case 'ranking': allExtensions.sort((a, b) => a.rank - b.rank); break; case 'alphabetical': allExtensions.sort((a, b) => a.extensionName.localeCompare(b.extensionName), ); break; default: { const _exhaustiveCheck: never = orderBy; throw new Error(`Unhandled orderBy: ${_exhaustiveCheck}`); } } const startIndex = (page - 1) * limit; const endIndex = startIndex + limit; return { extensions: allExtensions.slice(startIndex, endIndex), total: allExtensions.length, }; } async searchExtensions(query: string): Promise { const allExtensions = await this.fetchAllExtensions(); if (!query.trim()) { return allExtensions; } const fzf = new AsyncFzf(allExtensions, { selector: (ext: RegistryExtension) => `${ext.extensionName} ${ext.extensionDescription} ${ext.fullName}`, fuzzy: true, }); // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const results = await fzf.find(query); // eslint-disable-next-line @typescript-eslint/no-unsafe-return return results.map((r: { item: RegistryExtension }) => r.item); } async getExtension(id: string): Promise { const allExtensions = await this.fetchAllExtensions(); return allExtensions.find((ext) => ext.id === id); } private async fetchAllExtensions(): Promise { if (ExtensionRegistryClient.fetchPromise) { return ExtensionRegistryClient.fetchPromise; } const uri = this.registryURI; ExtensionRegistryClient.fetchPromise = (async () => { try { if (uri.startsWith('http')) { if (isPrivateIp(uri)) { throw new Error( 'Private IP addresses are not allowed for the extension registry.', ); } const response = await fetchWithTimeout( uri, ExtensionRegistryClient.FETCH_TIMEOUT_MS, ); if (!response.ok) { throw new Error( `Failed to fetch extensions: ${response.statusText}`, ); } // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return (await response.json()) as RegistryExtension[]; } else { // Handle local file path const filePath = resolveToRealPath(uri); const content = await fs.readFile(filePath, 'utf-8'); // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return JSON.parse(content) as RegistryExtension[]; } } catch (error) { ExtensionRegistryClient.fetchPromise = null; throw error; } })(); return ExtensionRegistryClient.fetchPromise; } }