[extensions] Add extension management install command (#6703)

This commit is contained in:
christine betts
2025-08-25 17:02:10 +00:00
committed by GitHub
parent 49cce8a15d
commit 0bd496bd51
14 changed files with 562 additions and 115 deletions

View File

@@ -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 <command>',
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.
},
};

View File

@@ -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');
});
});

View File

@@ -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,
});
},
};

View File

@@ -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);

View File

@@ -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);

View File

@@ -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<CliArgs> {
export async function parseArguments(settings: Settings): Promise<CliArgs> {
const yargsInstance = yargs(hideBin(process.argv))
.locale('en')
.scriptName('gemini')
@@ -252,7 +253,13 @@ export async function parseArguments(): Promise<CliArgs> {
}),
)
// 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<CliArgs> {
// 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);
}

View File

@@ -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<typeof import('os')>();
@@ -22,6 +30,13 @@ vi.mock('os', async (importOriginal) => {
};
});
vi.mock('child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('child_process')>();
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<typeof simpleGit>;
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;
}

View File

@@ -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<string> {
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<string, Extension>();
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<void> {
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<void> {
await fs.promises.cp(source, destination, { recursive: true });
}
export async function installExtension(
installMetadata: ExtensionInstallMetadata,
): Promise<string> {
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;
}

View File

@@ -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',

View File

@@ -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,

View File

@@ -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;
}