diff --git a/docs/extensions/reference.md b/docs/extensions/reference.md index 2c2b730126..46d43225b2 100644 --- a/docs/extensions/reference.md +++ b/docs/extensions/reference.md @@ -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" + } } ``` @@ -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///plans/`. When Gemini CLI starts, it loads all the extensions and merges their configurations. If there are any conflicts, the workspace configuration takes diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 919ad86c51..b22b7412cc 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -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 { @@ -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', + ), + ); + }); + }); }); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index bbc8b1681e..b478d67478 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -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 = ''; @@ -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, diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts index 56152cd6e1..a9fce44635 100644 --- a/packages/cli/src/config/extension-manager.ts +++ b/packages/cli/src/config/extension-manager.ts @@ -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( diff --git a/packages/cli/src/config/extension.ts b/packages/cli/src/config/extension.ts index 815cf23ece..04a7b885ca 100644 --- a/packages/cli/src/config/extension.ts +++ b/packages/cli/src/config/extension.ts @@ -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 { diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 83ee54f8e0..e587fc2e2e 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -2950,9 +2950,11 @@ describe('Plans Directory Initialization', () => { 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, @@ -2961,14 +2963,32 @@ describe('Plans Directory Initialization', () => { 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, diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 1a5c14b12c..258bd78f93 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -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 { @@ -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