mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-12 12:54:07 -07:00
feat: implement extensible plan mode with custom directory configuration
- Adds 'general.plan' configuration object for plan settings (directory). - Updates 'experimental.plan' to a boolean flag for enablement. - Implements dynamic high-priority policy for custom plan directories in core. - Adds migration logic for previous configuration formats. - Updates documentation and schema.
This commit is contained in:
@@ -107,6 +107,62 @@ These are the only allowed tools:
|
||||
- **Skills:** [`activate_skill`] (allows loading specialized instructions and
|
||||
resources in a read-only manner)
|
||||
|
||||
### Customizing the Plan Directory
|
||||
|
||||
By default, plans are stored in a temporary directory within `~/.gemini/tmp/`.
|
||||
You can customize this location, but doing so requires **two steps**:
|
||||
configuring the setting and adding a policy rule.
|
||||
|
||||
**Important:** If you only update `settings.json`, the agent will be blocked
|
||||
from writing to your custom directory by the default safety policies.
|
||||
|
||||
#### 1. Configure the directory in `settings.json`
|
||||
|
||||
Add the `plan.directory` setting to your `~/.gemini/settings.json` file. This
|
||||
path can be **absolute** or **relative** to your project root.
|
||||
|
||||
```json
|
||||
{
|
||||
"general": {
|
||||
"plan": {
|
||||
"directory": "conductor"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Add a policy to allow writing to that directory
|
||||
|
||||
Create a policy file (e.g., `~/.gemini/policies/custom-plans.toml`) to
|
||||
explicitly allow the agent to write files to your custom directory while in Plan
|
||||
Mode.
|
||||
|
||||
The `argsPattern` in your policy must match the `file_path` (or `path`) argument
|
||||
passed to the tool.
|
||||
|
||||
```toml
|
||||
[[rule]]
|
||||
toolName = ["write_file", "replace"]
|
||||
# Allow writing to any path within the "conductor/" directory
|
||||
# This regex matches a relative path.
|
||||
argsPattern = "\"(?:file_path|path)\":\"conductor/[^\"]+\""
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
modes = ["plan"]
|
||||
```
|
||||
|
||||
**Relative vs. Absolute Paths:**
|
||||
|
||||
- **Relative Paths:** If you use a relative path like `"conductor"` in
|
||||
`settings.json`, the agent will typically use `conductor/plan.md`. Your
|
||||
`argsPattern` should reflect this relative structure.
|
||||
- **Absolute Paths:** If you use an absolute path like `"/usr/local/plans"`,
|
||||
your `argsPattern` must match that absolute path:
|
||||
`\"(?:file_path|path)\":\"/usr/local/plans/[^\"]+\"`.
|
||||
|
||||
> **Tip:** For Windows users, the regex pattern must match double-backslashes in
|
||||
> the JSON-stringified arguments: `conductor\\\\[^"]+`.
|
||||
|
||||
### Customizing Planning with Skills
|
||||
|
||||
You can leverage [Agent Skills](./skills.md) to customize how Gemini CLI
|
||||
|
||||
@@ -142,6 +142,10 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`general.plan`** (object):
|
||||
- **Description:** Configuration for planning features.
|
||||
- **Default:** `{}`
|
||||
|
||||
- **`general.enablePromptCompletion`** (boolean):
|
||||
- **Description:** Enable AI-powered prompt completion suggestions while
|
||||
typing.
|
||||
|
||||
@@ -833,6 +833,15 @@ export async function loadCliConfig(
|
||||
enableExtensionReloading: settings.experimental?.extensionReloading,
|
||||
enableAgents: settings.experimental?.enableAgents,
|
||||
plan: settings.experimental?.plan,
|
||||
planDirectory:
|
||||
settings.general &&
|
||||
'plan' in settings.general &&
|
||||
typeof settings.general.plan === 'object' &&
|
||||
settings.general.plan !== null &&
|
||||
'directory' in settings.general.plan &&
|
||||
typeof settings.general.plan.directory === 'string'
|
||||
? settings.general.plan.directory
|
||||
: undefined,
|
||||
enableEventDrivenScheduler: true,
|
||||
skillsSupport: settings.skills?.enabled ?? true,
|
||||
disabledSkills: settings.skills?.disabled,
|
||||
|
||||
@@ -401,6 +401,18 @@ describe('SettingsSchema', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should have plan config in general schema', () => {
|
||||
const setting = getSettingsSchema().general.properties.plan;
|
||||
expect(setting).toBeDefined();
|
||||
expect(setting.type).toBe('object');
|
||||
expect(setting.category).toBe('General');
|
||||
expect(setting.default).toStrictEqual({});
|
||||
expect(setting.requiresRestart).toBe(false);
|
||||
expect(setting.showInDialog).toBe(false);
|
||||
expect(setting.ref).toBe('PlanConfig');
|
||||
expect(setting.description).toBe('Configuration for planning features.');
|
||||
});
|
||||
|
||||
it('should have hooksConfig.notifications setting in schema', () => {
|
||||
const setting = getSettingsSchema().hooksConfig?.properties.notifications;
|
||||
expect(setting).toBeDefined();
|
||||
|
||||
@@ -266,6 +266,16 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
},
|
||||
},
|
||||
plan: {
|
||||
type: 'object',
|
||||
label: 'Plan Configuration',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: {},
|
||||
description: 'Configuration for planning features.',
|
||||
showInDialog: false,
|
||||
ref: 'PlanConfig',
|
||||
},
|
||||
enablePromptCompletion: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Prompt Completion',
|
||||
@@ -2131,6 +2141,17 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record<
|
||||
},
|
||||
},
|
||||
},
|
||||
PlanConfig: {
|
||||
type: 'object',
|
||||
description: 'Planning features configuration.',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
directory: {
|
||||
type: 'string',
|
||||
description: 'Custom directory for implementation plans.',
|
||||
},
|
||||
},
|
||||
},
|
||||
TelemetrySettings: {
|
||||
type: 'object',
|
||||
description: 'Telemetry configuration for Gemini CLI.',
|
||||
|
||||
@@ -151,6 +151,7 @@ Implement a comprehensive authentication system with multiple providers.
|
||||
...options,
|
||||
config: {
|
||||
getTargetDir: () => mockTargetDir,
|
||||
getPlanDirectory: () => mockPlansDir,
|
||||
getIdeMode: () => false,
|
||||
isTrustedFolder: () => true,
|
||||
storage: {
|
||||
@@ -426,6 +427,7 @@ Implement a comprehensive authentication system with multiple providers.
|
||||
useAlternateBuffer,
|
||||
config: {
|
||||
getTargetDir: () => mockTargetDir,
|
||||
getPlanDirectory: () => mockPlansDir,
|
||||
getIdeMode: () => false,
|
||||
isTrustedFolder: () => true,
|
||||
storage: {
|
||||
|
||||
@@ -65,7 +65,7 @@ function usePlanContent(planPath: string, config: Config): PlanContentState {
|
||||
try {
|
||||
const pathError = await validatePlanPath(
|
||||
planPath,
|
||||
config.storage.getProjectTempPlansDir(),
|
||||
config.getPlanDirectory(),
|
||||
config.getTargetDir(),
|
||||
);
|
||||
if (ignore) return;
|
||||
@@ -83,7 +83,7 @@ function usePlanContent(planPath: string, config: Config): PlanContentState {
|
||||
|
||||
const result = await processSingleFileContent(
|
||||
planPath,
|
||||
config.storage.getProjectTempPlansDir(),
|
||||
config.getPlanDirectory(),
|
||||
config.getFileSystemService(),
|
||||
);
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ describe('ToolConfirmationQueue', () => {
|
||||
getModel: () => 'gemini-pro',
|
||||
getDebugMode: () => false,
|
||||
getTargetDir: () => '/mock/target/dir',
|
||||
getPlanDirectory: () => '/mock/temp/plans',
|
||||
getFileSystemService: () => ({
|
||||
readFile: vi.fn().mockResolvedValue('Plan content'),
|
||||
}),
|
||||
|
||||
@@ -43,6 +43,10 @@ import { DEFAULT_MODEL_CONFIGS } from './defaultModelConfigs.js';
|
||||
import { DEFAULT_GEMINI_MODEL } from './models.js';
|
||||
import { Storage } from './storage.js';
|
||||
|
||||
vi.mock('glob', () => ({
|
||||
glob: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
vi.mock('fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('fs')>();
|
||||
return {
|
||||
@@ -2510,6 +2514,25 @@ describe('Plans Directory Initialization', () => {
|
||||
expect(context.getDirectories()).toContain(plansDir);
|
||||
});
|
||||
|
||||
it('should create custom plans directory and add it to workspace context when plan.directory is provided', async () => {
|
||||
const customDir = 'custom-plans';
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
plan: true,
|
||||
planDirectory: customDir,
|
||||
});
|
||||
|
||||
await config.initialize();
|
||||
|
||||
const expectedDir = path.resolve(baseParams.targetDir, customDir);
|
||||
expect(fs.promises.mkdir).toHaveBeenCalledWith(expectedDir, {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
const context = config.getWorkspaceContext();
|
||||
expect(context.getDirectories()).toContain(expectedDir);
|
||||
});
|
||||
|
||||
it('should NOT create plans directory or add it to workspace context when plan is disabled', async () => {
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
@@ -2628,3 +2651,55 @@ describe('syncPlanModeTools', () => {
|
||||
expect(setToolsSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Plan Directory', () => {
|
||||
const baseParams: ConfigParameters = {
|
||||
sessionId: 'test',
|
||||
targetDir: '/tmp/project',
|
||||
debugMode: false,
|
||||
model: 'test-model',
|
||||
cwd: '/tmp/project',
|
||||
};
|
||||
|
||||
it('should return custom plan directory when provided', () => {
|
||||
const customDir = 'custom-plans';
|
||||
const params: ConfigParameters = {
|
||||
...baseParams,
|
||||
planDirectory: customDir,
|
||||
};
|
||||
const config = new Config(params);
|
||||
expect(config.getPlanDirectory()).toBe(
|
||||
path.resolve(baseParams.targetDir, customDir),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return default plans directory when no custom directory is provided', () => {
|
||||
const config = new Config(baseParams);
|
||||
// storage.getProjectTempPlansDir() is tested in storage.test.ts, here we just check it matches
|
||||
expect(config.getPlanDirectory()).toBe(
|
||||
config.storage.getProjectTempPlansDir(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should support absolute paths for custom plans directory within workspace', () => {
|
||||
const absolutePath = path.resolve(baseParams.targetDir, 'plans');
|
||||
const params: ConfigParameters = {
|
||||
...baseParams,
|
||||
planDirectory: absolutePath,
|
||||
};
|
||||
const config = new Config(params);
|
||||
expect(config.getPlanDirectory()).toBe(absolutePath);
|
||||
});
|
||||
|
||||
it('should fallback to default plans directory when configured path is outside workspace', () => {
|
||||
const outsidePath = '/outside/workspace/plans';
|
||||
const params: ConfigParameters = {
|
||||
...baseParams,
|
||||
planDirectory: outsidePath,
|
||||
};
|
||||
const config = new Config(params);
|
||||
expect(config.getPlanDirectory()).toBe(
|
||||
config.storage.getProjectTempPlansDir(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -492,6 +492,7 @@ export interface ConfigParameters {
|
||||
toolOutputMasking?: Partial<ToolOutputMaskingConfig>;
|
||||
disableLLMCorrection?: boolean;
|
||||
plan?: boolean;
|
||||
planDirectory?: string;
|
||||
modelSteering?: boolean;
|
||||
onModelChange?: (model: string) => void;
|
||||
mcpEnabled?: boolean;
|
||||
@@ -685,6 +686,7 @@ export class Config {
|
||||
private readonly experimentalJitContext: boolean;
|
||||
private readonly disableLLMCorrection: boolean;
|
||||
private readonly planEnabled: boolean;
|
||||
private readonly planDirectory?: string;
|
||||
private readonly modelSteering: boolean;
|
||||
private contextManager?: ContextManager;
|
||||
private terminalBackground: string | undefined = undefined;
|
||||
@@ -774,6 +776,7 @@ export class Config {
|
||||
this.agents = params.agents ?? {};
|
||||
this.disableLLMCorrection = params.disableLLMCorrection ?? true;
|
||||
this.planEnabled = params.plan ?? false;
|
||||
this.planDirectory = params.planDirectory;
|
||||
this.enableEventDrivenScheduler = params.enableEventDrivenScheduler ?? true;
|
||||
this.skillsSupport = params.skillsSupport ?? true;
|
||||
this.disabledSkills = params.disabledSkills ?? [];
|
||||
@@ -963,7 +966,7 @@ export class Config {
|
||||
|
||||
// Add plans directory to workspace context for plan file storage
|
||||
if (this.planEnabled) {
|
||||
const plansDir = this.storage.getProjectTempPlansDir();
|
||||
const plansDir = this.getPlanDirectory();
|
||||
await fs.promises.mkdir(plansDir, { recursive: true });
|
||||
this.workspaceContext.addDirectory(plansDir);
|
||||
}
|
||||
@@ -2042,6 +2045,22 @@ export class Config {
|
||||
return this.planEnabled;
|
||||
}
|
||||
|
||||
getPlanDirectory(): string {
|
||||
if (this.planDirectory) {
|
||||
const resolvedPath = path.resolve(
|
||||
this.getTargetDir(),
|
||||
this.planDirectory,
|
||||
);
|
||||
if (isSubpath(this.getTargetDir(), resolvedPath)) {
|
||||
return resolvedPath;
|
||||
}
|
||||
debugLogger.warn(
|
||||
`Configured plan directory '${resolvedPath}' is outside the project root. Falling back to default temporary directory.`,
|
||||
);
|
||||
}
|
||||
return this.storage.getProjectTempPlansDir();
|
||||
}
|
||||
|
||||
getApprovedPlanPath(): string | undefined {
|
||||
return this.approvedPlanPath;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ describe('EnterPlanModeTool', () => {
|
||||
|
||||
mockConfig = {
|
||||
setApprovalMode: vi.fn(),
|
||||
getPlanDirectory: vi.fn().mockReturnValue('/mock/plans/dir'),
|
||||
validatePathAccess: vi.fn().mockReturnValue(null),
|
||||
storage: {
|
||||
getProjectTempPlansDir: vi.fn().mockReturnValue('/mock/plans/dir'),
|
||||
} as unknown as Config['storage'],
|
||||
@@ -60,7 +62,7 @@ describe('EnterPlanModeTool', () => {
|
||||
expect(result.title).toBe('Enter Plan Mode');
|
||||
if (result.type === 'info') {
|
||||
expect(result.prompt).toBe(
|
||||
'This will restrict the agent to read-only tools to allow for safe planning.',
|
||||
'This will switch to Plan Mode. The agent will be primarily restricted to read-only tools, but will have write access to its designated plans directory: /mock/plans/dir',
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -101,7 +103,7 @@ describe('EnterPlanModeTool', () => {
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
it('should set approval mode to PLAN and return message', async () => {
|
||||
it('should set approval mode to PLAN', async () => {
|
||||
const invocation = tool.build({});
|
||||
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
@@ -113,6 +115,21 @@ describe('EnterPlanModeTool', () => {
|
||||
expect(result.returnDisplay).toBe('Switching to Plan mode');
|
||||
});
|
||||
|
||||
it('should throw error if plan directory validation fails', async () => {
|
||||
const invocation = tool.build({});
|
||||
mockConfig.validatePathAccess = vi
|
||||
.fn()
|
||||
.mockReturnValue('Path outside workspace');
|
||||
|
||||
await expect(
|
||||
invocation.execute(new AbortController().signal),
|
||||
).rejects.toThrow(
|
||||
'Invalid plan directory configuration: Path outside workspace',
|
||||
);
|
||||
|
||||
expect(mockConfig.setApprovalMode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should include optional reason in output display but not in llmContent', async () => {
|
||||
const reason = 'Design new database schema';
|
||||
const invocation = tool.build({ reason });
|
||||
|
||||
@@ -97,12 +97,13 @@ export class EnterPlanModeInvocation extends BaseToolInvocation<
|
||||
);
|
||||
}
|
||||
|
||||
const plansDir = this.config.getPlanDirectory();
|
||||
|
||||
// ASK_USER
|
||||
return {
|
||||
type: 'info',
|
||||
title: 'Enter Plan Mode',
|
||||
prompt:
|
||||
'This will restrict the agent to read-only tools to allow for safe planning.',
|
||||
prompt: `This will switch to Plan Mode. The agent will be primarily restricted to read-only tools, but will have write access to its designated plans directory: ${plansDir}`,
|
||||
onConfirm: async (outcome: ToolConfirmationOutcome) => {
|
||||
this.confirmationOutcome = outcome;
|
||||
await this.publishPolicyUpdate(outcome);
|
||||
@@ -118,6 +119,13 @@ export class EnterPlanModeInvocation extends BaseToolInvocation<
|
||||
};
|
||||
}
|
||||
|
||||
const plansDir = this.config.getPlanDirectory();
|
||||
// Validate that the plan directory is safe (within workspace)
|
||||
const pathError = this.config.validatePathAccess(plansDir, 'write');
|
||||
if (pathError) {
|
||||
throw new Error(`Invalid plan directory configuration: ${pathError}`);
|
||||
}
|
||||
|
||||
this.config.setApprovalMode(ApprovalMode.PLAN);
|
||||
|
||||
return {
|
||||
|
||||
@@ -42,6 +42,7 @@ describe('ExitPlanModeTool', () => {
|
||||
|
||||
mockConfig = {
|
||||
getTargetDir: vi.fn().mockReturnValue(tempRootDir),
|
||||
getPlanDirectory: vi.fn().mockReturnValue(mockPlansDir),
|
||||
setApprovalMode: vi.fn(),
|
||||
setApprovedPlanPath: vi.fn(),
|
||||
storage: {
|
||||
|
||||
@@ -57,7 +57,7 @@ export class ExitPlanModeTool extends BaseDeclarativeTool<
|
||||
private config: Config,
|
||||
messageBus: MessageBus,
|
||||
) {
|
||||
const plansDir = config.storage.getProjectTempPlansDir();
|
||||
const plansDir = config.getPlanDirectory();
|
||||
const definition = getExitPlanModeDefinition(plansDir);
|
||||
super(
|
||||
EXIT_PLAN_MODE_TOOL_NAME,
|
||||
@@ -78,9 +78,7 @@ export class ExitPlanModeTool extends BaseDeclarativeTool<
|
||||
|
||||
// Since validateToolParamValues is synchronous, we use a basic synchronous check
|
||||
// for path traversal safety. High-level async validation is deferred to shouldConfirmExecute.
|
||||
const plansDir = resolveToRealPath(
|
||||
this.config.storage.getProjectTempPlansDir(),
|
||||
);
|
||||
const plansDir = resolveToRealPath(this.config.getPlanDirectory());
|
||||
const resolvedPath = path.resolve(
|
||||
this.config.getTargetDir(),
|
||||
params.plan_path,
|
||||
@@ -111,7 +109,7 @@ export class ExitPlanModeTool extends BaseDeclarativeTool<
|
||||
}
|
||||
|
||||
override getSchema(modelId?: string) {
|
||||
const plansDir = this.config.storage.getProjectTempPlansDir();
|
||||
const plansDir = this.config.getPlanDirectory();
|
||||
return resolveToolDeclaration(getExitPlanModeDefinition(plansDir), modelId);
|
||||
}
|
||||
}
|
||||
@@ -141,7 +139,7 @@ export class ExitPlanModeInvocation extends BaseToolInvocation<
|
||||
|
||||
const pathError = await validatePlanPath(
|
||||
this.params.plan_path,
|
||||
this.config.storage.getProjectTempPlansDir(),
|
||||
this.config.getPlanDirectory(),
|
||||
this.config.getTargetDir(),
|
||||
);
|
||||
if (pathError) {
|
||||
|
||||
@@ -105,6 +105,13 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"plan": {
|
||||
"title": "Plan Configuration",
|
||||
"description": "Configuration for planning features.",
|
||||
"markdownDescription": "Configuration for planning features.\n\n- Category: `General`\n- Requires restart: `no`\n- Default: `{}`",
|
||||
"default": {},
|
||||
"$ref": "#/$defs/PlanConfig"
|
||||
},
|
||||
"enablePromptCompletion": {
|
||||
"title": "Enable Prompt Completion",
|
||||
"description": "Enable AI-powered prompt completion suggestions while typing.",
|
||||
@@ -1971,6 +1978,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"PlanConfig": {
|
||||
"type": "object",
|
||||
"description": "Planning features configuration.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"directory": {
|
||||
"type": "string",
|
||||
"description": "Custom directory for implementation plans."
|
||||
}
|
||||
}
|
||||
},
|
||||
"TelemetrySettings": {
|
||||
"type": "object",
|
||||
"description": "Telemetry configuration for Gemini CLI.",
|
||||
|
||||
Reference in New Issue
Block a user