Make 'source' a positional argument in extensions install and set flag to true (#7807)

This commit is contained in:
christine betts
2025-09-05 11:44:41 -07:00
committed by GitHub
parent a7bfab4d96
commit 69da43eb70
9 changed files with 52 additions and 73 deletions
@@ -17,14 +17,14 @@ describe('extensions install command', () => {
it('should fail if no source is provided', () => { it('should fail if no source is provided', () => {
const validationParser = yargs([]).command(installCommand).fail(false); const validationParser = yargs([]).command(installCommand).fail(false);
expect(() => validationParser.parse('install')).toThrow( expect(() => validationParser.parse('install')).toThrow(
'Either --source or --path must be provided.', 'Either source or --path must be provided.',
); );
}); });
it('should fail if both git source and local path are provided', () => { it('should fail if both git source and local path are provided', () => {
const validationParser = yargs([]).command(installCommand).fail(false); const validationParser = yargs([]).command(installCommand).fail(false);
expect(() => expect(() =>
validationParser.parse('install --source some-url --path /some/path'), validationParser.parse('install some-url --path /some/path'),
).toThrow('Arguments source and path are mutually exclusive'); ).toThrow('Arguments source and path are mutually exclusive');
}); });
}); });
@@ -65,12 +65,12 @@ export async function handleInstall(args: InstallArgs) {
} }
export const installCommand: CommandModule = { export const installCommand: CommandModule = {
command: 'install [--source | --path ]', command: 'install [source]',
describe: describe:
'Installs an extension from a git repository (URL or "org/repo") or a local path.', 'Installs an extension from a git repository (URL or "org/repo") or a local path.',
builder: (yargs) => builder: (yargs) =>
yargs yargs
.option('source', { .positional('source', {
describe: 'The git URL or "org/repo" of the extension to install.', describe: 'The git URL or "org/repo" of the extension to install.',
type: 'string', type: 'string',
}) })
@@ -81,7 +81,7 @@ export const installCommand: CommandModule = {
.conflicts('source', 'path') .conflicts('source', 'path')
.check((argv) => { .check((argv) => {
if (!argv.source && !argv.path) { if (!argv.source && !argv.path) {
throw new Error('Either --source or --path must be provided.'); throw new Error('Either source or --path must be provided.');
} }
return true; return true;
}), }),
+1 -1
View File
@@ -305,7 +305,7 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
// Register MCP subcommands // Register MCP subcommands
.command(mcpCommand); .command(mcpCommand);
if (settings?.experimental?.extensionManagement ?? false) { if (settings?.experimental?.extensionManagement ?? true) {
yargsInstance.command(extensionsCommand); yargsInstance.command(extensionsCommand);
} }
+38 -61
View File
@@ -63,59 +63,36 @@ vi.mock('child_process', async (importOriginal) => {
const EXTENSIONS_DIRECTORY_NAME = path.join(GEMINI_DIR, 'extensions'); const EXTENSIONS_DIRECTORY_NAME = path.join(GEMINI_DIR, 'extensions');
describe('loadExtensions', () => { describe('loadExtensions', () => {
let tempWorkspaceDir: string;
let tempHomeDir: string; let tempHomeDir: string;
let workspaceExtensionsDir: string; let userExtensionsDir: string;
beforeEach(() => { beforeEach(() => {
tempWorkspaceDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-workspace-'),
);
tempHomeDir = fs.mkdtempSync( tempHomeDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-home-'), path.join(os.tmpdir(), 'gemini-cli-test-home-'),
); );
vi.mocked(os.homedir).mockReturnValue(tempHomeDir); vi.mocked(os.homedir).mockReturnValue(tempHomeDir);
vi.mocked(isWorkspaceTrusted).mockReturnValue(true); vi.mocked(isWorkspaceTrusted).mockReturnValue(true);
workspaceExtensionsDir = path.join( userExtensionsDir = path.join(tempHomeDir, EXTENSIONS_DIRECTORY_NAME);
tempWorkspaceDir, fs.mkdirSync(userExtensionsDir, { recursive: true });
EXTENSIONS_DIRECTORY_NAME,
);
fs.mkdirSync(workspaceExtensionsDir, { recursive: true });
}); });
afterEach(() => { afterEach(() => {
fs.rmSync(tempWorkspaceDir, { recursive: true, force: true });
fs.rmSync(tempHomeDir, { recursive: true, force: true }); fs.rmSync(tempHomeDir, { recursive: true, force: true });
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
it('ignores extensions in untrusted workspaces', () => {
vi.mocked(isWorkspaceTrusted).mockReturnValue(false);
const extensionDir = path.join(workspaceExtensionsDir, 'test-extension');
fs.mkdirSync(extensionDir, { recursive: true });
createExtension({
extensionsDir: workspaceExtensionsDir,
name: 'ext1',
version: '1.0.0',
addContextFile: true,
});
const extensions = loadExtensions(tempWorkspaceDir);
expect(extensions.length).toBe(0);
});
it('should include extension path in loaded extension', () => { it('should include extension path in loaded extension', () => {
const extensionDir = path.join(workspaceExtensionsDir, 'test-extension'); const extensionDir = path.join(userExtensionsDir, 'test-extension');
fs.mkdirSync(extensionDir, { recursive: true }); fs.mkdirSync(extensionDir, { recursive: true });
createExtension({ createExtension({
extensionsDir: workspaceExtensionsDir, extensionsDir: userExtensionsDir,
name: 'test-extension', name: 'test-extension',
version: '1.0.0', version: '1.0.0',
}); });
const extensions = loadExtensions(tempWorkspaceDir); const extensions = loadExtensions();
expect(extensions).toHaveLength(1); expect(extensions).toHaveLength(1);
expect(extensions[0].path).toBe(extensionDir); expect(extensions[0].path).toBe(extensionDir);
expect(extensions[0].config.name).toBe('test-extension'); expect(extensions[0].config.name).toBe('test-extension');
@@ -123,70 +100,70 @@ describe('loadExtensions', () => {
it('should load context file path when GEMINI.md is present', () => { it('should load context file path when GEMINI.md is present', () => {
createExtension({ createExtension({
extensionsDir: workspaceExtensionsDir, extensionsDir: userExtensionsDir,
name: 'ext1', name: 'ext1',
version: '1.0.0', version: '1.0.0',
addContextFile: true, addContextFile: true,
}); });
createExtension({ createExtension({
extensionsDir: workspaceExtensionsDir, extensionsDir: userExtensionsDir,
name: 'ext2', name: 'ext2',
version: '2.0.0', version: '2.0.0',
}); });
const extensions = loadExtensions(tempWorkspaceDir); const extensions = loadExtensions();
expect(extensions).toHaveLength(2); expect(extensions).toHaveLength(2);
const ext1 = extensions.find((e) => e.config.name === 'ext1'); const ext1 = extensions.find((e) => e.config.name === 'ext1');
const ext2 = extensions.find((e) => e.config.name === 'ext2'); const ext2 = extensions.find((e) => e.config.name === 'ext2');
expect(ext1?.contextFiles).toEqual([ expect(ext1?.contextFiles).toEqual([
path.join(workspaceExtensionsDir, 'ext1', 'GEMINI.md'), path.join(userExtensionsDir, 'ext1', 'GEMINI.md'),
]); ]);
expect(ext2?.contextFiles).toEqual([]); expect(ext2?.contextFiles).toEqual([]);
}); });
it('should load context file path from the extension config', () => { it('should load context file path from the extension config', () => {
createExtension({ createExtension({
extensionsDir: workspaceExtensionsDir, extensionsDir: userExtensionsDir,
name: 'ext1', name: 'ext1',
version: '1.0.0', version: '1.0.0',
addContextFile: false, addContextFile: false,
contextFileName: 'my-context-file.md', contextFileName: 'my-context-file.md',
}); });
const extensions = loadExtensions(tempWorkspaceDir); const extensions = loadExtensions();
expect(extensions).toHaveLength(1); expect(extensions).toHaveLength(1);
const ext1 = extensions.find((e) => e.config.name === 'ext1'); const ext1 = extensions.find((e) => e.config.name === 'ext1');
expect(ext1?.contextFiles).toEqual([ expect(ext1?.contextFiles).toEqual([
path.join(workspaceExtensionsDir, 'ext1', 'my-context-file.md'), path.join(userExtensionsDir, 'ext1', 'my-context-file.md'),
]); ]);
}); });
it('should filter out disabled extensions', () => { it('should filter out disabled extensions', () => {
createExtension({ createExtension({
extensionsDir: workspaceExtensionsDir, extensionsDir: userExtensionsDir,
name: 'ext1', name: 'ext1',
version: '1.0.0', version: '1.0.0',
}); });
createExtension({ createExtension({
extensionsDir: workspaceExtensionsDir, extensionsDir: userExtensionsDir,
name: 'ext2', name: 'ext2',
version: '2.0.0', version: '2.0.0',
}); });
const settingsDir = path.join(tempWorkspaceDir, GEMINI_DIR); const settingsDir = path.join(tempHomeDir, GEMINI_DIR);
fs.mkdirSync(settingsDir, { recursive: true }); fs.mkdirSync(settingsDir, { recursive: true });
fs.writeFileSync( fs.writeFileSync(
path.join(settingsDir, 'settings.json'), path.join(settingsDir, 'settings.json'),
JSON.stringify({ extensions: { disabled: ['ext1'] } }), JSON.stringify({ extensions: { disabled: ['ext1'] } }),
); );
const extensions = loadExtensions(tempWorkspaceDir); const extensions = loadExtensions();
const activeExtensions = annotateActiveExtensions( const activeExtensions = annotateActiveExtensions(
extensions, extensions,
[], [],
tempWorkspaceDir, tempHomeDir,
).filter((e) => e.isActive); ).filter((e) => e.isActive);
expect(activeExtensions).toHaveLength(1); expect(activeExtensions).toHaveLength(1);
expect(activeExtensions[0].name).toBe('ext2'); expect(activeExtensions[0].name).toBe('ext2');
@@ -194,7 +171,7 @@ describe('loadExtensions', () => {
it('should hydrate variables', () => { it('should hydrate variables', () => {
createExtension({ createExtension({
extensionsDir: workspaceExtensionsDir, extensionsDir: userExtensionsDir,
name: 'test-extension', name: 'test-extension',
version: '1.0.0', version: '1.0.0',
addContextFile: false, addContextFile: false,
@@ -206,11 +183,11 @@ describe('loadExtensions', () => {
}, },
}); });
const extensions = loadExtensions(tempWorkspaceDir); const extensions = loadExtensions();
expect(extensions).toHaveLength(1); expect(extensions).toHaveLength(1);
const loadedConfig = extensions[0].config; const loadedConfig = extensions[0].config;
const expectedCwd = path.join( const expectedCwd = path.join(
workspaceExtensionsDir, userExtensionsDir,
'test-extension', 'test-extension',
'server', 'server',
); );
@@ -218,6 +195,9 @@ describe('loadExtensions', () => {
}); });
it('should load a linked extension correctly', async () => { it('should load a linked extension correctly', async () => {
const tempWorkspaceDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-workspace-'),
);
const sourceExtDir = createExtension({ const sourceExtDir = createExtension({
extensionsDir: tempWorkspaceDir, extensionsDir: tempWorkspaceDir,
name: 'my-linked-extension', name: 'my-linked-extension',
@@ -231,7 +211,7 @@ describe('loadExtensions', () => {
type: 'link', type: 'link',
}); });
expect(extensionName).toEqual('my-linked-extension'); expect(extensionName).toEqual('my-linked-extension');
const extensions = loadExtensions(tempHomeDir); const extensions = loadExtensions();
expect(extensions).toHaveLength(1); expect(extensions).toHaveLength(1);
const linkedExt = extensions[0]; const linkedExt = extensions[0];
@@ -252,13 +232,13 @@ describe('loadExtensions', () => {
process.env.TEST_DB_URL = 'postgresql://localhost:5432/testdb'; process.env.TEST_DB_URL = 'postgresql://localhost:5432/testdb';
try { try {
const workspaceExtensionsDir = path.join( const userExtensionsDir = path.join(
tempWorkspaceDir, tempHomeDir,
EXTENSIONS_DIRECTORY_NAME, EXTENSIONS_DIRECTORY_NAME,
); );
fs.mkdirSync(workspaceExtensionsDir, { recursive: true }); fs.mkdirSync(userExtensionsDir, { recursive: true });
const extDir = path.join(workspaceExtensionsDir, 'test-extension'); const extDir = path.join(userExtensionsDir, 'test-extension');
fs.mkdirSync(extDir); fs.mkdirSync(extDir);
// Write config to a separate file for clarity and good practices // Write config to a separate file for clarity and good practices
@@ -280,7 +260,7 @@ describe('loadExtensions', () => {
}; };
fs.writeFileSync(configPath, JSON.stringify(extensionConfig)); fs.writeFileSync(configPath, JSON.stringify(extensionConfig));
const extensions = loadExtensions(tempWorkspaceDir); const extensions = loadExtensions();
expect(extensions).toHaveLength(1); expect(extensions).toHaveLength(1);
const extension = extensions[0]; const extension = extensions[0];
@@ -302,13 +282,10 @@ describe('loadExtensions', () => {
}); });
it('should handle missing environment variables gracefully', () => { it('should handle missing environment variables gracefully', () => {
const workspaceExtensionsDir = path.join( const userExtensionsDir = path.join(tempHomeDir, EXTENSIONS_DIRECTORY_NAME);
tempWorkspaceDir, fs.mkdirSync(userExtensionsDir, { recursive: true });
EXTENSIONS_DIRECTORY_NAME,
);
fs.mkdirSync(workspaceExtensionsDir, { recursive: true });
const extDir = path.join(workspaceExtensionsDir, 'test-extension'); const extDir = path.join(userExtensionsDir, 'test-extension');
fs.mkdirSync(extDir); fs.mkdirSync(extDir);
const extensionConfig = { const extensionConfig = {
@@ -331,7 +308,7 @@ describe('loadExtensions', () => {
JSON.stringify(extensionConfig), JSON.stringify(extensionConfig),
); );
const extensions = loadExtensions(tempWorkspaceDir); const extensions = loadExtensions();
expect(extensions).toHaveLength(1); expect(extensions).toHaveLength(1);
const extension = extensions[0]; const extension = extensions[0];
@@ -592,7 +569,7 @@ describe('uninstallExtension', () => {
await uninstallExtension('my-local-extension'); await uninstallExtension('my-local-extension');
expect(fs.existsSync(sourceExtDir)).toBe(false); expect(fs.existsSync(sourceExtDir)).toBe(false);
expect(loadExtensions(tempHomeDir)).toHaveLength(1); expect(loadExtensions()).toHaveLength(1);
expect(fs.existsSync(otherExtDir)).toBe(true); expect(fs.existsSync(otherExtDir)).toBe(true);
}); });
@@ -675,7 +652,7 @@ describe('performWorkspaceExtensionMigration', () => {
}); });
await performWorkspaceExtensionMigration([loadExtension(ext1Path)!]); await performWorkspaceExtensionMigration([loadExtension(ext1Path)!]);
const extensions = loadExtensions(tempWorkspaceDir); const extensions = loadExtensions();
expect(extensions).toEqual([]); expect(extensions).toEqual([]);
}); });
@@ -703,7 +680,7 @@ describe('performWorkspaceExtensionMigration', () => {
const userExtensionsDir = path.join(tempHomeDir, GEMINI_DIR, 'extensions'); const userExtensionsDir = path.join(tempHomeDir, GEMINI_DIR, 'extensions');
const userExt1Path = path.join(userExtensionsDir, 'ext1'); const userExt1Path = path.join(userExtensionsDir, 'ext1');
const extensions = loadExtensions(tempWorkspaceDir); const extensions = loadExtensions();
expect(extensions).toHaveLength(2); expect(extensions).toHaveLength(2);
const metadataPath = path.join(userExt1Path, INSTALL_METADATA_FILENAME); const metadataPath = path.join(userExt1Path, INSTALL_METADATA_FILENAME);
@@ -912,7 +889,7 @@ describe('enableExtension', () => {
}); });
const getActiveExtensions = (): GeminiCLIExtension[] => { const getActiveExtensions = (): GeminiCLIExtension[] => {
const extensions = loadExtensions(tempWorkspaceDir); const extensions = loadExtensions();
const activeExtensions = annotateActiveExtensions( const activeExtensions = annotateActiveExtensions(
extensions, extensions,
[], [],
+2 -1
View File
@@ -119,7 +119,8 @@ export function loadExtensions(
if ( if (
(isWorkspaceTrusted(settings) ?? true) && (isWorkspaceTrusted(settings) ?? true) &&
!settings.experimental?.extensionManagement // Default management setting to true
!(settings.experimental?.extensionManagement ?? true)
) { ) {
allExtensions.push(...getWorkspaceExtensions(workspaceDir)); allExtensions.push(...getWorkspaceExtensions(workspaceDir));
} }
+1 -1
View File
@@ -76,7 +76,7 @@ const MIGRATION_MAP: Record<string, string> = {
excludeTools: 'tools.exclude', excludeTools: 'tools.exclude',
excludeMCPServers: 'mcp.excluded', excludeMCPServers: 'mcp.excluded',
excludedProjectEnvVars: 'advanced.excludedEnvVars', excludedProjectEnvVars: 'advanced.excludedEnvVars',
extensionManagement: 'advanced.extensionManagement', extensionManagement: 'experimental.extensionManagement',
extensions: 'extensions', extensions: 'extensions',
fileFiltering: 'context.fileFiltering', fileFiltering: 'context.fileFiltering',
folderTrustFeature: 'security.folderTrust.featureEnabled', folderTrustFeature: 'security.folderTrust.featureEnabled',
+1 -1
View File
@@ -838,7 +838,7 @@ export const SETTINGS_SCHEMA = {
label: 'Extension Management', label: 'Extension Management',
category: 'Experimental', category: 'Experimental',
requiresRestart: true, requiresRestart: true,
default: false, default: true,
description: 'Enable extension management features.', description: 'Enable extension management features.',
showInDialog: false, showInDialog: false,
}, },
@@ -20,7 +20,8 @@ export function useWorkspaceMigration(settings: LoadedSettings) {
); );
useEffect(() => { useEffect(() => {
if (!settings.merged.experimental?.extensionManagement) { // Default to true if not set.
if (!(settings.merged.experimental?.extensionManagement ?? true)) {
return; return;
} }
const cwd = process.cwd(); const cwd = process.cwd();
+2 -2
View File
@@ -282,7 +282,7 @@ export class Config {
private readonly useRipgrep: boolean; private readonly useRipgrep: boolean;
private readonly shouldUseNodePtyShell: boolean; private readonly shouldUseNodePtyShell: boolean;
private readonly skipNextSpeakerCheck: boolean; private readonly skipNextSpeakerCheck: boolean;
private readonly extensionManagement: boolean; private readonly extensionManagement: boolean = true;
private readonly enablePromptCompletion: boolean = false; private readonly enablePromptCompletion: boolean = false;
private initialized: boolean = false; private initialized: boolean = false;
readonly storage: Storage; readonly storage: Storage;
@@ -360,7 +360,7 @@ export class Config {
this.shouldUseNodePtyShell = params.shouldUseNodePtyShell ?? false; this.shouldUseNodePtyShell = params.shouldUseNodePtyShell ?? false;
this.skipNextSpeakerCheck = params.skipNextSpeakerCheck ?? false; this.skipNextSpeakerCheck = params.skipNextSpeakerCheck ?? false;
this.useSmartEdit = params.useSmartEdit ?? true; this.useSmartEdit = params.useSmartEdit ?? true;
this.extensionManagement = params.extensionManagement ?? false; this.extensionManagement = params.extensionManagement ?? true;
this.storage = new Storage(this.targetDir); this.storage = new Storage(this.targetDir);
this.enablePromptCompletion = params.enablePromptCompletion ?? false; this.enablePromptCompletion = params.enablePromptCompletion ?? false;
this.fileExclusions = new FileExclusions(this); this.fileExclusions = new FileExclusions(this);