mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-25 20:44:46 -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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user