Skip to content
Merged
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
10 changes: 9 additions & 1 deletion docs/extensions/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,10 @@ The manifest file defines the extension's behavior and configuration.
}
},
"contextFileName": "GEMINI.md",
"excludeTools": ["run_shell_command"]
"excludeTools": ["run_shell_command"],
"plan": {
"directory": ".gemini/plans"
}
}
```

Expand Down Expand Up @@ -157,6 +160,11 @@ The manifest file defines the extension's behavior and configuration.
`"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.
- `plan`: Planning features configuration.
- `directory`: The directory where planning artifacts are stored. This serves
as a fallback if the user hasn't specified a plan directory in their
settings. If not specified by either the extension or the user, the default
is `~/.gemini/tmp/<project>/<session-id>/plans/`.

When Gemini CLI starts, it loads all the extensions and merges their
configurations. If there are any conflicts, the workspace configuration takes
Expand Down
99 changes: 99 additions & 0 deletions packages/cli/src/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import {
debugLogger,
ApprovalMode,
type MCPServerConfig,
type GeminiCLIExtension,
Storage,
} from '@google/gemini-cli-core';
import { loadCliConfig, parseArguments, type CliArgs } from './config.js';
import {
Expand Down Expand Up @@ -3524,4 +3526,101 @@ describe('loadCliConfig mcpEnabled', () => {
expect(config.getAllowedMcpServers()).toEqual(['serverA']);
expect(config.getBlockedMcpServers()).toEqual(['serverB']);
});

describe('extension plan settings', () => {
beforeEach(() => {
vi.spyOn(Storage.prototype, 'getProjectTempDir').mockReturnValue(
'/mock/home/user/.gemini/tmp/test-project',
);
});

it('should use plan directory from active extension when user has not specified one', async () => {
process.argv = ['node', 'script.js'];
const settings = createTestMergedSettings({
experimental: { plan: true },
});
const argv = await parseArguments(settings);

vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([
{
name: 'ext-plan',
isActive: true,
plan: { directory: 'ext-plans-dir' },
} as unknown as GeminiCLIExtension,
]);

const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.storage.getPlansDir()).toContain('ext-plans-dir');
});

it('should NOT use plan directory from active extension when user has specified one', async () => {
process.argv = ['node', 'script.js'];
const settings = createTestMergedSettings({
experimental: { plan: true },
general: {
plan: { directory: 'user-plans-dir' },
},
});
const argv = await parseArguments(settings);

vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([
{
name: 'ext-plan',
isActive: true,
plan: { directory: 'ext-plans-dir' },
} as unknown as GeminiCLIExtension,
]);

const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.storage.getPlansDir()).toContain('user-plans-dir');
expect(config.storage.getPlansDir()).not.toContain('ext-plans-dir');
});

it('should NOT use plan directory from inactive extension', async () => {
process.argv = ['node', 'script.js'];
const settings = createTestMergedSettings({
experimental: { plan: true },
});
const argv = await parseArguments(settings);

vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([
{
name: 'ext-plan',
isActive: false,
plan: { directory: 'ext-plans-dir-inactive' },
} as unknown as GeminiCLIExtension,
]);

const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.storage.getPlansDir()).not.toContain(
'ext-plans-dir-inactive',
);
});

it('should use default path if neither user nor extension settings provide a plan directory', async () => {
process.argv = ['node', 'script.js'];
const settings = createTestMergedSettings({
experimental: { plan: true },
});
const argv = await parseArguments(settings);

// No extensions providing plan directory
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]);

const config = await loadCliConfig(settings, 'test-session', argv);
// Should return the default managed temp directory path
expect(config.storage.getPlansDir()).toBe(
path.join(
'/mock',
'home',
'user',
'.gemini',
'tmp',
'test-project',
'test-session',
'plans',
),
);
});
});
});
8 changes: 7 additions & 1 deletion packages/cli/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,10 @@ export async function loadCliConfig(
});
await extensionManager.loadExtensions();

const extensionPlanSettings = extensionManager
.getExtensions()
.find((ext) => ext.isActive && ext.plan?.directory)?.plan;

const experimentalJitContext = settings.experimental?.jitContext ?? false;

let memoryContent: string | HierarchicalMemory = '';
Expand Down Expand Up @@ -827,7 +831,9 @@ export async function loadCliConfig(
enableAgents: settings.experimental?.enableAgents,
plan: settings.experimental?.plan,
directWebFetch: settings.experimental?.directWebFetch,
planSettings: settings.general?.plan,
planSettings: settings.general?.plan?.directory
? settings.general.plan
: (extensionPlanSettings ?? settings.general?.plan),
enableEventDrivenScheduler: true,
skillsSupport: settings.skills?.enabled ?? true,
disabledSkills: settings.skills?.disabled,
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/config/extension-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -886,6 +886,7 @@ Would you like to attempt to install via "git clone" instead?`,
themes: config.themes,
rules,
checkers,
plan: config.plan,
};
} catch (e) {
debugLogger.error(
Expand Down
9 changes: 9 additions & 0 deletions packages/cli/src/config/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,15 @@ export interface ExtensionConfig {
* These themes will be registered when the extension is activated.
*/
themes?: CustomTheme[];
/**
* Planning features configuration contributed by this extension.
*/
plan?: {
/**
* The directory where planning artifacts are stored.
*/
directory?: string;
};
}

export interface ExtensionUpdateInfo {
Expand Down
28 changes: 24 additions & 4 deletions packages/core/src/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2487,7 +2487,7 @@
mockCodeAssistServer.retrieveUserQuota.mockResolvedValue({
buckets: [
{
modelId: 'gemini-3.1-pro-preview',

Check warning on line 2490 in packages/core/src/config/config.test.ts

View workflow job for this annotation

GitHub Actions / Lint

Found sensitive keyword "gemini-3.1". Please make sure this change is appropriate to submit.
remainingAmount: '100',
remainingFraction: 1.0,
},
Expand Down Expand Up @@ -2950,9 +2950,11 @@

afterEach(() => {
vi.mocked(fs.promises.mkdir).mockRestore();
vi.mocked(fs.promises.access).mockRestore?.();
});

it('should create plans directory and add it to workspace context when plan is enabled', async () => {
it('should add plans directory to workspace context if it exists', async () => {
vi.spyOn(fs.promises, 'access').mockResolvedValue(undefined);
const config = new Config({
...baseParams,
plan: true,
Expand All @@ -2961,14 +2963,32 @@
await config.initialize();

const plansDir = config.storage.getPlansDir();
expect(fs.promises.mkdir).toHaveBeenCalledWith(plansDir, {
recursive: true,
});
// Should NOT create the directory eagerly
expect(fs.promises.mkdir).not.toHaveBeenCalled();
// Should check if it exists
expect(fs.promises.access).toHaveBeenCalledWith(plansDir);

const context = config.getWorkspaceContext();
expect(context.getDirectories()).toContain(plansDir);
});

it('should NOT add plans directory to workspace context if it does not exist', async () => {
vi.spyOn(fs.promises, 'access').mockRejectedValue({ code: 'ENOENT' });
const config = new Config({
...baseParams,
plan: true,
});

await config.initialize();

const plansDir = config.storage.getPlansDir();
expect(fs.promises.mkdir).not.toHaveBeenCalled();
expect(fs.promises.access).toHaveBeenCalledWith(plansDir);

const context = config.getWorkspaceContext();
expect(context.getDirectories()).not.toContain(plansDir);
});

it('should NOT create plans directory or add it to workspace context when plan is disabled', async () => {
const config = new Config({
...baseParams,
Expand Down
20 changes: 18 additions & 2 deletions packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,15 @@ export interface GeminiCLIExtension {
* Safety checkers contributed by this extension.
*/
checkers?: SafetyCheckerRule[];
/**
* Planning features configuration contributed by this extension.
*/
plan?: {
/**
* The directory where planning artifacts are stored.
*/
directory?: string;
};
}

export interface ExtensionInstallMetadata {
Expand Down Expand Up @@ -1093,8 +1102,15 @@ export class Config implements McpContext {
// Add plans directory to workspace context for plan file storage
if (this.planEnabled) {
const plansDir = this.storage.getPlansDir();
await fs.promises.mkdir(plansDir, { recursive: true });
this.workspaceContext.addDirectory(plansDir);
try {
await fs.promises.access(plansDir);
this.workspaceContext.addDirectory(plansDir);
} catch {
// Directory does not exist yet, so we don't add it to the workspace context.
// It will be created when the first plan is written. Since custom plan
// directories must be within the project root, they are automatically
// covered by the project-wide file discovery once created.
}
}

// Initialize centralized FileDiscoveryService
Expand Down
Loading