diff --git a/docs/extension.md b/docs/extension.md index aa5d837a..f00c24a5 100644 --- a/docs/extension.md +++ b/docs/extension.md @@ -74,3 +74,16 @@ For example, if both a user and the `gcp` extension define a `deploy` command: - `/deploy` - Executes the user's deploy command - `/gcp.deploy` - Executes the extension's deploy command (marked with `[gcp]` tag) + +## Installing Extensions + +You can install extensions using the `install` command. This command allows you to install extensions from a Git repository or a local path. + +### Usage + +`gemini extensions install | [options]` + +### Options + +- `source positional argument`: The URL of a Git repository to install the extension from. The repository must contain a `gemini-extension.json` file in its root. +- `--path `: The path to a local directory to install as an extension. The directory must contain a `gemini-extension.json` file. diff --git a/package-lock.json b/package-lock.json index bb8cb5d4..83625451 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "dependencies": { "@lvce-editor/ripgrep": "^2.1.0", "node-fetch": "^3.3.2", + "simple-git": "^3.28.0", "strip-ansi": "^7.1.0" }, "bin": { diff --git a/package.json b/package.json index ede883c6..c5eb6cad 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,7 @@ "dependencies": { "@lvce-editor/ripgrep": "^2.1.0", "node-fetch": "^3.3.2", + "simple-git": "^3.28.0", "strip-ansi": "^7.1.0" }, "optionalDependencies": { diff --git a/packages/cli/src/commands/extensions.tsx b/packages/cli/src/commands/extensions.tsx new file mode 100644 index 00000000..5ce89d36 --- /dev/null +++ b/packages/cli/src/commands/extensions.tsx @@ -0,0 +1,22 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CommandModule } from 'yargs'; +import { installCommand } from './extensions/install.js'; + +export const extensionsCommand: CommandModule = { + command: 'extensions ', + describe: 'Manage Gemini CLI extensions.', + builder: (yargs) => + yargs + .command(installCommand) + .demandCommand(1, 'You need at least one command before continuing.') + .version(false), + handler: () => { + // This handler is not called when a subcommand is provided. + // Yargs will show the help menu. + }, +}; diff --git a/packages/cli/src/commands/extensions/install.test.ts b/packages/cli/src/commands/extensions/install.test.ts new file mode 100644 index 00000000..6bf5c860 --- /dev/null +++ b/packages/cli/src/commands/extensions/install.test.ts @@ -0,0 +1,25 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { installCommand } from './install.js'; +import yargs from 'yargs'; + +describe('extensions install command', () => { + it('should fail if no source is provided', () => { + const validationParser = yargs([]).command(installCommand).fail(false); + expect(() => validationParser.parse('install')).toThrow( + 'Either a git URL --source or a --path must be provided.', + ); + }); + + it('should fail if both git source and local path are provided', () => { + const validationParser = yargs([]).command(installCommand).fail(false); + expect(() => + validationParser.parse('install --source some-url --path /some/path'), + ).toThrow('Arguments source and path are mutually exclusive'); + }); +}); diff --git a/packages/cli/src/commands/extensions/install.ts b/packages/cli/src/commands/extensions/install.ts new file mode 100644 index 00000000..3ac8f0b9 --- /dev/null +++ b/packages/cli/src/commands/extensions/install.ts @@ -0,0 +1,62 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CommandModule } from 'yargs'; +import { + installExtension, + ExtensionInstallMetadata, +} from '../../config/extension.js'; + +interface InstallArgs { + source?: string; + path?: string; +} + +export async function handleInstall(args: InstallArgs) { + try { + const installMetadata: ExtensionInstallMetadata = { + source: (args.source || args.path) as string, + type: args.source ? 'git' : 'local', + }; + const extensionName = await installExtension(installMetadata); + console.log( + `Extension "${extensionName}" installed successfully and enabled.`, + ); + } catch (error) { + console.error((error as Error).message); + process.exit(1); + } +} + +export const installCommand: CommandModule = { + command: 'install [--source | --path ]', + describe: 'Installs an extension from a git repository or a local path.', + builder: (yargs) => + yargs + .option('source', { + describe: 'The git URL of the extension to install.', + type: 'string', + }) + .option('path', { + describe: 'Path to a local extension directory.', + type: 'string', + }) + .conflicts('source', 'path') + .check((argv) => { + if (!argv.source && !argv.path) { + throw new Error( + 'Either a git URL --source or a --path must be provided.', + ); + } + return true; + }), + handler: async (argv) => { + await handleInstall({ + source: argv['source'] as string | undefined, + path: argv['path'] as string | undefined, + }); + }, +}; diff --git a/packages/cli/src/config/config.integration.test.ts b/packages/cli/src/config/config.integration.test.ts index 6d77740d..0c0aef19 100644 --- a/packages/cli/src/config/config.integration.test.ts +++ b/packages/cli/src/config/config.integration.test.ts @@ -282,7 +282,7 @@ describe('Configuration Integration Tests', () => { 'test', ]; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); // Verify that the argument was parsed correctly expect(argv.approvalMode).toBe('auto_edit'); @@ -306,7 +306,7 @@ describe('Configuration Integration Tests', () => { 'test', ]; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); expect(argv.approvalMode).toBe('yolo'); expect(argv.prompt).toBe('test'); @@ -329,7 +329,7 @@ describe('Configuration Integration Tests', () => { 'test', ]; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); expect(argv.approvalMode).toBe('default'); expect(argv.prompt).toBe('test'); @@ -345,7 +345,7 @@ describe('Configuration Integration Tests', () => { try { process.argv = ['node', 'script.js', '--yolo', '-p', 'test']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); expect(argv.yolo).toBe(true); expect(argv.approvalMode).toBeUndefined(); // Should NOT be set when using --yolo @@ -362,7 +362,7 @@ describe('Configuration Integration Tests', () => { process.argv = ['node', 'script.js', '--approval-mode', 'invalid_mode']; // Should throw during argument parsing due to yargs validation - await expect(parseArguments()).rejects.toThrow(); + await expect(parseArguments({} as Settings)).rejects.toThrow(); } finally { process.argv = originalArgv; } @@ -381,7 +381,7 @@ describe('Configuration Integration Tests', () => { ]; // Should throw during argument parsing due to conflict validation - await expect(parseArguments()).rejects.toThrow(); + await expect(parseArguments({} as Settings)).rejects.toThrow(); } finally { process.argv = originalArgv; } @@ -394,7 +394,7 @@ describe('Configuration Integration Tests', () => { // Test that no approval mode arguments defaults to no flags set process.argv = ['node', 'script.js', '-p', 'test']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); expect(argv.approvalMode).toBeUndefined(); expect(argv.yolo).toBe(false); diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index ac10a1fb..b3941b9c 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -125,7 +125,9 @@ describe('parseArguments', () => { .spyOn(console, 'error') .mockImplementation(() => {}); - await expect(parseArguments()).rejects.toThrow('process.exit called'); + await expect(parseArguments({} as Settings)).rejects.toThrow( + 'process.exit called', + ); expect(mockConsoleError).toHaveBeenCalledWith( expect.stringContaining( @@ -155,7 +157,9 @@ describe('parseArguments', () => { .spyOn(console, 'error') .mockImplementation(() => {}); - await expect(parseArguments()).rejects.toThrow('process.exit called'); + await expect(parseArguments({} as Settings)).rejects.toThrow( + 'process.exit called', + ); expect(mockConsoleError).toHaveBeenCalledWith( expect.stringContaining( @@ -169,7 +173,7 @@ describe('parseArguments', () => { it('should allow --prompt without --prompt-interactive', async () => { process.argv = ['node', 'script.js', '--prompt', 'test prompt']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); expect(argv.prompt).toBe('test prompt'); expect(argv.promptInteractive).toBeUndefined(); }); @@ -181,14 +185,14 @@ describe('parseArguments', () => { '--prompt-interactive', 'interactive prompt', ]; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); expect(argv.promptInteractive).toBe('interactive prompt'); expect(argv.prompt).toBeUndefined(); }); it('should allow -i flag as alias for --prompt-interactive', async () => { process.argv = ['node', 'script.js', '-i', 'interactive prompt']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); expect(argv.promptInteractive).toBe('interactive prompt'); expect(argv.prompt).toBeUndefined(); }); @@ -210,7 +214,9 @@ describe('parseArguments', () => { .spyOn(console, 'error') .mockImplementation(() => {}); - await expect(parseArguments()).rejects.toThrow('process.exit called'); + await expect(parseArguments({} as Settings)).rejects.toThrow( + 'process.exit called', + ); expect(mockConsoleError).toHaveBeenCalledWith( expect.stringContaining( @@ -233,7 +239,9 @@ describe('parseArguments', () => { .spyOn(console, 'error') .mockImplementation(() => {}); - await expect(parseArguments()).rejects.toThrow('process.exit called'); + await expect(parseArguments({} as Settings)).rejects.toThrow( + 'process.exit called', + ); expect(mockConsoleError).toHaveBeenCalledWith( expect.stringContaining( @@ -247,14 +255,14 @@ describe('parseArguments', () => { it('should allow --approval-mode without --yolo', async () => { process.argv = ['node', 'script.js', '--approval-mode', 'auto_edit']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); expect(argv.approvalMode).toBe('auto_edit'); expect(argv.yolo).toBe(false); }); it('should allow --yolo without --approval-mode', async () => { process.argv = ['node', 'script.js', '--yolo']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); expect(argv.yolo).toBe(true); expect(argv.approvalMode).toBeUndefined(); }); @@ -270,7 +278,9 @@ describe('parseArguments', () => { .spyOn(console, 'error') .mockImplementation(() => {}); - await expect(parseArguments()).rejects.toThrow('process.exit called'); + await expect(parseArguments({} as Settings)).rejects.toThrow( + 'process.exit called', + ); expect(mockConsoleError).toHaveBeenCalledWith( expect.stringContaining('Invalid values:'), @@ -298,7 +308,7 @@ describe('loadCliConfig', () => { it('should set showMemoryUsage to true when --show-memory-usage flag is present', async () => { process.argv = ['node', 'script.js', '--show-memory-usage']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const settings: Settings = {}; const config = await loadCliConfig(settings, [], 'test-session', argv); expect(config.getShowMemoryUsage()).toBe(true); @@ -306,7 +316,7 @@ describe('loadCliConfig', () => { it('should set showMemoryUsage to false when --memory flag is not present', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const settings: Settings = {}; const config = await loadCliConfig(settings, [], 'test-session', argv); expect(config.getShowMemoryUsage()).toBe(false); @@ -314,7 +324,7 @@ describe('loadCliConfig', () => { it('should set showMemoryUsage to false by default from settings if CLI flag is not present', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const settings: Settings = { showMemoryUsage: false }; const config = await loadCliConfig(settings, [], 'test-session', argv); expect(config.getShowMemoryUsage()).toBe(false); @@ -322,7 +332,7 @@ describe('loadCliConfig', () => { it('should prioritize CLI flag over settings for showMemoryUsage (CLI true, settings false)', async () => { process.argv = ['node', 'script.js', '--show-memory-usage']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const settings: Settings = { showMemoryUsage: false }; const config = await loadCliConfig(settings, [], 'test-session', argv); expect(config.getShowMemoryUsage()).toBe(true); @@ -356,7 +366,7 @@ describe('loadCliConfig', () => { it(`should leave proxy to empty by default`, async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const settings: Settings = {}; const config = await loadCliConfig(settings, [], 'test-session', argv); expect(config.getProxy()).toBeFalsy(); @@ -397,7 +407,7 @@ describe('loadCliConfig', () => { it(`should set proxy to ${expected} according to environment variable [${input.env_name}]`, async () => { vi.stubEnv(input.env_name, input.proxy_url); process.argv = ['node', 'script.js']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const settings: Settings = {}; const config = await loadCliConfig(settings, [], 'test-session', argv); expect(config.getProxy()).toBe(expected); @@ -406,7 +416,7 @@ describe('loadCliConfig', () => { it('should set proxy when --proxy flag is present', async () => { process.argv = ['node', 'script.js', '--proxy', 'http://localhost:7890']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const settings: Settings = {}; const config = await loadCliConfig(settings, [], 'test-session', argv); expect(config.getProxy()).toBe('http://localhost:7890'); @@ -415,7 +425,7 @@ describe('loadCliConfig', () => { it('should prioritize CLI flag over environment variable for proxy (CLI http://localhost:7890, environment variable http://localhost:7891)', async () => { vi.stubEnv('http_proxy', 'http://localhost:7891'); process.argv = ['node', 'script.js', '--proxy', 'http://localhost:7890']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const settings: Settings = {}; const config = await loadCliConfig(settings, [], 'test-session', argv); expect(config.getProxy()).toBe('http://localhost:7890'); @@ -440,7 +450,7 @@ describe('loadCliConfig telemetry', () => { it('should set telemetry to false by default when no flag or setting is present', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const settings: Settings = {}; const config = await loadCliConfig(settings, [], 'test-session', argv); expect(config.getTelemetryEnabled()).toBe(false); @@ -448,7 +458,7 @@ describe('loadCliConfig telemetry', () => { it('should set telemetry to true when --telemetry flag is present', async () => { process.argv = ['node', 'script.js', '--telemetry']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const settings: Settings = {}; const config = await loadCliConfig(settings, [], 'test-session', argv); expect(config.getTelemetryEnabled()).toBe(true); @@ -456,7 +466,7 @@ describe('loadCliConfig telemetry', () => { it('should set telemetry to false when --no-telemetry flag is present', async () => { process.argv = ['node', 'script.js', '--no-telemetry']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const settings: Settings = {}; const config = await loadCliConfig(settings, [], 'test-session', argv); expect(config.getTelemetryEnabled()).toBe(false); @@ -464,7 +474,7 @@ describe('loadCliConfig telemetry', () => { it('should use telemetry value from settings if CLI flag is not present (settings true)', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const settings: Settings = { telemetry: { enabled: true } }; const config = await loadCliConfig(settings, [], 'test-session', argv); expect(config.getTelemetryEnabled()).toBe(true); @@ -472,7 +482,7 @@ describe('loadCliConfig telemetry', () => { it('should use telemetry value from settings if CLI flag is not present (settings false)', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const settings: Settings = { telemetry: { enabled: false } }; const config = await loadCliConfig(settings, [], 'test-session', argv); expect(config.getTelemetryEnabled()).toBe(false); @@ -480,7 +490,7 @@ describe('loadCliConfig telemetry', () => { it('should prioritize --telemetry CLI flag (true) over settings (false)', async () => { process.argv = ['node', 'script.js', '--telemetry']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const settings: Settings = { telemetry: { enabled: false } }; const config = await loadCliConfig(settings, [], 'test-session', argv); expect(config.getTelemetryEnabled()).toBe(true); @@ -488,7 +498,7 @@ describe('loadCliConfig telemetry', () => { it('should prioritize --no-telemetry CLI flag (false) over settings (true)', async () => { process.argv = ['node', 'script.js', '--no-telemetry']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const settings: Settings = { telemetry: { enabled: true } }; const config = await loadCliConfig(settings, [], 'test-session', argv); expect(config.getTelemetryEnabled()).toBe(false); @@ -496,7 +506,7 @@ describe('loadCliConfig telemetry', () => { it('should use telemetry OTLP endpoint from settings if CLI flag is not present', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const settings: Settings = { telemetry: { otlpEndpoint: 'http://settings.example.com' }, }; @@ -513,7 +523,7 @@ describe('loadCliConfig telemetry', () => { '--telemetry-otlp-endpoint', 'http://cli.example.com', ]; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const settings: Settings = { telemetry: { otlpEndpoint: 'http://settings.example.com' }, }; @@ -523,7 +533,7 @@ describe('loadCliConfig telemetry', () => { it('should use default endpoint if no OTLP endpoint is provided via CLI or settings', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const settings: Settings = { telemetry: { enabled: true } }; const config = await loadCliConfig(settings, [], 'test-session', argv); expect(config.getTelemetryOtlpEndpoint()).toBe('http://localhost:4317'); @@ -531,7 +541,7 @@ describe('loadCliConfig telemetry', () => { it('should use telemetry target from settings if CLI flag is not present', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const settings: Settings = { telemetry: { target: ServerConfig.DEFAULT_TELEMETRY_TARGET }, }; @@ -543,7 +553,7 @@ describe('loadCliConfig telemetry', () => { it('should prioritize --telemetry-target CLI flag over settings', async () => { process.argv = ['node', 'script.js', '--telemetry-target', 'gcp']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const settings: Settings = { telemetry: { target: ServerConfig.DEFAULT_TELEMETRY_TARGET }, }; @@ -553,7 +563,7 @@ describe('loadCliConfig telemetry', () => { it('should use default target if no target is provided via CLI or settings', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const settings: Settings = { telemetry: { enabled: true } }; const config = await loadCliConfig(settings, [], 'test-session', argv); expect(config.getTelemetryTarget()).toBe( @@ -563,7 +573,7 @@ describe('loadCliConfig telemetry', () => { it('should use telemetry log prompts from settings if CLI flag is not present', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const settings: Settings = { telemetry: { logPrompts: false } }; const config = await loadCliConfig(settings, [], 'test-session', argv); expect(config.getTelemetryLogPromptsEnabled()).toBe(false); @@ -571,7 +581,7 @@ describe('loadCliConfig telemetry', () => { it('should prioritize --telemetry-log-prompts CLI flag (true) over settings (false)', async () => { process.argv = ['node', 'script.js', '--telemetry-log-prompts']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const settings: Settings = { telemetry: { logPrompts: false } }; const config = await loadCliConfig(settings, [], 'test-session', argv); expect(config.getTelemetryLogPromptsEnabled()).toBe(true); @@ -579,7 +589,7 @@ describe('loadCliConfig telemetry', () => { it('should prioritize --no-telemetry-log-prompts CLI flag (false) over settings (true)', async () => { process.argv = ['node', 'script.js', '--no-telemetry-log-prompts']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const settings: Settings = { telemetry: { logPrompts: true } }; const config = await loadCliConfig(settings, [], 'test-session', argv); expect(config.getTelemetryLogPromptsEnabled()).toBe(false); @@ -587,7 +597,7 @@ describe('loadCliConfig telemetry', () => { it('should use default log prompts (true) if no value is provided via CLI or settings', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const settings: Settings = { telemetry: { enabled: true } }; const config = await loadCliConfig(settings, [], 'test-session', argv); expect(config.getTelemetryLogPromptsEnabled()).toBe(true); @@ -595,7 +605,7 @@ describe('loadCliConfig telemetry', () => { it('should use telemetry OTLP protocol from settings if CLI flag is not present', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const settings: Settings = { telemetry: { otlpProtocol: 'http' }, }; @@ -605,7 +615,7 @@ describe('loadCliConfig telemetry', () => { it('should prioritize --telemetry-otlp-protocol CLI flag over settings', async () => { process.argv = ['node', 'script.js', '--telemetry-otlp-protocol', 'http']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const settings: Settings = { telemetry: { otlpProtocol: 'grpc' }, }; @@ -615,7 +625,7 @@ describe('loadCliConfig telemetry', () => { it('should use default protocol if no OTLP protocol is provided via CLI or settings', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const settings: Settings = { telemetry: { enabled: true } }; const config = await loadCliConfig(settings, [], 'test-session', argv); expect(config.getTelemetryOtlpProtocol()).toBe('grpc'); @@ -637,7 +647,9 @@ describe('loadCliConfig telemetry', () => { .spyOn(console, 'error') .mockImplementation(() => {}); - await expect(parseArguments()).rejects.toThrow('process.exit called'); + await expect(parseArguments({} as Settings)).rejects.toThrow( + 'process.exit called', + ); expect(mockConsoleError).toHaveBeenCalledWith( expect.stringContaining('Invalid values:'), @@ -691,7 +703,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => { ], }, ]; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); await loadCliConfig(settings, extensions, 'session-id', argv); expect(ServerConfig.loadServerHierarchicalMemory).toHaveBeenCalledWith( expect.any(String), @@ -765,7 +777,7 @@ describe('mergeMcpServers', () => { ]; const originalSettings = JSON.parse(JSON.stringify(settings)); process.argv = ['node', 'script.js']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); await loadCliConfig(settings, extensions, 'test-session', argv); expect(settings).toEqual(originalSettings); }); @@ -806,7 +818,7 @@ describe('mergeExcludeTools', () => { }, ]; process.argv = ['node', 'script.js']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const config = await loadCliConfig( settings, extensions, @@ -833,7 +845,7 @@ describe('mergeExcludeTools', () => { }, ]; process.argv = ['node', 'script.js']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const config = await loadCliConfig( settings, extensions, @@ -869,7 +881,7 @@ describe('mergeExcludeTools', () => { }, ]; process.argv = ['node', 'script.js']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const config = await loadCliConfig( settings, extensions, @@ -887,7 +899,7 @@ describe('mergeExcludeTools', () => { const settings: Settings = {}; const extensions: Extension[] = []; process.argv = ['node', 'script.js']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const config = await loadCliConfig( settings, extensions, @@ -902,7 +914,7 @@ describe('mergeExcludeTools', () => { const settings: Settings = {}; const extensions: Extension[] = []; process.argv = ['node', 'script.js', '-p', 'test']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const config = await loadCliConfig( settings, extensions, @@ -914,7 +926,7 @@ describe('mergeExcludeTools', () => { it('should handle settings with excludeTools but no extensions', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const settings: Settings = { excludeTools: ['tool1', 'tool2'] }; const extensions: Extension[] = []; const config = await loadCliConfig( @@ -943,7 +955,7 @@ describe('mergeExcludeTools', () => { }, ]; process.argv = ['node', 'script.js']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const config = await loadCliConfig( settings, extensions, @@ -971,7 +983,7 @@ describe('mergeExcludeTools', () => { ]; const originalSettings = JSON.parse(JSON.stringify(settings)); process.argv = ['node', 'script.js']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); await loadCliConfig(settings, extensions, 'test-session', argv); expect(settings).toEqual(originalSettings); }); @@ -990,7 +1002,7 @@ describe('Approval mode tool exclusion logic', () => { it('should exclude all interactive tools in non-interactive mode with default approval mode', async () => { process.argv = ['node', 'script.js', '-p', 'test']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const settings: Settings = {}; const extensions: Extension[] = []; @@ -1016,7 +1028,7 @@ describe('Approval mode tool exclusion logic', () => { '-p', 'test', ]; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const settings: Settings = {}; const extensions: Extension[] = []; @@ -1042,7 +1054,7 @@ describe('Approval mode tool exclusion logic', () => { '-p', 'test', ]; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const settings: Settings = {}; const extensions: Extension[] = []; @@ -1068,7 +1080,7 @@ describe('Approval mode tool exclusion logic', () => { '-p', 'test', ]; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const settings: Settings = {}; const extensions: Extension[] = []; @@ -1087,7 +1099,7 @@ describe('Approval mode tool exclusion logic', () => { it('should exclude no interactive tools in non-interactive mode with legacy yolo flag', async () => { process.argv = ['node', 'script.js', '--yolo', '-p', 'test']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const settings: Settings = {}; const extensions: Extension[] = []; @@ -1117,7 +1129,7 @@ describe('Approval mode tool exclusion logic', () => { for (const testCase of testCases) { process.argv = testCase.args; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const settings: Settings = {}; const extensions: Extension[] = []; @@ -1144,7 +1156,7 @@ describe('Approval mode tool exclusion logic', () => { '-p', 'test', ]; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const settings: Settings = { excludeTools: ['custom_tool'] }; const extensions: Extension[] = []; @@ -1212,7 +1224,7 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { it('should allow all MCP servers if the flag is not provided', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const config = await loadCliConfig(baseSettings, [], 'test-session', argv); expect(config.getMcpServers()).toEqual(baseSettings.mcpServers); }); @@ -1224,7 +1236,7 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { '--allowed-mcp-server-names', 'server1', ]; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const config = await loadCliConfig(baseSettings, [], 'test-session', argv); expect(config.getMcpServers()).toEqual({ server1: { url: 'http://localhost:8080' }, @@ -1240,7 +1252,7 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { '--allowed-mcp-server-names', 'server3', ]; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const config = await loadCliConfig(baseSettings, [], 'test-session', argv); expect(config.getMcpServers()).toEqual({ server1: { url: 'http://localhost:8080' }, @@ -1257,7 +1269,7 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { '--allowed-mcp-server-names', 'server4', ]; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const config = await loadCliConfig(baseSettings, [], 'test-session', argv); expect(config.getMcpServers()).toEqual({ server1: { url: 'http://localhost:8080' }, @@ -1266,14 +1278,14 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { it('should allow no MCP servers if the flag is provided but empty', async () => { process.argv = ['node', 'script.js', '--allowed-mcp-server-names', '']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const config = await loadCliConfig(baseSettings, [], 'test-session', argv); expect(config.getMcpServers()).toEqual({}); }); it('should read allowMCPServers from settings', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const settings: Settings = { ...baseSettings, allowMCPServers: ['server1', 'server2'], @@ -1287,7 +1299,7 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { it('should read excludeMCPServers from settings', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const settings: Settings = { ...baseSettings, excludeMCPServers: ['server1', 'server2'], @@ -1300,7 +1312,7 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { it('should override allowMCPServers with excludeMCPServers if overlapping', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const settings: Settings = { ...baseSettings, excludeMCPServers: ['server1'], @@ -1319,7 +1331,7 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { '--allowed-mcp-server-names', 'server1', ]; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const settings: Settings = { ...baseSettings, excludeMCPServers: ['server1'], @@ -1348,7 +1360,7 @@ describe('loadCliConfig extensions', () => { it('should not filter extensions if --extensions flag is not used', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const settings: Settings = {}; const config = await loadCliConfig( settings, @@ -1364,7 +1376,7 @@ describe('loadCliConfig extensions', () => { it('should filter extensions if --extensions flag is used', async () => { process.argv = ['node', 'script.js', '--extensions', 'ext1']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const settings: Settings = {}; const config = await loadCliConfig( settings, @@ -1379,7 +1391,7 @@ describe('loadCliConfig extensions', () => { describe('loadCliConfig model selection', () => { it('selects a model from settings.json if provided', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const config = await loadCliConfig( { model: 'gemini-9001-ultra', @@ -1394,7 +1406,7 @@ describe('loadCliConfig model selection', () => { it('uses the default gemini model if nothing is set', async () => { process.argv = ['node', 'script.js']; // No model set. - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const config = await loadCliConfig( { // No model set. @@ -1409,7 +1421,7 @@ describe('loadCliConfig model selection', () => { it('always prefers model from argvs', async () => { process.argv = ['node', 'script.js', '--model', 'gemini-8675309-ultra']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const config = await loadCliConfig( { model: 'gemini-9001-ultra', @@ -1424,7 +1436,7 @@ describe('loadCliConfig model selection', () => { it('selects the model from argvs if provided', async () => { process.argv = ['node', 'script.js', '--model', 'gemini-8675309-ultra']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const config = await loadCliConfig( { // No model provided via settings. @@ -1456,14 +1468,14 @@ describe('loadCliConfig folderTrustFeature', () => { it('should be false by default', async () => { process.argv = ['node', 'script.js']; const settings: Settings = {}; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const config = await loadCliConfig(settings, [], 'test-session', argv); expect(config.getFolderTrustFeature()).toBe(false); }); it('should be true when settings.folderTrustFeature is true', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const settings: Settings = { folderTrustFeature: true }; const config = await loadCliConfig(settings, [], 'test-session', argv); expect(config.getFolderTrustFeature()).toBe(true); @@ -1491,14 +1503,14 @@ describe('loadCliConfig folderTrust', () => { folderTrustFeature: false, folderTrust: false, }; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const config = await loadCliConfig(settings, [], 'test-session', argv); expect(config.getFolderTrust()).toBe(false); }); it('should be false if folderTrustFeature is true and folderTrust is false', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const settings: Settings = { folderTrustFeature: true, folderTrust: false }; const config = await loadCliConfig(settings, [], 'test-session', argv); expect(config.getFolderTrust()).toBe(false); @@ -1506,7 +1518,7 @@ describe('loadCliConfig folderTrust', () => { it('should be false if folderTrustFeature is false and folderTrust is true', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const settings: Settings = { folderTrustFeature: false, folderTrust: true }; const config = await loadCliConfig(settings, [], 'test-session', argv); expect(config.getFolderTrust()).toBe(false); @@ -1514,7 +1526,7 @@ describe('loadCliConfig folderTrust', () => { it('should be true when folderTrustFeature is true and folderTrust is true', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const settings: Settings = { folderTrustFeature: true, folderTrust: true }; const config = await loadCliConfig(settings, [], 'test-session', argv); expect(config.getFolderTrust()).toBe(true); @@ -1547,7 +1559,7 @@ describe('loadCliConfig with includeDirectories', () => { '--include-directories', `${path.resolve(path.sep, 'cli', 'path1')},${path.join(mockCwd, 'cli', 'path2')}`, ]; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const settings: Settings = { includeDirectories: [ path.resolve(path.sep, 'settings', 'path1'), @@ -1590,7 +1602,7 @@ describe('loadCliConfig chatCompression', () => { it('should pass chatCompression settings to the core config', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const settings: Settings = { chatCompression: { contextPercentageThreshold: 0.5, @@ -1604,7 +1616,7 @@ describe('loadCliConfig chatCompression', () => { it('should have undefined chatCompression if not in settings', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const settings: Settings = {}; const config = await loadCliConfig(settings, [], 'test-session', argv); expect(config.getChatCompression()).toBeUndefined(); @@ -1628,7 +1640,7 @@ describe('loadCliConfig useRipgrep', () => { it('should be false by default when useRipgrep is not set in settings', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const settings: Settings = {}; const config = await loadCliConfig(settings, [], 'test-session', argv); expect(config.getUseRipgrep()).toBe(false); @@ -1636,7 +1648,7 @@ describe('loadCliConfig useRipgrep', () => { it('should be true when useRipgrep is set to true in settings', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const settings: Settings = { useRipgrep: true }; const config = await loadCliConfig(settings, [], 'test-session', argv); expect(config.getUseRipgrep()).toBe(true); @@ -1644,7 +1656,7 @@ describe('loadCliConfig useRipgrep', () => { it('should be false when useRipgrep is explicitly set to false in settings', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const settings: Settings = { useRipgrep: false }; const config = await loadCliConfig(settings, [], 'test-session', argv); expect(config.getUseRipgrep()).toBe(false); @@ -1672,7 +1684,7 @@ describe('loadCliConfig tool exclusions', () => { it('should not exclude interactive tools in interactive mode without YOLO', async () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const config = await loadCliConfig({}, [], 'test-session', argv); expect(config.getExcludeTools()).not.toContain('run_shell_command'); expect(config.getExcludeTools()).not.toContain('replace'); @@ -1682,7 +1694,7 @@ describe('loadCliConfig tool exclusions', () => { it('should not exclude interactive tools in interactive mode with YOLO', async () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js', '--yolo']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const config = await loadCliConfig({}, [], 'test-session', argv); expect(config.getExcludeTools()).not.toContain('run_shell_command'); expect(config.getExcludeTools()).not.toContain('replace'); @@ -1692,7 +1704,7 @@ describe('loadCliConfig tool exclusions', () => { it('should exclude interactive tools in non-interactive mode without YOLO', async () => { process.stdin.isTTY = false; process.argv = ['node', 'script.js', '-p', 'test']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const config = await loadCliConfig({}, [], 'test-session', argv); expect(config.getExcludeTools()).toContain('run_shell_command'); expect(config.getExcludeTools()).toContain('replace'); @@ -1702,7 +1714,7 @@ describe('loadCliConfig tool exclusions', () => { it('should not exclude interactive tools in non-interactive mode with YOLO', async () => { process.stdin.isTTY = false; process.argv = ['node', 'script.js', '-p', 'test', '--yolo']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const config = await loadCliConfig({}, [], 'test-session', argv); expect(config.getExcludeTools()).not.toContain('run_shell_command'); expect(config.getExcludeTools()).not.toContain('replace'); @@ -1731,7 +1743,7 @@ describe('loadCliConfig interactive', () => { it('should be interactive if isTTY and no prompt', async () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const config = await loadCliConfig({}, [], 'test-session', argv); expect(config.isInteractive()).toBe(true); }); @@ -1739,7 +1751,7 @@ describe('loadCliConfig interactive', () => { it('should be interactive if prompt-interactive is set', async () => { process.stdin.isTTY = false; process.argv = ['node', 'script.js', '--prompt-interactive', 'test']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const config = await loadCliConfig({}, [], 'test-session', argv); expect(config.isInteractive()).toBe(true); }); @@ -1747,7 +1759,7 @@ describe('loadCliConfig interactive', () => { it('should not be interactive if not isTTY and no prompt', async () => { process.stdin.isTTY = false; process.argv = ['node', 'script.js']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const config = await loadCliConfig({}, [], 'test-session', argv); expect(config.isInteractive()).toBe(false); }); @@ -1755,7 +1767,7 @@ describe('loadCliConfig interactive', () => { it('should not be interactive if prompt is set', async () => { process.stdin.isTTY = true; process.argv = ['node', 'script.js', '--prompt', 'test']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const config = await loadCliConfig({}, [], 'test-session', argv); expect(config.isInteractive()).toBe(false); }); @@ -1779,42 +1791,42 @@ describe('loadCliConfig approval mode', () => { it('should default to DEFAULT approval mode when no flags are set', async () => { process.argv = ['node', 'script.js']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const config = await loadCliConfig({}, [], 'test-session', argv); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); }); it('should set YOLO approval mode when --yolo flag is used', async () => { process.argv = ['node', 'script.js', '--yolo']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const config = await loadCliConfig({}, [], 'test-session', argv); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO); }); it('should set YOLO approval mode when -y flag is used', async () => { process.argv = ['node', 'script.js', '-y']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const config = await loadCliConfig({}, [], 'test-session', argv); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO); }); it('should set DEFAULT approval mode when --approval-mode=default', async () => { process.argv = ['node', 'script.js', '--approval-mode', 'default']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const config = await loadCliConfig({}, [], 'test-session', argv); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); }); it('should set AUTO_EDIT approval mode when --approval-mode=auto_edit', async () => { process.argv = ['node', 'script.js', '--approval-mode', 'auto_edit']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const config = await loadCliConfig({}, [], 'test-session', argv); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.AUTO_EDIT); }); it('should set YOLO approval mode when --approval-mode=yolo', async () => { process.argv = ['node', 'script.js', '--approval-mode', 'yolo']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const config = await loadCliConfig({}, [], 'test-session', argv); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO); }); @@ -1823,7 +1835,7 @@ describe('loadCliConfig approval mode', () => { // Note: This test documents the intended behavior, but in practice the validation // prevents both flags from being used together process.argv = ['node', 'script.js', '--approval-mode', 'default']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); // Manually set yolo to true to simulate what would happen if validation didn't prevent it argv.yolo = true; const config = await loadCliConfig({}, [], 'test-session', argv); @@ -1832,7 +1844,7 @@ describe('loadCliConfig approval mode', () => { it('should fall back to --yolo behavior when --approval-mode is not set', async () => { process.argv = ['node', 'script.js', '--yolo']; - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const config = await loadCliConfig({}, [], 'test-session', argv); expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO); }); @@ -1949,7 +1961,7 @@ describe('loadCliConfig trustedFolder', () => { (settings.folderTrust ?? true); return featureIsEnabled ? mockTrustValue : true; }); - const argv = await parseArguments(); + const argv = await parseArguments({} as Settings); const settings: Settings = { folderTrustFeature, folderTrust }; const config = await loadCliConfig(settings, [], 'test-session', argv); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 4b7510f9..759252ed 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -11,6 +11,7 @@ import yargs from 'yargs/yargs'; import { hideBin } from 'yargs/helpers'; import process from 'node:process'; import { mcpCommand } from '../commands/mcp.js'; +import { extensionsCommand } from '../commands/extensions.js'; import { Config, loadServerHierarchicalMemory, @@ -76,7 +77,7 @@ export interface CliArgs { screenReader: boolean | undefined; } -export async function parseArguments(): Promise { +export async function parseArguments(settings: Settings): Promise { const yargsInstance = yargs(hideBin(process.argv)) .locale('en') .scriptName('gemini') @@ -252,7 +253,13 @@ export async function parseArguments(): Promise { }), ) // Register MCP subcommands - .command(mcpCommand) + .command(mcpCommand); + + if (settings?.extensionManagement ?? false) { + yargsInstance.command(extensionsCommand); + } + + yargsInstance .version(await getCliVersion()) // This will enable the --version flag based on package.json .alias('v', 'version') .help() @@ -265,7 +272,10 @@ export async function parseArguments(): Promise { // Handle case where MCP subcommands are executed - they should exit the process // and not return to main CLI logic - if (result._.length > 0 && result._[0] === 'mcp') { + if ( + result._.length > 0 && + (result._[0] === 'mcp' || result._[0] === 'extensions') + ) { // MCP commands handle their own execution and process exit process.exit(0); } diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts index ece36916..20ae8889 100644 --- a/packages/cli/src/config/extension.test.ts +++ b/packages/cli/src/config/extension.test.ts @@ -10,9 +10,17 @@ import * as os from 'os'; import * as path from 'path'; import { EXTENSIONS_CONFIG_FILENAME, + INSTALL_METADATA_FILENAME, annotateActiveExtensions, + installExtension, loadExtensions, } from './extension.js'; +import { execSync } from 'child_process'; +import { SimpleGit, simpleGit } from 'simple-git'; + +vi.mock('simple-git', () => ({ + simpleGit: vi.fn(), +})); vi.mock('os', async (importOriginal) => { const os = await importOriginal(); @@ -22,6 +30,13 @@ vi.mock('os', async (importOriginal) => { }; }); +vi.mock('child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + execSync: vi.fn(), + }; +}); const EXTENSIONS_DIRECTORY_NAME = path.join('.gemini', 'extensions'); describe('loadExtensions', () => { @@ -163,15 +178,117 @@ describe('annotateActiveExtensions', () => { }); }); +describe('installExtension', () => { + let tempHomeDir: string; + let userExtensionsDir: string; + + beforeEach(() => { + tempHomeDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'gemini-cli-test-home-'), + ); + vi.mocked(os.homedir).mockReturnValue(tempHomeDir); + userExtensionsDir = path.join(tempHomeDir, '.gemini', 'extensions'); + // Clean up before each test + fs.rmSync(userExtensionsDir, { recursive: true, force: true }); + fs.mkdirSync(userExtensionsDir, { recursive: true }); + + vi.mocked(execSync).mockClear(); + }); + + afterEach(() => { + fs.rmSync(tempHomeDir, { recursive: true, force: true }); + }); + + it('should install an extension from a local path', async () => { + const sourceExtDir = createExtension( + tempHomeDir, + 'my-local-extension', + '1.0.0', + ); + const targetExtDir = path.join(userExtensionsDir, 'my-local-extension'); + const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME); + + await installExtension({ source: sourceExtDir, type: 'local' }); + + expect(fs.existsSync(targetExtDir)).toBe(true); + expect(fs.existsSync(metadataPath)).toBe(true); + const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8')); + expect(metadata).toEqual({ + source: sourceExtDir, + type: 'local', + }); + fs.rmSync(targetExtDir, { recursive: true, force: true }); + }); + + it('should throw an error if the extension already exists', async () => { + const sourceExtDir = createExtension( + tempHomeDir, + 'my-local-extension', + '1.0.0', + ); + await installExtension({ source: sourceExtDir, type: 'local' }); + await expect( + installExtension({ source: sourceExtDir, type: 'local' }), + ).rejects.toThrow( + 'Error: Extension "my-local-extension" is already installed. Please uninstall it first.', + ); + }); + + it('should throw an error and cleanup if gemini-extension.json is missing', async () => { + const sourceExtDir = path.join(tempHomeDir, 'bad-extension'); + fs.mkdirSync(sourceExtDir, { recursive: true }); + + await expect( + installExtension({ source: sourceExtDir, type: 'local' }), + ).rejects.toThrow( + `Invalid extension at ${sourceExtDir}. Please make sure it has a valid gemini-extension.json file.`, + ); + + const targetExtDir = path.join(userExtensionsDir, 'bad-extension'); + expect(fs.existsSync(targetExtDir)).toBe(false); + }); + + it('should install an extension from a git URL', async () => { + const gitUrl = 'https://github.com/google/gemini-extensions.git'; + const extensionName = 'gemini-extensions'; + const targetExtDir = path.join(userExtensionsDir, extensionName); + const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME); + + const clone = vi.fn().mockImplementation(async (_, destination) => { + fs.mkdirSync(destination, { recursive: true }); + fs.writeFileSync( + path.join(destination, EXTENSIONS_CONFIG_FILENAME), + JSON.stringify({ name: extensionName, version: '1.0.0' }), + ); + }); + + const mockedSimpleGit = simpleGit as vi.MockedFunction; + mockedSimpleGit.mockReturnValue({ + clone, + } as unknown as SimpleGit); + + await installExtension({ source: gitUrl, type: 'git' }); + + expect(fs.existsSync(targetExtDir)).toBe(true); + expect(fs.existsSync(metadataPath)).toBe(true); + const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8')); + expect(metadata).toEqual({ + source: gitUrl, + type: 'git', + }); + fs.rmSync(targetExtDir, { recursive: true, force: true }); + }); +}); + function createExtension( extensionsDir: string, name: string, version: string, addContextFile = false, contextFileName?: string, -): void { +): string { const extDir = path.join(extensionsDir, name); - fs.mkdirSync(extDir); + fs.mkdirSync(extDir, { recursive: true }); fs.writeFileSync( path.join(extDir, EXTENSIONS_CONFIG_FILENAME), JSON.stringify({ name, version, contextFileName }), @@ -184,4 +301,5 @@ function createExtension( if (contextFileName) { fs.writeFileSync(path.join(extDir, contextFileName), 'context'); } + return extDir; } diff --git a/packages/cli/src/config/extension.ts b/packages/cli/src/config/extension.ts index 46fd32fc..63f80831 100644 --- a/packages/cli/src/config/extension.ts +++ b/packages/cli/src/config/extension.ts @@ -12,13 +12,18 @@ import { import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; +import { simpleGit } from 'simple-git'; + +export const EXTENSIONS_DIRECTORY_NAME = '.gemini/extensions'; export const EXTENSIONS_CONFIG_FILENAME = 'gemini-extension.json'; +export const INSTALL_METADATA_FILENAME = '.gemini-extension-install.json'; export interface Extension { path: string; config: ExtensionConfig; contextFiles: string[]; + installMetadata?: ExtensionInstallMetadata | undefined; } export interface ExtensionConfig { @@ -29,6 +34,45 @@ export interface ExtensionConfig { excludeTools?: string[]; } +export interface ExtensionInstallMetadata { + source: string; + type: 'git' | 'local'; +} + +export class ExtensionStorage { + private readonly extensionName: string; + + constructor(extensionName: string) { + this.extensionName = extensionName; + } + + getExtensionDir(): string { + return path.join( + ExtensionStorage.getUserExtensionsDir(), + this.extensionName, + ); + } + + getConfigPath(): string { + return path.join(this.getExtensionDir(), EXTENSIONS_CONFIG_FILENAME); + } + + static getSettingsPath(): string { + return process.cwd(); + } + + static getUserExtensionsDir(): string { + const storage = new Storage(os.homedir()); + return storage.getExtensionsDir(); + } + + static async createTmpDir(): Promise { + return await fs.promises.mkdtemp( + path.join(os.tmpdir(), 'gemini-extension'), + ); + } +} + export function loadExtensions(workspaceDir: string): Extension[] { const allExtensions = [ ...loadExtensionsFromDir(workspaceDir), @@ -45,7 +89,20 @@ export function loadExtensions(workspaceDir: string): Extension[] { return Array.from(uniqueExtensions.values()); } -function loadExtensionsFromDir(dir: string): Extension[] { +export function loadUserExtensions(): Extension[] { + const userExtensions = loadExtensionsFromDir(os.homedir()); + + const uniqueExtensions = new Map(); + for (const extension of userExtensions) { + if (!uniqueExtensions.has(extension.config.name)) { + uniqueExtensions.set(extension.config.name, extension); + } + } + + return Array.from(uniqueExtensions.values()); +} + +export function loadExtensionsFromDir(dir: string): Extension[] { const storage = new Storage(dir); const extensionsDir = storage.getExtensionsDir(); if (!fs.existsSync(extensionsDir)) { @@ -64,7 +121,7 @@ function loadExtensionsFromDir(dir: string): Extension[] { return extensions; } -function loadExtension(extensionDir: string): Extension | null { +export function loadExtension(extensionDir: string): Extension | null { if (!fs.statSync(extensionDir).isDirectory()) { console.error( `Warning: unexpected file ${extensionDir} in extensions directory.`, @@ -98,6 +155,7 @@ function loadExtension(extensionDir: string): Extension | null { path: extensionDir, config, contextFiles, + installMetadata: loadInstallMetadata(extensionDir), }; } catch (e) { console.error( @@ -107,6 +165,19 @@ function loadExtension(extensionDir: string): Extension | null { } } +function loadInstallMetadata( + extensionDir: string, +): ExtensionInstallMetadata | undefined { + const metadataFilePath = path.join(extensionDir, INSTALL_METADATA_FILENAME); + try { + const configContent = fs.readFileSync(metadataFilePath, 'utf-8'); + const metadata = JSON.parse(configContent) as ExtensionInstallMetadata; + return metadata; + } catch (_e) { + return undefined; + } +} + function getContextFileNames(config: ExtensionConfig): string[] { if (!config.contextFileName) { return ['GEMINI.md']; @@ -171,3 +242,99 @@ export function annotateActiveExtensions( return annotatedExtensions; } + +/** + * Clones a Git repository to a specified local path. + * @param gitUrl The Git URL to clone. + * @param destination The destination path to clone the repository to. + */ +async function cloneFromGit( + gitUrl: string, + destination: string, +): Promise { + try { + // TODO(chrstnb): Download the archive instead to avoid unnecessary .git info. + await simpleGit().clone(gitUrl, destination, ['--depth', '1']); + } catch (error) { + throw new Error(`Failed to clone Git repository from ${gitUrl}`, { + cause: error, + }); + } +} + +/** + * Copies an extension from a source to a destination path. + * @param source The source path of the extension. + * @param destination The destination path to copy the extension to. + */ +async function copyExtension( + source: string, + destination: string, +): Promise { + await fs.promises.cp(source, destination, { recursive: true }); +} + +export async function installExtension( + installMetadata: ExtensionInstallMetadata, +): Promise { + const extensionsDir = ExtensionStorage.getUserExtensionsDir(); + await fs.promises.mkdir(extensionsDir, { recursive: true }); + + // Convert relative paths to absolute paths for the metadata file. + if ( + installMetadata.type === 'local' && + !path.isAbsolute(installMetadata.source) + ) { + installMetadata.source = path.resolve( + process.cwd(), + installMetadata.source, + ); + } + + let localSourcePath: string; + let tempDir: string | undefined; + if (installMetadata.type === 'git') { + tempDir = await ExtensionStorage.createTmpDir(); + await cloneFromGit(installMetadata.source, tempDir); + localSourcePath = tempDir; + } else { + localSourcePath = installMetadata.source; + } + let newExtensionName: string | undefined; + try { + const newExtension = loadExtension(localSourcePath); + if (!newExtension) { + throw new Error( + `Invalid extension at ${installMetadata.source}. Please make sure it has a valid gemini-extension.json file.`, + ); + } + + // ~/.gemini/extensions/{ExtensionConfig.name}. + newExtensionName = newExtension.config.name; + const extensionStorage = new ExtensionStorage(newExtensionName); + const destinationPath = extensionStorage.getExtensionDir(); + + const installedExtensions = loadUserExtensions(); + if ( + installedExtensions.some( + (installed) => installed.config.name === newExtensionName, + ) + ) { + throw new Error( + `Error: Extension "${newExtensionName}" is already installed. Please uninstall it first.`, + ); + } + + await copyExtension(localSourcePath, destinationPath); + + const metadataString = JSON.stringify(installMetadata, null, 2); + const metadataPath = path.join(destinationPath, INSTALL_METADATA_FILENAME); + await fs.promises.writeFile(metadataPath, metadataString); + } finally { + if (tempDir) { + await fs.promises.rm(tempDir, { recursive: true, force: true }); + } + } + + return newExtensionName; +} diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 94f60288..e23277b1 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -535,6 +535,15 @@ export const SETTINGS_SCHEMA = { description: 'Show line numbers in the chat.', showInDialog: true, }, + extensionManagement: { + type: 'boolean', + label: 'Extension Management', + category: 'Feature Flag', + requiresRestart: true, + default: false, + description: 'Enable extension management features.', + showInDialog: false, + }, skipNextSpeakerCheck: { type: 'boolean', label: 'Skip Next Speaker Check', diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 7e8b10a9..aadddaeb 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -188,7 +188,7 @@ export async function main() { process.exit(1); } - const argv = await parseArguments(); + const argv = await parseArguments(settings.merged); const extensions = loadExtensions(workspaceRoot); const config = await loadCliConfig( settings.merged, diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 9e197dd1..1bca179e 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -205,6 +205,7 @@ export interface ConfigParameters { useRipgrep?: boolean; shouldUseNodePtyShell?: boolean; skipNextSpeakerCheck?: boolean; + extensionManagement?: boolean; enablePromptCompletion?: boolean; } @@ -275,6 +276,7 @@ export class Config { private readonly useRipgrep: boolean; private readonly shouldUseNodePtyShell: boolean; private readonly skipNextSpeakerCheck: boolean; + private readonly extensionManagement: boolean; private readonly enablePromptCompletion: boolean = false; private initialized: boolean = false; readonly storage: Storage; @@ -349,6 +351,7 @@ export class Config { this.useRipgrep = params.useRipgrep ?? false; this.shouldUseNodePtyShell = params.shouldUseNodePtyShell ?? false; this.skipNextSpeakerCheck = params.skipNextSpeakerCheck ?? false; + this.extensionManagement = params.extensionManagement ?? false; this.storage = new Storage(this.targetDir); this.enablePromptCompletion = params.enablePromptCompletion ?? false; this.fileExclusions = new FileExclusions(this); @@ -678,6 +681,10 @@ export class Config { return this.listExtensions; } + getExtensionManagement(): boolean { + return this.extensionManagement; + } + getExtensions(): GeminiCLIExtension[] { return this._extensions; }