Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -998,6 +998,12 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `false`
- **Requires restart:** Yes

- **`experimental.extensionRegistryURI`** (string):
- **Description:** The URI (web URL or local file path) of the extension
registry.
- **Default:** `"https://geminicli.com/extensions.json"`
- **Requires restart:** Yes

- **`experimental.extensionReloading`** (boolean):
- **Description:** Enables extension loading/unloading within the CLI session.
- **Default:** `false`
Expand Down
12 changes: 12 additions & 0 deletions packages/cli/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import yargs from 'yargs/yargs';
import { hideBin } from 'yargs/helpers';
import process from 'node:process';
import * as path from 'node:path';
import { mcpCommand } from '../commands/mcp.js';
import { extensionsCommand } from '../commands/extensions.js';
import { skillsCommand } from '../commands/skills.js';
Expand All @@ -33,6 +34,7 @@ import {
getAdminErrorMessage,
isHeadlessMode,
Config,
resolveToRealPath,
applyAdminAllowlist,
getAdminBlockedMcpServersMessage,
type HookDefinition,
Expand Down Expand Up @@ -488,6 +490,15 @@ export async function loadCliConfig(

const experimentalJitContext = settings.experimental?.jitContext ?? false;

let extensionRegistryURI: string | undefined = trustedFolder
? settings.experimental?.extensionRegistryURI
: undefined;
if (extensionRegistryURI && !extensionRegistryURI.startsWith('http')) {
extensionRegistryURI = resolveToRealPath(
path.resolve(cwd, resolvePath(extensionRegistryURI)),
);
}

let memoryContent: string | HierarchicalMemory = '';
let fileCount = 0;
let filePaths: string[] = [];
Expand Down Expand Up @@ -764,6 +775,7 @@ export async function loadCliConfig(
deleteSession: argv.deleteSession,
enabledExtensions: argv.extensions,
extensionLoader: extensionManager,
extensionRegistryURI,
enableExtensionReloading: settings.experimental?.extensionReloading,
enableAgents: settings.experimental?.enableAgents,
plan: settings.experimental?.plan,
Expand Down
44 changes: 41 additions & 3 deletions packages/cli/src/config/extensionRegistryClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,24 @@ import {
afterEach,
type Mock,
} from 'vitest';
import * as fs from 'node:fs/promises';
import {
ExtensionRegistryClient,
type RegistryExtension,
} from './extensionRegistryClient.js';
import { fetchWithTimeout } from '@google/gemini-cli-core';
import { fetchWithTimeout, resolveToRealPath } from '@google/gemini-cli-core';

vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
fetchWithTimeout: vi.fn(),
};
});

vi.mock('@google/gemini-cli-core', () => ({
fetchWithTimeout: vi.fn(),
vi.mock('node:fs/promises', () => ({
readFile: vi.fn(),
}));

const mockExtensions: RegistryExtension[] = [
Expand Down Expand Up @@ -279,4 +289,32 @@ describe('ExtensionRegistryClient', () => {
expect(ids).not.toContain('dataplex');
expect(ids).toContain('conductor');
});

it('should fetch extensions from a local file path', async () => {
const filePath = '/path/to/extensions.json';
const clientWithFile = new ExtensionRegistryClient(filePath);
const mockReadFile = vi.mocked(fs.readFile);
mockReadFile.mockResolvedValue(JSON.stringify(mockExtensions));

const result = await clientWithFile.getExtensions();
expect(result.extensions).toHaveLength(3);
expect(mockReadFile).toHaveBeenCalledWith(
resolveToRealPath(filePath),
'utf-8',
);
});

it('should fetch extensions from a file:// URL', async () => {
const fileUrl = 'file:///path/to/extensions.json';
const clientWithFileUrl = new ExtensionRegistryClient(fileUrl);
const mockReadFile = vi.mocked(fs.readFile);
mockReadFile.mockResolvedValue(JSON.stringify(mockExtensions));

const result = await clientWithFileUrl.getExtensions();
expect(result.extensions).toHaveLength(3);
expect(mockReadFile).toHaveBeenCalledWith(
resolveToRealPath(fileUrl),
'utf-8',
);
});
});
50 changes: 39 additions & 11 deletions packages/cli/src/config/extensionRegistryClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@
* SPDX-License-Identifier: Apache-2.0
*/

import { fetchWithTimeout } from '@google/gemini-cli-core';
import * as fs from 'node:fs/promises';
import {
fetchWithTimeout,
resolveToRealPath,
isPrivateIp,
} from '@google/gemini-cli-core';
import { AsyncFzf } from 'fzf';

export interface RegistryExtension {
Expand All @@ -29,12 +34,19 @@ export interface RegistryExtension {
}

export class ExtensionRegistryClient {
private static readonly REGISTRY_URL =
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;
Expand Down Expand Up @@ -97,18 +109,34 @@ export class ExtensionRegistryClient {
return ExtensionRegistryClient.fetchPromise;
}

const uri = this.registryURI;
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}`);
}
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[];
// 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;
Expand Down
10 changes: 10 additions & 0 deletions packages/cli/src/config/settingsSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1779,6 +1779,16 @@ const SETTINGS_SCHEMA = {
description: 'Enable extension registry explore UI.',
showInDialog: false,
},
extensionRegistryURI: {
type: 'string',
label: 'Extension Registry URI',
category: 'Experimental',
requiresRestart: true,
default: 'https://geminicli.com/extensions.json',
description:
'The URI (web URL or local file path) of the extension registry.',
showInDialog: false,
},
extensionReloading: {
type: 'boolean',
label: 'Extension Reloading',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,9 @@ describe('ExtensionRegistryView', () => {

vi.mocked(useConfig).mockReturnValue({
getEnableExtensionReloading: vi.fn().mockReturnValue(false),
getExtensionRegistryURI: vi
.fn()
.mockReturnValue('https://geminicli.com/extensions.json'),
} as unknown as ReturnType<typeof useConfig>);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,11 @@ export function ExtensionRegistryView({
onClose,
extensionManager,
}: ExtensionRegistryViewProps): React.JSX.Element {
const { extensions, loading, error, search } = useExtensionRegistry();
const config = useConfig();
const { extensions, loading, error, search } = useExtensionRegistry(
'',
config.getExtensionRegistryURI(),
);
const { terminalHeight, staticExtraHeight } = useUIState();

const { extensionsUpdateState } = useExtensionUpdates(
Expand Down
6 changes: 5 additions & 1 deletion packages/cli/src/ui/hooks/useExtensionRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,16 @@ export interface UseExtensionRegistryResult {

export function useExtensionRegistry(
initialQuery = '',
registryURI?: string,
): UseExtensionRegistryResult {
const [extensions, setExtensions] = useState<RegistryExtension[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);

const client = useMemo(() => new ExtensionRegistryClient(), []);
const client = useMemo(
() => new ExtensionRegistryClient(registryURI),
[registryURI],
);

// Ref to track the latest query to avoid race conditions
const latestQueryRef = useRef(initialQuery);
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,7 @@ export interface ConfigParameters {
skipNextSpeakerCheck?: boolean;
shellExecutionConfig?: ShellExecutionConfig;
extensionManagement?: boolean;
extensionRegistryURI?: string;
truncateToolOutputThreshold?: number;
eventEmitter?: EventEmitter;
useWriteTodos?: boolean;
Expand Down Expand Up @@ -733,6 +734,7 @@ export class Config implements McpContext, AgentLoopContext {
private readonly useAlternateBuffer: boolean;
private shellExecutionConfig: ShellExecutionConfig;
private readonly extensionManagement: boolean = true;
private readonly extensionRegistryURI: string | undefined;
private readonly truncateToolOutputThreshold: number;
private compressionTruncationCounter = 0;
private initialized = false;
Expand Down Expand Up @@ -962,6 +964,7 @@ export class Config implements McpContext, AgentLoopContext {
this.shellToolInactivityTimeout =
(params.shellToolInactivityTimeout ?? 300) * 1000; // 5 minutes
this.extensionManagement = params.extensionManagement ?? true;
this.extensionRegistryURI = params.extensionRegistryURI;
this.enableExtensionReloading = params.enableExtensionReloading ?? false;
this.storage = new Storage(this.targetDir, this._sessionId);
this.storage.setCustomPlansDir(params.planSettings?.directory);
Expand Down Expand Up @@ -1833,6 +1836,10 @@ export class Config implements McpContext, AgentLoopContext {
return this.extensionsEnabled;
}

getExtensionRegistryURI(): string | undefined {
return this.extensionRegistryURI;
}

getMcpClientManager(): McpClientManager | undefined {
return this.mcpClientManager;
}
Expand Down
7 changes: 7 additions & 0 deletions schemas/settings.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1682,6 +1682,13 @@
"default": false,
"type": "boolean"
},
"extensionRegistryURI": {
"title": "Extension Registry URI",
"description": "The URI (web URL or local file path) of the extension registry.",
"markdownDescription": "The URI (web URL or local file path) of the extension registry.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `https://geminicli.com/extensions.json`",
"default": "https://geminicli.com/extensions.json",
"type": "string"
},
"extensionReloading": {
"title": "Extension Reloading",
"description": "Enables extension loading/unloading within the CLI session.",
Expand Down
Loading