From 5dafccc79392653b8f89536dd1979bfd2ae004fb Mon Sep 17 00:00:00 2001 From: Christine Betts Date: Thu, 5 Feb 2026 15:03:39 -0500 Subject: [PATCH 1/2] Add extension registry client --- .../config/extensionRegistryClient.test.ts | 205 ++++++++++++++++++ .../cli/src/config/extensionRegistryClient.ts | 90 ++++++++ 2 files changed, 295 insertions(+) create mode 100644 packages/cli/src/config/extensionRegistryClient.test.ts create mode 100644 packages/cli/src/config/extensionRegistryClient.ts diff --git a/packages/cli/src/config/extensionRegistryClient.test.ts b/packages/cli/src/config/extensionRegistryClient.test.ts new file mode 100644 index 00000000000..1e45912c6ba --- /dev/null +++ b/packages/cli/src/config/extensionRegistryClient.test.ts @@ -0,0 +1,205 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + type Mock, +} from 'vitest'; +import { + ExtensionRegistryClient, + type RegistryExtension, +} from './extensionRegistryClient.js'; + +const mockExtensions: RegistryExtension[] = [ + { + id: 'ext1', + rank: 1, + url: 'https://github.com/test/ext1', + fullName: 'test/ext1', + repoDescription: 'Test extension 1', + stars: 100, + lastUpdated: '2025-01-01T00:00:00Z', + extensionName: 'extension-one', + extensionVersion: '1.0.0', + extensionDescription: 'First test extension', + avatarUrl: 'https://example.com/avatar1.png', + hasMCP: true, + hasContext: false, + isGoogleOwned: false, + licenseKey: 'mit', + hasHooks: false, + hasCustomCommands: false, + hasSkills: false, + }, + { + id: 'ext2', + rank: 2, + url: 'https://github.com/test/ext2', + fullName: 'test/ext2', + repoDescription: 'Test extension 2', + stars: 50, + lastUpdated: '2025-01-02T00:00:00Z', + extensionName: 'extension-two', + extensionVersion: '0.5.0', + extensionDescription: 'Second test extension', + avatarUrl: 'https://example.com/avatar2.png', + hasMCP: false, + hasContext: true, + isGoogleOwned: true, + licenseKey: 'apache-2.0', + hasHooks: false, + hasCustomCommands: false, + hasSkills: false, + }, + { + id: 'ext3', + rank: 3, + url: 'https://github.com/test/ext3', + fullName: 'test/ext3', + repoDescription: 'Test extension 3', + stars: 10, + lastUpdated: '2025-01-03T00:00:00Z', + extensionName: 'extension-three', + extensionVersion: '0.1.0', + extensionDescription: 'Third test extension', + avatarUrl: 'https://example.com/avatar3.png', + hasMCP: true, + hasContext: true, + isGoogleOwned: false, + licenseKey: 'gpl-3.0', + hasHooks: false, + hasCustomCommands: false, + hasSkills: false, + }, +]; + +describe('ExtensionRegistryClient', () => { + let client: ExtensionRegistryClient; + let fetchMock: Mock; + + beforeEach(() => { + client = new ExtensionRegistryClient(); + fetchMock = vi.fn(); + global.fetch = fetchMock; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should fetch and return extensions with pagination (default ranking)', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => mockExtensions, + }); + + const result = await client.getExtensions(1, 2); + expect(result.extensions).toHaveLength(2); + expect(result.extensions[0].id).toBe('ext1'); // rank 1 + expect(result.extensions[1].id).toBe('ext2'); // rank 2 + expect(result.total).toBe(3); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + 'https://geminicli.com/extensions.json', + ); + }); + + it('should return extensions sorted alphabetically', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => mockExtensions, + }); + + const result = await client.getExtensions(1, 3, 'alphabetical'); + expect(result.extensions).toHaveLength(3); + expect(result.extensions[0].id).toBe('ext1'); + expect(result.extensions[1].id).toBe('ext3'); + expect(result.extensions[2].id).toBe('ext2'); + }); + + it('should return the second page of extensions', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => mockExtensions, + }); + + const result = await client.getExtensions(2, 2); + expect(result.extensions).toHaveLength(1); + expect(result.extensions[0].id).toBe('ext3'); + expect(result.total).toBe(3); + }); + + it('should search extensions by name', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => mockExtensions, + }); + + const results = await client.searchExtensions('one'); + expect(results).toHaveLength(1); + expect(results[0].id).toBe('ext1'); + }); + + it('should search extensions by description', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => mockExtensions, + }); + + const results = await client.searchExtensions('Second'); + expect(results).toHaveLength(1); + expect(results[0].id).toBe('ext2'); + }); + + it('should get an extension by ID', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => mockExtensions, + }); + + const result = await client.getExtension('ext2'); + expect(result).toBeDefined(); + expect(result?.id).toBe('ext2'); + }); + + it('should return undefined if extension not found', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => mockExtensions, + }); + + const result = await client.getExtension('non-existent'); + expect(result).toBeUndefined(); + }); + + it('should cache the fetch result', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => mockExtensions, + }); + + await client.getExtensions(); + await client.getExtensions(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('should throw an error if fetch fails', async () => { + fetchMock.mockResolvedValue({ + ok: false, + statusText: 'Not Found', + }); + + await expect(client.getExtensions()).rejects.toThrow( + 'Failed to fetch extensions: Not Found', + ); + }); +}); diff --git a/packages/cli/src/config/extensionRegistryClient.ts b/packages/cli/src/config/extensionRegistryClient.ts new file mode 100644 index 00000000000..b7b035a9520 --- /dev/null +++ b/packages/cli/src/config/extensionRegistryClient.ts @@ -0,0 +1,90 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +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 { + private static readonly REGISTRY_URL = + 'https://geminicli.com/extensions.json'; + private cache: RegistryExtension[] | null = 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: + break; + } + + 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(); + const lowerQuery = query.toLowerCase(); + return allExtensions.filter( + (ext) => + ext.extensionName.toLowerCase().includes(lowerQuery) || + ext.extensionDescription.toLowerCase().includes(lowerQuery) || + ext.fullName.toLowerCase().includes(lowerQuery), + ); + } + + async getExtension(id: string): Promise { + const allExtensions = await this.fetchAllExtensions(); + return allExtensions.find((ext) => ext.id === id); + } + + private async fetchAllExtensions(): Promise { + if (this.cache) { + return this.cache; + } + + const response = await fetch(ExtensionRegistryClient.REGISTRY_URL); + if (!response.ok) { + throw new Error(`Failed to fetch extensions: ${response.statusText}`); + } + + this.cache = (await response.json()) as RegistryExtension[]; + return this.cache; + } +} From 7a576678c48dbb3a0abff84c59a3ca131cb6a942 Mon Sep 17 00:00:00 2001 From: Christine Betts Date: Fri, 6 Feb 2026 11:30:14 -0500 Subject: [PATCH 2/2] address comments --- .../config/extensionRegistryClient.test.ts | 32 +++++++-- .../cli/src/config/extensionRegistryClient.ts | 66 +++++++++++++------ packages/core/src/index.ts | 1 + 3 files changed, 75 insertions(+), 24 deletions(-) diff --git a/packages/cli/src/config/extensionRegistryClient.test.ts b/packages/cli/src/config/extensionRegistryClient.test.ts index 1e45912c6ba..187390ceb05 100644 --- a/packages/cli/src/config/extensionRegistryClient.test.ts +++ b/packages/cli/src/config/extensionRegistryClient.test.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ @@ -17,6 +17,11 @@ import { ExtensionRegistryClient, type RegistryExtension, } from './extensionRegistryClient.js'; +import { fetchWithTimeout } from '@google/gemini-cli-core'; + +vi.mock('@google/gemini-cli-core', () => ({ + fetchWithTimeout: vi.fn(), +})); const mockExtensions: RegistryExtension[] = [ { @@ -86,9 +91,10 @@ describe('ExtensionRegistryClient', () => { let fetchMock: Mock; beforeEach(() => { + ExtensionRegistryClient.resetCache(); client = new ExtensionRegistryClient(); - fetchMock = vi.fn(); - global.fetch = fetchMock; + fetchMock = fetchWithTimeout as Mock; + fetchMock.mockReset(); }); afterEach(() => { @@ -109,6 +115,7 @@ describe('ExtensionRegistryClient', () => { expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledWith( 'https://geminicli.com/extensions.json', + 10000, ); }); @@ -144,7 +151,7 @@ describe('ExtensionRegistryClient', () => { }); const results = await client.searchExtensions('one'); - expect(results).toHaveLength(1); + expect(results.length).toBeGreaterThanOrEqual(1); expect(results[0].id).toBe('ext1'); }); @@ -155,7 +162,7 @@ describe('ExtensionRegistryClient', () => { }); const results = await client.searchExtensions('Second'); - expect(results).toHaveLength(1); + expect(results.length).toBeGreaterThanOrEqual(1); expect(results[0].id).toBe('ext2'); }); @@ -192,6 +199,21 @@ describe('ExtensionRegistryClient', () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); + it('should share the fetch result across instances', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => mockExtensions, + }); + + const client1 = new ExtensionRegistryClient(); + const client2 = new ExtensionRegistryClient(); + + await client1.getExtensions(); + await client2.getExtensions(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + it('should throw an error if fetch fails', async () => { fetchMock.mockResolvedValue({ ok: false, diff --git a/packages/cli/src/config/extensionRegistryClient.ts b/packages/cli/src/config/extensionRegistryClient.ts index b7b035a9520..8104b8aeac7 100644 --- a/packages/cli/src/config/extensionRegistryClient.ts +++ b/packages/cli/src/config/extensionRegistryClient.ts @@ -1,9 +1,12 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ +import { fetchWithTimeout } from '@google/gemini-cli-core'; +import { AsyncFzf } from 'fzf'; + export interface RegistryExtension { id: string; rank: number; @@ -28,7 +31,14 @@ export interface RegistryExtension { export class ExtensionRegistryClient { private static readonly REGISTRY_URL = 'https://geminicli.com/extensions.json'; - private cache: RegistryExtension[] | null = null; + private static readonly FETCH_TIMEOUT_MS = 10000; // 10 seconds + + private static fetchPromise: Promise | null = null; + + /** @internal */ + static resetCache() { + ExtensionRegistryClient.fetchPromise = null; + } async getExtensions( page: number = 1, @@ -46,8 +56,10 @@ export class ExtensionRegistryClient { a.extensionName.localeCompare(b.extensionName), ); break; - default: - break; + default: { + const _exhaustiveCheck: never = orderBy; + throw new Error(`Unhandled orderBy: ${_exhaustiveCheck}`); + } } const startIndex = (page - 1) * limit; @@ -60,13 +72,17 @@ export class ExtensionRegistryClient { async searchExtensions(query: string): Promise { const allExtensions = await this.fetchAllExtensions(); - const lowerQuery = query.toLowerCase(); - return allExtensions.filter( - (ext) => - ext.extensionName.toLowerCase().includes(lowerQuery) || - ext.extensionDescription.toLowerCase().includes(lowerQuery) || - ext.fullName.toLowerCase().includes(lowerQuery), - ); + if (!query.trim()) { + return allExtensions; + } + + const fzf = new AsyncFzf(allExtensions, { + selector: (ext: RegistryExtension) => + `${ext.extensionName} ${ext.extensionDescription} ${ext.fullName}`, + fuzzy: 'v2', + }); + const results = await fzf.find(query); + return results.map((r: { item: RegistryExtension }) => r.item); } async getExtension(id: string): Promise { @@ -75,16 +91,28 @@ export class ExtensionRegistryClient { } private async fetchAllExtensions(): Promise { - if (this.cache) { - return this.cache; + if (ExtensionRegistryClient.fetchPromise) { + return ExtensionRegistryClient.fetchPromise; } - const response = await fetch(ExtensionRegistryClient.REGISTRY_URL); - if (!response.ok) { - throw new Error(`Failed to fetch extensions: ${response.statusText}`); - } + ExtensionRegistryClient.fetchPromise = (async () => { + try { + const response = await fetchWithTimeout( + ExtensionRegistryClient.REGISTRY_URL, + ExtensionRegistryClient.FETCH_TIMEOUT_MS, + ); + if (!response.ok) { + throw new Error(`Failed to fetch extensions: ${response.statusText}`); + } + + return (await response.json()) as RegistryExtension[]; + } catch (error) { + // Clear the promise on failure so that subsequent calls can try again + ExtensionRegistryClient.fetchPromise = null; + throw error; + } + })(); - this.cache = (await response.json()) as RegistryExtension[]; - return this.cache; + return ExtensionRegistryClient.fetchPromise; } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 41c11961fd6..b06a416176f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -54,6 +54,7 @@ export * from './code_assist/admin/admin_controls.js'; export * from './core/apiKeyCredentialStorage.js'; // Export utilities +export * from './utils/fetch.js'; export { homedir, tmpdir } from './utils/paths.js'; export * from './utils/paths.js'; export * from './utils/checks.js';