Files
gemini-cli/packages/cli/src/config/extensionRegistryClient.ts

149 lines
4.3 KiB
TypeScript

/**
* @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<RegistryExtension[]> | 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<RegistryExtension[]> {
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<RegistryExtension | undefined> {
const allExtensions = await this.fetchAllExtensions();
return allExtensions.find((ext) => ext.id === id);
}
private async fetchAllExtensions(): Promise<RegistryExtension[]> {
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;
}
}