Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
40 changes: 38 additions & 2 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';

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

const mockExtensions: RegistryExtension[] = [
Expand Down Expand Up @@ -279,4 +289,30 @@ 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(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);
// On Unix, file:///path/to/extensions.json -> /path/to/extensions.json
expect(mockReadFile).toHaveBeenCalledWith(
'/path/to/extensions.json',
'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