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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion docs/extensions/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,10 @@ The file has the following structure:
}
},
"contextFileName": "GEMINI.md",
"excludeTools": ["run_shell_command"]
"excludeTools": ["run_shell_command"],
"context": {
"includeDirectories": ["/path/to/include"]
}
}
```

Expand Down Expand Up @@ -181,6 +184,10 @@ The file has the following structure:
`"excludeTools": ["run_shell_command(rm -rf)"]` will block the `rm -rf`
command. Note that this differs from the MCP server `excludeTools`
functionality, which can be listed in the MCP server config.
- `context.includeDirectories`: An array of directories to include in the
context. These directories will be added to the CLI's `includeDirectories`
setting when the extension is active. Relative paths are resolved relative to
the extension's directory.

When Gemini CLI starts, it loads all the extensions and merges their
configurations. If there are any conflicts, the workspace configuration takes
Expand Down
36 changes: 36 additions & 0 deletions packages/cli/src/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
WRITE_FILE_TOOL_NAME,
EDIT_TOOL_NAME,
WEB_FETCH_TOOL_NAME,
type GeminiCLIExtension,
type ExtensionLoader,
debugLogger,
} from '@google/gemini-cli-core';
Expand Down Expand Up @@ -88,15 +89,26 @@ vi.mock('@google/gemini-cli-core', async () => {
const actualServer = await vi.importActual<typeof ServerConfig>(
'@google/gemini-cli-core',
);
const actualConfig = await vi.importActual<
typeof import('../../../core/src/config/config.js')
>('../../../core/src/config/config.js');
return {
...actualServer,
Config: actualConfig.Config,
IdeClient: {
getInstance: vi.fn().mockResolvedValue({
getConnectionStatus: vi.fn(),
initialize: vi.fn(),
shutdown: vi.fn(),
}),
},
EDITOR_DISPLAY_NAMES: {
vim: 'Vim',
nano: 'Nano',
},
checkHasEditorType: vi.fn().mockReturnValue(true),
allowEditorTypeInSandbox: vi.fn().mockReturnValue(true),
logExtensionEnable: vi.fn(),
loadEnvironment: vi.fn(),
loadServerHierarchicalMemory: vi.fn(
(
Expand Down Expand Up @@ -1445,6 +1457,30 @@ describe('loadCliConfig with includeDirectories', () => {
expected.length - 1,
);
});

it('should include directories from active extensions', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings);
const settings: Settings = {};

const mockExtension = {
name: 'test-extension',
version: '1.0.0',
isActive: true,
path: '/path/to/extension',
id: 'test-extension-id',
contextFiles: [],
includeDirectories: ['/extension/dir'],
} as unknown as GeminiCLIExtension;

vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([
mockExtension,
]);

const config = await loadCliConfig(settings, 'test-session', argv);
const pendingDirs = config.getPendingIncludeDirectories();
expect(pendingDirs).toContain(path.resolve('/extension/dir'));
});
});

describe('loadCliConfig compressionThreshold', () => {
Expand Down
15 changes: 11 additions & 4 deletions packages/cli/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -414,10 +414,6 @@ export async function loadCliConfig(
...settings.context?.fileFiltering,
};

const includeDirectories = (settings.context?.includeDirectories || [])
.map(resolvePath)
.concat((argv.includeDirectories || []).map(resolvePath));

const extensionManager = new ExtensionManager({
settings,
requestConsent: requestConsentNonInteractive,
Expand All @@ -428,6 +424,17 @@ export async function loadCliConfig(
});
await extensionManager.loadExtensions();

const includeDirectories = (settings.context?.includeDirectories || [])
.map(resolvePath)
.concat((argv.includeDirectories || []).map(resolvePath))
.concat(
extensionManager
.getExtensions()
.filter((e) => e.isActive && e.includeDirectories)
.flatMap((e) => e.includeDirectories!)
.map(resolvePath),
);

// Call the (now wrapper) loadHierarchicalGeminiMemory which calls the server's version
const { memoryContent, fileCount, filePaths } =
await loadServerHierarchicalMemory(
Expand Down
5 changes: 5 additions & 0 deletions packages/cli/src/config/extension-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,11 @@ export class ExtensionManager extends ExtensionLoader {
this.workspaceDir,
),
id: getExtensionId(config, installMetadata),
includeDirectories: config.context?.includeDirectories?.map((dir) =>
path.isAbsolute(dir) || dir.startsWith('~')
? dir
: path.resolve(effectiveExtensionPath, dir),
),
};
this.loadedExtensions = [...this.loadedExtensions, extension];

Expand Down
59 changes: 59 additions & 0 deletions packages/cli/src/config/extension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,65 @@ describe('extension tests', () => {
expect(extensions[0].mcpServers?.['test-server'].cwd).toBe(expectedCwd);
});

it('should load includeDirectories from extension config', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'test-extension',
version: '1.0.0',
includeDirectories: [
'/absolute/path',
'~/home/path',
'./relative/path',
'${extensionPath}/hydrated/path',
],
});

const extensions = await extensionManager.loadExtensions();
expect(extensions).toHaveLength(1);
const includeDirs = extensions[0].includeDirectories;
expect(includeDirs).toBeDefined();
expect(includeDirs).toContain('/absolute/path');
// ~ is not expanded by extension loader, but by config loader.
// However, relative paths should be resolved relative to extension dir?
// Relative paths should be resolved relative to the extension directory
const expectedRelativePath = path.join(
userExtensionsDir,
'test-extension',
'relative',
'path',
);
expect(includeDirs).toContain(expectedRelativePath);

// Variable substitution happens in loadExtensionConfig
const expectedHydratedPath = path.join(
userExtensionsDir,
'test-extension',
'hydrated',
'path',
);
expect(includeDirs).toContain(expectedHydratedPath);
});

it('should not include directories from inactive extensions', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'inactive-extension',
version: '1.0.0',
includeDirectories: ['/inactive/path'],
});

// Mock isEnabled to return false for this extension
vi.spyOn(
extensionManager['extensionEnablementManager'],
'isEnabled',
).mockReturnValue(false);

const extensions = await extensionManager.loadExtensions();
expect(extensions).toHaveLength(1);
expect(extensions[0].isActive).toBe(false);
// The extension object still has the property, but config.ts filters by isActive
});

it('should load a linked extension correctly', async () => {
const sourceExtDir = createExtension({
extensionsDir: tempWorkspaceDir,
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/config/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ export interface ExtensionConfig {
mcpServers?: Record<string, MCPServerConfig>;
contextFileName?: string | string[];
excludeTools?: string[];
context?: {
includeDirectories?: string[];
};
settings?: ExtensionSetting[];
}

Expand Down
20 changes: 20 additions & 0 deletions packages/cli/src/config/extensions/consent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,26 @@ describe('consent', () => {
await maybeRequestConsentOrFail(newConfig, requestConsent, prevConfig);
expect(requestConsent).toHaveBeenCalledTimes(1);
});

it('should request consent if includeDirectories changes', async () => {
const prevConfig: ExtensionConfig = { ...baseConfig };
const newConfig: ExtensionConfig = {
...baseConfig,
context: {
includeDirectories: ['/path/to/include'],
},
};
const requestConsent = vi.fn().mockResolvedValue(true);
await maybeRequestConsentOrFail(newConfig, requestConsent, prevConfig);

const expectedConsentString = [
'Installing extension "test-ext".',
INSTALL_WARNING_MESSAGE,
'This extension will add the following directories to your context (while active): /path/to/include',
].join('\n');

expect(requestConsent).toHaveBeenCalledWith(expectedConsentString);
});
});
});
});
5 changes: 5 additions & 0 deletions packages/cli/src/config/extensions/consent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,11 @@ function extensionConsentString(extensionConfig: ExtensionConfig): string {
`This extension will exclude the following core tools: ${sanitizedConfig.excludeTools}`,
);
}
if (sanitizedConfig.context?.includeDirectories) {
output.push(
`This extension will add the following directories to your context (while active): ${sanitizedConfig.context.includeDirectories.join(', ')}`,
);
}
return output.join('\n');
}

Expand Down
10 changes: 9 additions & 1 deletion packages/cli/src/test-utils/createExtension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,20 @@ export function createExtension({
mcpServers = {} as Record<string, MCPServerConfig>,
installMetadata = undefined as ExtensionInstallMetadata | undefined,
settings = undefined as ExtensionSetting[] | undefined,
includeDirectories = undefined as string[] | undefined,
} = {}): string {
const extDir = path.join(extensionsDir, name);
fs.mkdirSync(extDir, { recursive: true });
fs.writeFileSync(
path.join(extDir, EXTENSIONS_CONFIG_FILENAME),
JSON.stringify({ name, version, contextFileName, mcpServers, settings }),
JSON.stringify({
name,
version,
contextFileName,
mcpServers,
settings,
context: includeDirectories ? { includeDirectories } : undefined,
}),
);

if (addContextFile) {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ export interface GeminiCLIExtension {
excludeTools?: string[];
id: string;
hooks?: { [K in HookEventName]?: HookDefinition[] };
includeDirectories?: string[];
}

export interface ExtensionInstallMetadata {
Expand Down