feat(core): implement feature lifecycle management (Alpha, Beta, GA)

This commit is contained in:
Jerop Kipruto
2026-02-16 23:48:45 -05:00
parent 6aa6630137
commit 04f22a51b1
23 changed files with 1678 additions and 60 deletions
+3
View File
@@ -4,6 +4,9 @@
"extensionReloading": true, "extensionReloading": true,
"modelSteering": true "modelSteering": true
}, },
"features": {
"allAlpha": true
},
"general": { "general": {
"devtools": true "devtools": true
} }
+193
View File
@@ -0,0 +1,193 @@
# Feature Lifecycle
Gemini CLI uses a **Feature Lifecycle** management system to handle the
introduction, maturation, and deprecation of new and optional features.
- [Feature Lifecycle](#feature-lifecycle)
- [Feature Stages](#feature-stages)
- [Configuration](#configuration)
- [Precedence and Reconciliation](#precedence-and-reconciliation)
- [Available Features](#available-features)
- [Managing Feature Lifecycle](#managing-feature-lifecycle)
- [Adding a Feature](#adding-a-feature)
- [Promoting a Feature](#promoting-a-feature)
- [Deprecating a Feature](#deprecating-a-feature)
- [Removing a Feature](#removing-a-feature)
- [Relevant Documentation](#relevant-documentation)
## Feature Stages
Features progress through the following stages:
| Stage | Default | UI Badge | Description |
| -------------- | -------- | -------------- | ------------------------------------------------------------------------------------------- |
| **ALPHA** | Disabled | `[ALPHA]` | Early-access features. May be unstable, change significantly, or be removed without notice. |
| **BETA** | Enabled | `[BETA]` | Features that are well-tested and considered stable. Can be disabled if issues arise. |
| **GA** | Enabled | - | Stable features that are part of the core product. Cannot be disabled. |
| **DEPRECATED** | Disabled | `[DEPRECATED]` | Features scheduled for removal. Using them triggers a warning. |
## Configuration
The feature lifecycle can be configured in several ways:
1. **`settings.json`**: Use the `features` object to toggle specific features
or entire stages.
- `features.allAlpha`: Enable/disable all Alpha features.
- `features.allBeta`: Enable/disable all Beta features.
- `features.<featureName>`: Toggle an individual feature.
2. **CLI Flag**: Use `--feature-gates="feat1=true,feat2=false"` for runtime
overrides.
3. **Environment Variable**: Set
`GEMINI_FEATURE_GATES="feat1=true,feat2=false"`.
The stability of each feature is visually indicated in the
[`/settings` UI](/docs/cli/settings.md) with colored badges. **GA** features are
considerd stable and look identical to standard settings.
### Precedence and Reconciliation
When determining if a feature is enabled, the system follows this order of
precedence (highest priority first):
1. **Global Lock**: Features in the **GA** stage are locked to `true` and cannot
be disabled.
2. **CLI Flags & Environment Variables**: Runtime overrides (`--feature-gates`
or `GEMINI_FEATURE_GATES`) override persistent settings.
3. **Individual Toggle**: Specific feature toggles in `settings.json` (e.g.,
`"features": { "plan": true }`).
4. **Meta Toggles**: Stage-wide toggles in `settings.json` (`allAlpha` or
`allBeta`). For example, if `allAlpha` is `true`, all Alpha features are
enabled unless specifically disabled by an individual toggle.
5. **Stage Default**: The inherent default for the feature's current stage
(Alpha: Disabled, Beta/GA: Enabled).
For more details on persistent configuration, see the [Configuration guide].
## Available Features
<!-- FEATURES-AUTOGEN:START -->
| Feature | Stage | Default | Since | Description |
| --------------------- | ----- | -------- | ------ | ----------------------------------------------------------- |
| `enableAgents` | ALPHA | Disabled | 0.30.0 | Enable local and remote subagents. |
| `extensionConfig` | BETA | Enabled | 0.30.0 | Enable requesting and fetching of extension settings. |
| `extensionManagement` | BETA | Enabled | 0.30.0 | Enable extension management features. |
| `extensionRegistry` | ALPHA | Disabled | 0.30.0 | Enable extension registry explore UI. |
| `extensionReloading` | ALPHA | Disabled | 0.30.0 | Enables extension loading/unloading within the CLI session. |
| `jitContext` | ALPHA | Disabled | 0.30.0 | Enable Just-In-Time (JIT) context loading. |
| `plan` | ALPHA | Disabled | 0.30.0 | Enable planning features (Plan Mode and tools). |
| `toolOutputMasking` | BETA | Enabled | 0.30.0 | Enables tool output masking to save tokens. |
| `useOSC52Paste` | ALPHA | Disabled | 0.30.0 | Use OSC 52 sequence for pasting. |
| `zedIntegration` | ALPHA | Disabled | 0.30.0 | Enable Zed integration. |
<!-- FEATURES-AUTOGEN:END -->
## Managing Feature Lifecycle
Maintaining a feature involves promoting it through stages or eventually
deprecating and removing it.
### Adding a Feature
To add a new feature under lifecycle management:
1. **Define the Feature**: Add a new entry to [`FeatureDefinitions`] in
[`features.ts`].
```typescript
export const FeatureDefinitions: Record<string, FeatureSpec[]> = {
// ... existing features
myNewFeature: [
{
lockToDefault: false,
preRelease: FeatureStage.Alpha,
since: '0.31.0',
description: 'Description of my new feature.',
},
],
};
```
_Note: The `default` field is optional. If omitted, it defaults to `false`
for Alpha/Deprecated and `true` for Beta/GA._
2. **Expose in Settings**: Add the feature to the `features` object in
[`settingsSchema.ts`]. This ensures it appears in the `/settings` UI and is
validated.
```typescript
features: {
// ...
properties: {
// ...
myNewFeature: {
type: 'boolean',
label: 'My New Feature',
category: 'Features',
requiresRestart: true, // or false
description: 'Description of my new feature.',
showInDialog: true,
},
},
},
```
3. **Use the Feature**: In your code, check if the feature is enabled using the
`Config` object.
```typescript
if (this.config.isFeatureEnabled('myNewFeature')) {
// Feature logic
}
```
### Promoting a Feature
When a feature is ready for the next stage:
1. **Update [`features.ts`]**: Add a new `FeatureSpec` to the feature's array.
- **To BETA**: Set `preRelease: FeatureStage.Beta` (Defaults to `true`).
- **To GA**: Set `preRelease: FeatureStage.GA` (Defaults to `true` and
locked).
- Update the `since` version.
2. **Update [`settingsSchema.ts`]**: Update the `label` and `description` if
necessary.
3. **GA Cleanup**: Once a feature is GA and no longer optional, remove the
feature gate check from the code and make it a core part of the logic.
### Deprecating a Feature
This stage is for **Beta** and **GA** features scheduled for removal.
1. **Update [`features.ts`]**: Add a new `FeatureSpec` with
`preRelease: FeatureStage.Deprecated`.
- Optionally set `default: true` if it should remain enabled during
deprecation (it defaults to `false`).
- Optionally set an `until` version to indicate when it will be removed.
2. **Update [`settingsSchema.ts`]**: Update the description to notify users of
the deprecation and suggest alternatives.
### Removing a Feature
> **Alpha** features can be removed without formal deprecation. **Beta** and
> **GA** features should typically go through a
> [deprecation period](#deprecating-a-feature) first.
To completely remove a feature:
1. **Cleanup Code**: Remove all logic and tests associated with the feature.
2. **Update [`features.ts`]**: Remove the feature from [`FeatureDefinitions`].
3. **Update [`settingsSchema.ts`]**: Remove the feature from the `features`
object.
4. **Legacy Settings**: If the feature had a legacy `experimental` flag, ensure
its migration logic is cleaned up in [`config.ts`].
## Relevant Documentation
- [Settings Reference]
- [Configuration Layers]
[Configuration guide]: /docs/get-started/configuration.md
[Settings Reference]: /docs/cli/settings.md#features
[Configuration Layers]: /docs/get-started/configuration.md#configuration-layers
[`FeatureDefinitions`]: /packages/core/src/config/features.ts
[`features.ts`]: /packages/core/src/config/features.ts
[`settingsSchema.ts`]: /packages/cli/src/config/settingsSchema.ts
[`config.ts`]: /packages/core/src/config/config.ts
+17
View File
@@ -162,4 +162,21 @@ they appear in the UI.
| Enable Hooks | `hooksConfig.enabled` | Canonical toggle for the hooks system. When disabled, no hooks will be executed. | `true` | | Enable Hooks | `hooksConfig.enabled` | Canonical toggle for the hooks system. When disabled, no hooks will be executed. | `true` |
| Hook Notifications | `hooksConfig.notifications` | Show visual indicators when hooks are executing. | `true` | | Hook Notifications | `hooksConfig.notifications` | Show visual indicators when hooks are executing. | `true` |
### Features
| UI Label | Setting | Description | Default | Stage |
| ----------------------------- | ------------------------------ | ----------------------------------------------------------- | ------- | ------- |
| Enable all Alpha features | `features.allAlpha` | Enable all Alpha features by default. | `false` | - |
| Enable all Beta features | `features.allBeta` | Enable all Beta features by default. | `true` | - |
| Tool Output Masking | `features.toolOutputMasking` | Enables tool output masking to save tokens. | `true` | `BETA` |
| Enable Agents | `features.enableAgents` | Enable local and remote subagents. | `false` | `ALPHA` |
| Extension Management | `features.extensionManagement` | Enable extension management features. | `true` | `BETA` |
| Extension Configuration | `features.extensionConfig` | Enable requesting and fetching of extension settings. | `true` | `BETA` |
| Extension Registry Explore UI | `features.extensionRegistry` | Enable extension registry explore UI. | `false` | `ALPHA` |
| Extension Reloading | `features.extensionReloading` | Enables extension loading/unloading within the CLI session. | `false` | `ALPHA` |
| JIT Context Loading | `features.jitContext` | Enable Just-In-Time (JIT) context loading. | `false` | `ALPHA` |
| Use OSC 52 Paste | `features.useOSC52Paste` | Use OSC 52 sequence for pasting. | `false` | `ALPHA` |
| Plan Mode | `features.plan` | Enable planning features (Plan Mode and tools). | `false` | `ALPHA` |
| Zed Integration | `features.zedIntegration` | Enable Zed integration. | `false` | `ALPHA` |
<!-- SETTINGS-AUTOGEN:END --> <!-- SETTINGS-AUTOGEN:END -->
+91 -3
View File
@@ -83,6 +83,16 @@ contain other project-specific files related to Gemini CLI's operation, such as:
Settings are organized into categories. All settings should be placed within Settings are organized into categories. All settings should be placed within
their corresponding top-level category object in your `settings.json` file. their corresponding top-level category object in your `settings.json` file.
#### Feature Lifecycle
Gemini CLI uses a feature lifecycle management system to manage experimental and
optional features. Features are categorized by their stability stage (`ALPHA`,
`BETA`, `GA`, `DEPRECATED`).
For a detailed explanation of feature stages and a list of all available
features, see the
[Feature Lifecycle documentation](/docs/cli/feature-lifecycle.md).
<!-- SETTINGS-AUTOGEN:START --> <!-- SETTINGS-AUTOGEN:START -->
#### `policyPaths` #### `policyPaths`
@@ -1056,7 +1066,6 @@ their corresponding top-level category object in your `settings.json` file.
`gemma3-1b-gpu-custom`. `gemma3-1b-gpu-custom`.
- **Default:** `"gemma3-1b-gpu-custom"` - **Default:** `"gemma3-1b-gpu-custom"`
- **Requires restart:** Yes - **Requires restart:** Yes
#### `skills` #### `skills`
- **`skills.enabled`** (boolean): - **`skills.enabled`** (boolean):
@@ -1165,6 +1174,77 @@ their corresponding top-level category object in your `settings.json` file.
- **`admin.skills.enabled`** (boolean): - **`admin.skills.enabled`** (boolean):
- **Description:** If false, disallows agent skills from being used. - **Description:** If false, disallows agent skills from being used.
- **Default:** `true` - **Default:** `true`
#### `features`
- **`features.allAlpha`** (boolean):
- **Description:** Enable all Alpha features by default.
- **Default:** `false`
- **Requires restart:** Yes
- **`features.allBeta`** (boolean):
- **Description:** Enable all Beta features by default.
- **Default:** `true`
- **Requires restart:** Yes
- **`features.toolOutputMasking`** (boolean):
- **Description:** Enables tool output masking to save tokens.
- **Default:** `true`
- **Stage:** BETA
- **Requires restart:** Yes
- **`features.enableAgents`** (boolean):
- **Description:** Enable local and remote subagents.
- **Default:** `false`
- **Stage:** ALPHA
- **Requires restart:** Yes
- **`features.extensionManagement`** (boolean):
- **Description:** Enable extension management features.
- **Default:** `true`
- **Stage:** BETA
- **Requires restart:** Yes
- **`features.extensionConfig`** (boolean):
- **Description:** Enable requesting and fetching of extension settings.
- **Default:** `true`
- **Stage:** BETA
- **Requires restart:** Yes
- **`features.extensionRegistry`** (boolean):
- **Description:** Enable extension registry explore UI.
- **Default:** `false`
- **Stage:** ALPHA
- **Requires restart:** Yes
- **`features.extensionReloading`** (boolean):
- **Description:** Enables extension loading/unloading within the CLI session.
- **Default:** `false`
- **Stage:** ALPHA
- **Requires restart:** Yes
- **`features.jitContext`** (boolean):
- **Description:** Enable Just-In-Time (JIT) context loading.
- **Default:** `false`
- **Stage:** ALPHA
- **Requires restart:** Yes
- **`features.useOSC52Paste`** (boolean):
- **Description:** Use OSC 52 sequence for pasting.
- **Default:** `false`
- **Stage:** ALPHA
- **`features.plan`** (boolean):
- **Description:** Enable planning features (Plan Mode and tools).
- **Default:** `false`
- **Stage:** ALPHA
- **Requires restart:** Yes
- **`features.zedIntegration`** (boolean):
- **Description:** Enable Zed integration.
- **Default:** `false`
- **Stage:** ALPHA
- **Requires restart:** Yes
<!-- SETTINGS-AUTOGEN:END --> <!-- SETTINGS-AUTOGEN:END -->
#### `mcpServers` #### `mcpServers`
@@ -1350,6 +1430,10 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file.
is useful when running Gemini CLI in a standalone terminal while still is useful when running Gemini CLI in a standalone terminal while still
wanting to associate it with a specific IDE instance. wanting to associate it with a specific IDE instance.
- Overrides the automatic IDE detection logic. - Overrides the automatic IDE detection logic.
- **`GEMINI_FEATURE_GATES`**:
- Specifies a comma-separated list of feature key-value pairs to override the
default feature states.
- Example: `export GEMINI_FEATURE_GATES="plan=true,enableAgents=false"`
- **`GEMINI_CLI_HOME`**: - **`GEMINI_CLI_HOME`**:
- Specifies the root directory for Gemini CLI's user-level configuration and - Specifies the root directory for Gemini CLI's user-level configuration and
storage. storage.
@@ -1551,13 +1635,17 @@ for that specific session.
- `auto_edit`: Automatically approve edit tools (replace, write_file) while - `auto_edit`: Automatically approve edit tools (replace, write_file) while
prompting for others prompting for others
- `yolo`: Automatically approve all tool calls (equivalent to `--yolo`) - `yolo`: Automatically approve all tool calls (equivalent to `--yolo`)
- `plan`: Read-only mode for tool calls (requires experimental planning to - `plan`: Read-only mode for tool calls (requires planning feature to be
be enabled). enabled).
> **Note:** This mode is currently under development and not yet fully > **Note:** This mode is currently under development and not yet fully
> functional. > functional.
- Cannot be used together with `--yolo`. Use `--approval-mode=yolo` instead of - Cannot be used together with `--yolo`. Use `--approval-mode=yolo` instead of
`--yolo` for the new unified approach. `--yolo` for the new unified approach.
- Example: `gemini --approval-mode auto_edit` - Example: `gemini --approval-mode auto_edit`
- **`--feature-gates <key1=val1,key2=val2,...>`**:
- A comma-separated list of feature key-value pairs to override the default
feature states for this session.
- Example: `gemini --feature-gates "plan=true,enableAgents=false"`
- **`--allowed-tools <tool1,tool2,...>`**: - **`--allowed-tools <tool1,tool2,...>`**:
- A comma-separated list of tool names that will bypass the confirmation - A comma-separated list of tool names that will bypass the confirmation
dialog. dialog.
+8
View File
@@ -161,6 +161,14 @@
], ],
"*.{json,md}": [ "*.{json,md}": [
"prettier --write" "prettier --write"
],
"packages/cli/src/config/settingsSchema.ts": [
"npm run docs:settings",
"git add schemas/settings.schema.json docs/get-started/configuration.md docs/cli/settings.md docs/cli/feature-lifecycle.md"
],
"packages/cli/src/config/keyBindings.ts": [
"npm run docs:keybindings",
"git add docs/cli/keyboard-shortcuts.md"
] ]
} }
} }
@@ -12,7 +12,7 @@ import {
configureSpecificSetting, configureSpecificSetting,
getExtensionManager, getExtensionManager,
} from './utils.js'; } from './utils.js';
import { loadSettings } from '../../config/settings.js'; import { loadSettings, isFeatureEnabled } from '../../config/settings.js';
import { coreEvents, debugLogger } from '@google/gemini-cli-core'; import { coreEvents, debugLogger } from '@google/gemini-cli-core';
import { exitCli } from '../utils.js'; import { exitCli } from '../utils.js';
@@ -45,10 +45,10 @@ export const configureCommand: CommandModule<object, ConfigureArgs> = {
const { name, setting, scope } = args; const { name, setting, scope } = args;
const settings = loadSettings(process.cwd()).merged; const settings = loadSettings(process.cwd()).merged;
if (!(settings.experimental?.extensionConfig ?? true)) { if (!isFeatureEnabled(settings, 'extensionConfig')) {
coreEvents.emitFeedback( coreEvents.emitFeedback(
'error', 'error',
'Extension configuration is currently disabled. Enable it by setting "experimental.extensionConfig" to true.', 'Extension configuration is currently disabled. Enable it by setting "features.extensionConfig" to true.',
); );
await exitCli(); await exitCli();
return; return;
+56
View File
@@ -2647,6 +2647,62 @@ describe('loadCliConfig approval mode', () => {
expect(plansDir).toContain('.custom-plans'); expect(plansDir).toContain('.custom-plans');
}); });
describe('Feature Gates', () => {
it('should parse --feature-gates CLI flag', async () => {
process.argv = [
'node',
'script.js',
'--feature-gates',
'plan=true,enableAgents=false',
];
const argv = await parseArguments(createTestMergedSettings());
expect(argv.featureGates).toBe('plan=true,enableAgents=false');
});
it('should respect "features" setting in loadCliConfig', async () => {
process.argv = ['node', 'script.js'];
const settings = createTestMergedSettings({
features: { plan: true },
});
const argv = await parseArguments(settings);
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.isFeatureEnabled('plan')).toBe(true);
});
it('should prioritize --feature-gates flag over settings', async () => {
process.argv = ['node', 'script.js', '--feature-gates', 'plan=true'];
const settings = createTestMergedSettings({
features: { plan: false },
});
const argv = await parseArguments(settings);
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.isFeatureEnabled('plan')).toBe(true);
});
it('should allow plan approval mode when features.plan is enabled', async () => {
process.argv = ['node', 'script.js', '--approval-mode', 'plan'];
const settings = createTestMergedSettings({
features: { plan: true },
});
const argv = await parseArguments(settings);
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.PLAN);
});
it('should throw error when --approval-mode=plan is used but features.plan is disabled', async () => {
process.argv = ['node', 'script.js', '--approval-mode', 'plan'];
const settings = createTestMergedSettings({
features: { plan: false },
});
const argv = await parseArguments(settings);
await expect(
loadCliConfig(settings, 'test-session', argv),
).rejects.toThrow(
'Approval mode "plan" is only available when experimental.plan is enabled.',
);
});
});
// --- Untrusted Folder Scenarios --- // --- Untrusted Folder Scenarios ---
describe('when folder is NOT trusted', () => { describe('when folder is NOT trusted', () => {
beforeEach(() => { beforeEach(() => {
+24 -9
View File
@@ -44,6 +44,7 @@ import {
type MergedSettings, type MergedSettings,
saveModelChange, saveModelChange,
loadSettings, loadSettings,
isFeatureEnabled,
} from './settings.js'; } from './settings.js';
import { loadSandboxConfig } from './sandboxConfig.js'; import { loadSandboxConfig } from './sandboxConfig.js';
@@ -78,6 +79,7 @@ export interface CliArgs {
allowedTools: string[] | undefined; allowedTools: string[] | undefined;
acp?: boolean; acp?: boolean;
experimentalAcp?: boolean; experimentalAcp?: boolean;
featureGates?: string | undefined;
extensions: string[] | undefined; extensions: string[] | undefined;
listExtensions: boolean | undefined; listExtensions: boolean | undefined;
resume: string | typeof RESUME_LATEST | undefined; resume: string | typeof RESUME_LATEST | undefined;
@@ -182,6 +184,12 @@ export async function parseArguments(
description: description:
'Starts the agent in ACP mode (deprecated, use --acp instead)', 'Starts the agent in ACP mode (deprecated, use --acp instead)',
}) })
.option('feature-gates', {
type: 'string',
nargs: 1,
description:
'Comma-separated list of feature key-value pairs (e.g. "Foo=true,Bar=false")',
})
.option('allowed-mcp-server-names', { .option('allowed-mcp-server-names', {
type: 'array', type: 'array',
string: true, string: true,
@@ -328,7 +336,7 @@ export async function parseArguments(
return true; return true;
}); });
if (settings.experimental?.extensionManagement) { if (isFeatureEnabled(settings, 'extensionManagement')) {
yargsInstance.command(extensionsCommand); yargsInstance.command(extensionsCommand);
} }
@@ -486,7 +494,7 @@ export async function loadCliConfig(
.getExtensions() .getExtensions()
.find((ext) => ext.isActive && ext.plan?.directory)?.plan; .find((ext) => ext.isActive && ext.plan?.directory)?.plan;
const experimentalJitContext = settings.experimental?.jitContext ?? false; const experimentalJitContext = isFeatureEnabled(settings, 'jitContext');
let memoryContent: string | HierarchicalMemory = ''; let memoryContent: string | HierarchicalMemory = '';
let fileCount = 0; let fileCount = 0;
@@ -532,7 +540,7 @@ export async function loadCliConfig(
approvalMode = ApprovalMode.AUTO_EDIT; approvalMode = ApprovalMode.AUTO_EDIT;
break; break;
case 'plan': case 'plan':
if (!(settings.experimental?.plan ?? false)) { if (!isFeatureEnabled(settings, 'plan')) {
debugLogger.warn( debugLogger.warn(
'Approval mode "plan" is only available when experimental.plan is enabled. Falling back to "default".', 'Approval mode "plan" is only available when experimental.plan is enabled. Falling back to "default".',
); );
@@ -759,15 +767,17 @@ export async function loadCliConfig(
bugCommand: settings.advanced?.bugCommand, bugCommand: settings.advanced?.bugCommand,
model: resolvedModel, model: resolvedModel,
maxSessionTurns: settings.model?.maxSessionTurns, maxSessionTurns: settings.model?.maxSessionTurns,
experimentalZedIntegration: argv.experimentalAcp || false,
features: settings.features,
featureGates: argv.featureGates,
listExtensions: argv.listExtensions || false, listExtensions: argv.listExtensions || false,
listSessions: argv.listSessions || false, listSessions: argv.listSessions || false,
deleteSession: argv.deleteSession, deleteSession: argv.deleteSession,
enabledExtensions: argv.extensions, enabledExtensions: argv.extensions,
extensionLoader: extensionManager, extensionLoader: extensionManager,
enableExtensionReloading: settings.experimental?.extensionReloading, enableExtensionReloading: isFeatureEnabled(settings, 'extensionReloading'),
enableAgents: settings.experimental?.enableAgents, enableAgents: isFeatureEnabled(settings, 'enableAgents'),
plan: settings.experimental?.plan, plan: isFeatureEnabled(settings, 'plan'),
tracker: settings.experimental?.taskTracker, tracker: settings.experimental?.taskTracker,
directWebFetch: settings.experimental?.directWebFetch, directWebFetch: settings.experimental?.directWebFetch,
planSettings: settings.general?.plan?.directory planSettings: settings.general?.plan?.directory
@@ -776,9 +786,14 @@ export async function loadCliConfig(
enableEventDrivenScheduler: true, enableEventDrivenScheduler: true,
skillsSupport: settings.skills?.enabled ?? true, skillsSupport: settings.skills?.enabled ?? true,
disabledSkills: settings.skills?.disabled, disabledSkills: settings.skills?.disabled,
experimentalJitContext: settings.experimental?.jitContext, experimentalJitContext: isFeatureEnabled(settings, 'jitContext'),
modelSteering: settings.experimental?.modelSteering, modelSteering: settings.experimental?.modelSteering,
toolOutputMasking: settings.experimental?.toolOutputMasking, toolOutputMasking:
(settings.features as Record<string, unknown> | undefined)?.[
'toolOutputMasking'
] !== undefined
? { enabled: isFeatureEnabled(settings, 'toolOutputMasking') }
: settings.experimental?.toolOutputMasking,
noBrowser: !!process.env['NO_BROWSER'], noBrowser: !!process.env['NO_BROWSER'],
summarizeToolOutput: settings.model?.summarizeToolOutput, summarizeToolOutput: settings.model?.summarizeToolOutput,
ideMode, ideMode,
+18 -5
View File
@@ -9,7 +9,11 @@ import * as path from 'node:path';
import { stat } from 'node:fs/promises'; import { stat } from 'node:fs/promises';
import chalk from 'chalk'; import chalk from 'chalk';
import { ExtensionEnablementManager } from './extensions/extensionEnablement.js'; import { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
import { type MergedSettings, SettingScope } from './settings.js'; import {
type MergedSettings,
SettingScope,
isFeatureEnabled,
} from './settings.js';
import { createHash, randomUUID } from 'node:crypto'; import { createHash, randomUUID } from 'node:crypto';
import { loadInstallMetadata, type ExtensionConfig } from './extension.js'; import { loadInstallMetadata, type ExtensionConfig } from './extension.js';
import { import {
@@ -323,7 +327,10 @@ Would you like to attempt to install via "git clone" instead?`,
} }
await fs.promises.mkdir(destinationPath, { recursive: true }); await fs.promises.mkdir(destinationPath, { recursive: true });
if (this.requestSetting && this.settings.experimental.extensionConfig) { if (
this.requestSetting &&
isFeatureEnabled(this.settings, 'extensionConfig')
) {
if (isUpdate) { if (isUpdate) {
await maybePromptForSettings( await maybePromptForSettings(
newExtensionConfig, newExtensionConfig,
@@ -341,7 +348,10 @@ Would you like to attempt to install via "git clone" instead?`,
} }
} }
const missingSettings = this.settings.experimental.extensionConfig const missingSettings = isFeatureEnabled(
this.settings,
'extensionConfig',
)
? await getMissingSettings( ? await getMissingSettings(
newExtensionConfig, newExtensionConfig,
extensionId, extensionId,
@@ -677,7 +687,7 @@ Would you like to attempt to install via "git clone" instead?`,
let userSettings: Record<string, string> = {}; let userSettings: Record<string, string> = {};
let workspaceSettings: Record<string, string> = {}; let workspaceSettings: Record<string, string> = {};
if (this.settings.experimental.extensionConfig) { if (isFeatureEnabled(this.settings, 'extensionConfig')) {
userSettings = await getScopedEnvContents( userSettings = await getScopedEnvContents(
config, config,
extensionId, extensionId,
@@ -697,7 +707,10 @@ Would you like to attempt to install via "git clone" instead?`,
config = resolveEnvVarsInObject(config, customEnv); config = resolveEnvVarsInObject(config, customEnv);
const resolvedSettings: ResolvedExtensionSetting[] = []; const resolvedSettings: ResolvedExtensionSetting[] = [];
if (config.settings && this.settings.experimental.extensionConfig) { if (
config.settings &&
isFeatureEnabled(this.settings, 'extensionConfig')
) {
for (const setting of config.settings) { for (const setting of config.settings) {
const value = customEnv[setting.envVar]; const value = customEnv[setting.envVar];
let scope: 'user' | 'workspace' | undefined; let scope: 'user' | 'workspace' | undefined;
+7 -1
View File
@@ -206,7 +206,13 @@ describe('extension tests', () => {
}); });
vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir); vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
const settings = loadSettings(tempWorkspaceDir).merged; const settings = loadSettings(tempWorkspaceDir).merged;
settings.experimental.extensionConfig = true; if (settings.features) {
(settings.features as Record<string, unknown>)['extensionConfig'] = true;
} else {
(settings as unknown as Record<string, unknown>)['features'] = {
extensionConfig: true,
};
}
extensionManager = new ExtensionManager({ extensionManager = new ExtensionManager({
workspaceDir: tempWorkspaceDir, workspaceDir: tempWorkspaceDir,
requestConsent: mockRequestConsent, requestConsent: mockRequestConsent,
+48
View File
@@ -76,6 +76,8 @@ import {
LoadedSettings, LoadedSettings,
sanitizeEnvVar, sanitizeEnvVar,
createTestMergedSettings, createTestMergedSettings,
isFeatureEnabled,
type MergedSettings,
} from './settings.js'; } from './settings.js';
import { import {
FatalConfigError, FatalConfigError,
@@ -3119,4 +3121,50 @@ describe('LoadedSettings Isolation and Serializability', () => {
}).toThrow(/Maximum call stack size exceeded/); }).toThrow(/Maximum call stack size exceeded/);
}); });
}); });
describe('isFeatureEnabled', () => {
it('should return true if feature is enabled in "features"', () => {
const settings = {
features: { plan: true },
} as unknown as Settings; // Casting for simplicity
expect(
isFeatureEnabled(settings as unknown as MergedSettings, 'plan'),
).toBe(true);
});
it('should return false if feature is disabled in "features"', () => {
const settings = {
features: { plan: false },
} as unknown as Settings;
expect(
isFeatureEnabled(settings as unknown as MergedSettings, 'plan'),
).toBe(false);
});
it('should fallback to "experimental" if feature is not in "features"', () => {
const settings = {
experimental: { plan: true },
} as unknown as Settings;
expect(
isFeatureEnabled(settings as unknown as MergedSettings, 'plan'),
).toBe(true);
});
it('should prioritize "features" over "experimental"', () => {
const settings = {
features: { plan: false },
experimental: { plan: true },
} as unknown as Settings;
expect(
isFeatureEnabled(settings as unknown as MergedSettings, 'plan'),
).toBe(false);
});
it('should return false if neither is set', () => {
const settings = {} as unknown as Settings;
expect(
isFeatureEnabled(settings as unknown as MergedSettings, 'plan'),
).toBe(false);
});
});
}); });
+53
View File
@@ -18,6 +18,8 @@ import {
coreEvents, coreEvents,
homedir, homedir,
type AdminControlsSettings, type AdminControlsSettings,
DefaultFeatureGate,
type FeatureGate,
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
import stripJsonComments from 'strip-json-comments'; import stripJsonComments from 'strip-json-comments';
import { DefaultLight } from '../ui/themes/builtin/light/default-light.js'; import { DefaultLight } from '../ui/themes/builtin/light/default-light.js';
@@ -43,6 +45,57 @@ export {
getSettingsSchema, getSettingsSchema,
}; };
const featureGateCache = new WeakMap<MergedSettings, FeatureGate>();
/**
* Returns true if the feature is enabled in the given settings.
* Checks "features" first, then falls back to "experimental".
*/
export function isFeatureEnabled(
settings: MergedSettings,
featureName: string,
): boolean {
let gate = featureGateCache.get(settings);
if (!gate) {
const mutableGate = DefaultFeatureGate.deepCopy();
const overrides: Record<string, boolean> = {};
// 1. Experimental (Low Priority)
const experimental = settings.experimental as Record<string, unknown>;
if (experimental) {
for (const [key, value] of Object.entries(experimental)) {
if (typeof value === 'boolean') {
overrides[key] = value;
}
}
// Handle nested toolOutputMasking legacy structure
const toolOutputMasking = experimental['toolOutputMasking'];
if (
toolOutputMasking &&
typeof toolOutputMasking === 'object' &&
'enabled' in toolOutputMasking &&
typeof (toolOutputMasking as Record<string, unknown>)['enabled'] ===
'boolean'
) {
overrides['toolOutputMasking'] = Boolean(
(toolOutputMasking as Record<string, unknown>)['enabled'],
);
}
}
// 2. Features (High Priority)
if (settings.features) {
Object.assign(overrides, settings.features);
}
mutableGate.setFromMap(overrides);
gate = mutableGate;
featureGateCache.set(settings, gate);
}
return gate.enabled(featureName);
}
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js'; import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
import { customDeepMerge } from '../utils/deepMerge.js'; import { customDeepMerge } from '../utils/deepMerge.js';
import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js'; import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js';
+126 -3
View File
@@ -1687,7 +1687,9 @@ const SETTINGS_SCHEMA = {
category: 'Experimental', category: 'Experimental',
requiresRestart: true, requiresRestart: true,
default: {}, default: {},
description: 'Setting to enable experimental features', ignoreInDocs: true,
description:
'DEPRECATED: Use the "features" object instead. Setting to enable experimental features',
showInDialog: false, showInDialog: false,
properties: { properties: {
toolOutputMasking: { toolOutputMasking: {
@@ -1708,7 +1710,7 @@ const SETTINGS_SCHEMA = {
requiresRestart: true, requiresRestart: true,
default: true, default: true,
description: 'Enables tool output masking to save tokens.', description: 'Enables tool output masking to save tokens.',
showInDialog: true, showInDialog: false,
}, },
toolProtectionThreshold: { toolProtectionThreshold: {
type: 'number', type: 'number',
@@ -1825,7 +1827,7 @@ const SETTINGS_SCHEMA = {
requiresRestart: true, requiresRestart: true,
default: false, default: false,
description: 'Enable planning features (Plan Mode and tools).', description: 'Enable planning features (Plan Mode and tools).',
showInDialog: true, showInDialog: false,
}, },
taskTracker: { taskTracker: {
type: 'boolean', type: 'boolean',
@@ -2277,6 +2279,127 @@ const SETTINGS_SCHEMA = {
}, },
}, },
}, },
features: {
type: 'object',
label: 'Features',
category: 'Features',
requiresRestart: true,
default: {},
description: 'Feature Lifecycle Management settings.',
showInDialog: false,
properties: {
allAlpha: {
type: 'boolean',
label: 'Enable all Alpha features',
category: 'Features',
requiresRestart: true,
default: false,
description: 'Enable all Alpha features by default.',
showInDialog: true,
},
allBeta: {
type: 'boolean',
label: 'Enable all Beta features',
category: 'Features',
requiresRestart: true,
default: true,
description: 'Enable all Beta features by default.',
showInDialog: true,
},
toolOutputMasking: {
type: 'boolean',
label: 'Tool Output Masking',
category: 'Features',
requiresRestart: true,
default: true,
description: 'Enables tool output masking to save tokens.',
showInDialog: true,
},
enableAgents: {
type: 'boolean',
label: 'Enable Agents',
category: 'Features',
requiresRestart: true,
default: false,
description: 'Enable local and remote subagents.',
showInDialog: true,
},
extensionManagement: {
type: 'boolean',
label: 'Extension Management',
category: 'Features',
requiresRestart: true,
default: true,
description: 'Enable extension management features.',
showInDialog: true,
},
extensionConfig: {
type: 'boolean',
label: 'Extension Configuration',
category: 'Features',
requiresRestart: true,
default: true,
description: 'Enable requesting and fetching of extension settings.',
showInDialog: true,
},
extensionRegistry: {
type: 'boolean',
label: 'Extension Registry Explore UI',
category: 'Features',
requiresRestart: true,
default: false,
description: 'Enable extension registry explore UI.',
showInDialog: true,
},
extensionReloading: {
type: 'boolean',
label: 'Extension Reloading',
category: 'Features',
requiresRestart: true,
default: false,
description:
'Enables extension loading/unloading within the CLI session.',
showInDialog: true,
},
jitContext: {
type: 'boolean',
label: 'JIT Context Loading',
category: 'Features',
requiresRestart: true,
default: false,
description: 'Enable Just-In-Time (JIT) context loading.',
showInDialog: true,
},
useOSC52Paste: {
type: 'boolean',
label: 'Use OSC 52 Paste',
category: 'Features',
requiresRestart: false,
default: false,
description: 'Use OSC 52 sequence for pasting.',
showInDialog: true,
},
plan: {
type: 'boolean',
label: 'Plan Mode',
category: 'Features',
requiresRestart: true,
default: false,
description: 'Enable planning features (Plan Mode and tools).',
showInDialog: true,
},
zedIntegration: {
type: 'boolean',
label: 'Zed Integration',
category: 'Features',
requiresRestart: true,
default: false,
description: 'Enable Zed integration.',
showInDialog: true,
},
},
},
} as const satisfies SettingsSchema; } as const satisfies SettingsSchema;
export type SettingsSchemaType = typeof SETTINGS_SCHEMA; export type SettingsSchemaType = typeof SETTINGS_SCHEMA;
@@ -415,7 +415,7 @@ describe('SettingsDialog', () => {
await waitFor(() => { await waitFor(() => {
// Should wrap to last setting (without relying on exact bullet character) // Should wrap to last setting (without relying on exact bullet character)
expect(lastFrame()).toContain('Hook Notifications'); expect(lastFrame()).toContain('Zed Integration');
}); });
unmount(); unmount();
@@ -753,6 +753,33 @@ describe('SettingsDialog', () => {
}); });
describe('Specific Settings Behavior', () => { describe('Specific Settings Behavior', () => {
it('should render feature stage badges', async () => {
const featureSettings = createMockSettings({
'features.allAlpha': false,
'features.allBeta': true,
});
const onSelect = vi.fn();
const { lastFrame, stdin } = render(
<KeypressProvider>
<SettingsDialog
settings={featureSettings}
onSelect={onSelect}
availableTerminalHeight={100}
/>
</KeypressProvider>,
);
act(() => {
stdin.write('Plan');
});
await waitFor(() => {
expect(lastFrame()).toContain('Plan Mode');
expect(lastFrame()).toContain('[ALPHA]');
});
});
it('should show correct display values for settings with different states', async () => { it('should show correct display values for settings with different states', async () => {
const settings = createMockSettings({ const settings = createMockSettings({
user: { user: {
@@ -33,7 +33,12 @@ import {
type SettingsValue, type SettingsValue,
TOGGLE_TYPES, TOGGLE_TYPES,
} from '../../config/settingsSchema.js'; } from '../../config/settingsSchema.js';
import { debugLogger } from '@google/gemini-cli-core'; import {
coreEvents,
debugLogger,
FeatureDefinitions,
} from '@google/gemini-cli-core';
import type { Config } from '@google/gemini-cli-core';
import { useSearchBuffer } from '../hooks/useSearchBuffer.js'; import { useSearchBuffer } from '../hooks/useSearchBuffer.js';
import { import {
@@ -244,6 +249,14 @@ export function SettingsDialog({
// The inline editor needs a string but non primitive settings like Arrays and Objects exist // The inline editor needs a string but non primitive settings like Arrays and Objects exist
const editValue = getEditValue(type, rawValue); const editValue = getEditValue(type, rawValue);
let stage: string | undefined;
const definitionKey = key.replace(/^features\./, '');
if (key.startsWith('features.') && FeatureDefinitions[definitionKey]) {
const specs = FeatureDefinitions[definitionKey];
const latest = specs[specs.length - 1];
stage = latest.preRelease;
}
return { return {
key, key,
label: definition?.label || key, label: definition?.label || key,
@@ -252,8 +265,10 @@ export function SettingsDialog({
displayValue, displayValue,
isGreyedOut, isGreyedOut,
scopeMessage, scopeMessage,
rawValue, // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
rawValue: rawValue as string | number | boolean | undefined,
editValue, editValue,
stage,
}; };
}); });
}, [settingKeys, selectedScope, settings]); }, [settingKeys, selectedScope, settings]);
@@ -26,6 +26,7 @@ import {
import { useKeypress, type Key } from '../../hooks/useKeypress.js'; import { useKeypress, type Key } from '../../hooks/useKeypress.js';
import { keyMatchers, Command } from '../../keyMatchers.js'; import { keyMatchers, Command } from '../../keyMatchers.js';
import { formatCommand } from '../../utils/keybindingUtils.js'; import { formatCommand } from '../../utils/keybindingUtils.js';
import { FeatureStage } from '@google/gemini-cli-core';
/** /**
* Represents a single item in the settings dialog. * Represents a single item in the settings dialog.
@@ -49,6 +50,8 @@ export interface SettingsDialogItem {
rawValue?: SettingsValue; rawValue?: SettingsValue;
/** Optional pre-formatted edit buffer value for complex types */ /** Optional pre-formatted edit buffer value for complex types */
editValue?: string; editValue?: string;
/** Feature stage (e.g. ALPHA, BETA) */
stage?: string;
} }
/** /**
@@ -553,6 +556,20 @@ export function BaseSettingsDialog({
color={isActive ? theme.ui.focus : theme.text.primary} color={isActive ? theme.ui.focus : theme.text.primary}
> >
{item.label} {item.label}
{item.stage && item.stage !== FeatureStage.GA && (
<Text
color={
item.stage === FeatureStage.Deprecated
? theme.status.error
: item.stage === FeatureStage.Beta
? theme.text.accent
: theme.status.warning
}
>
{' '}
[{item.stage}]{' '}
</Text>
)}
{item.scopeMessage && ( {item.scopeMessage && (
<Text color={theme.text.secondary}> <Text color={theme.text.secondary}>
{' '} {' '}
+96 -1
View File
@@ -2391,7 +2391,102 @@ describe('Config setExperiments logging', () => {
}); });
}); });
describe('Availability Service Integration', () => { describe('FeatureGate Integration', () => {
const baseParams: ConfigParameters = {
cwd: '/tmp',
targetDir: '/path/to/target',
debugMode: false,
sessionId: 'test-session-id',
model: 'gemini-pro',
usageStatisticsEnabled: false,
};
it('should initialize FeatureGate with defaults', () => {
const config = new Config(baseParams);
// Assuming 'plan' is Alpha and defaults to false
expect(config.isFeatureEnabled('plan')).toBe(false);
});
it('should respect "features" setting', () => {
const params = {
...baseParams,
features: { plan: true },
};
const config = new Config(params);
expect(config.isFeatureEnabled('plan')).toBe(true);
});
it('should respect "featureGates" CLI flag', () => {
const params = {
...baseParams,
featureGates: 'plan=true',
};
const config = new Config(params);
expect(config.isFeatureEnabled('plan')).toBe(true);
});
it('should respect "featureGates" CLI flag overriding settings', () => {
const params = {
...baseParams,
features: { plan: false },
featureGates: 'plan=true',
};
const config = new Config(params);
expect(config.isFeatureEnabled('plan')).toBe(true);
});
it('should respect legacy "experimental" settings if feature is not explicitly set', () => {
const params = {
...baseParams,
plan: true, // legacy experimental param for plan
};
const config = new Config(params);
expect(config.isFeatureEnabled('plan')).toBe(true);
});
it('should prioritize "features" over legacy settings', () => {
const params = {
...baseParams,
plan: true, // legacy enabled
features: { plan: false }, // new disabled
};
const config = new Config(params);
expect(config.isFeatureEnabled('plan')).toBe(false);
});
it('should respect stage-based toggles from settings', () => {
const params = {
...baseParams,
features: { allAlpha: true },
};
const config = new Config(params);
// 'plan' is Alpha
expect(config.isFeatureEnabled('plan')).toBe(true);
});
it('should resolve specific feature accessors using FeatureGate', () => {
const config = new Config({
...baseParams,
features: {
jitContext: true,
toolOutputMasking: false,
extensionManagement: true,
plan: true,
enableAgents: true,
zedIntegration: true,
},
});
expect(config.isJitContextEnabled()).toBe(true);
expect(config.getToolOutputMaskingEnabled()).toBe(false);
expect(config.getExtensionManagement()).toBe(true);
expect(config.isPlanEnabled()).toBe(true);
expect(config.isAgentsEnabled()).toBe(true);
expect(config.getExperimentalZedIntegration()).toBe(true);
});
});
describe('refreshAuth', () => {
const baseModel = 'test-model'; const baseModel = 'test-model';
const baseParams: ConfigParameters = { const baseParams: ConfigParameters = {
sessionId: 'test', sessionId: 'test',
+100 -15
View File
@@ -114,6 +114,7 @@ import { WorkspaceContext } from '../utils/workspaceContext.js';
import { Storage } from './storage.js'; import { Storage } from './storage.js';
import type { ShellExecutionConfig } from '../services/shellExecutionService.js'; import type { ShellExecutionConfig } from '../services/shellExecutionService.js';
import { FileExclusions } from '../utils/ignorePatterns.js'; import { FileExclusions } from '../utils/ignorePatterns.js';
import { DefaultFeatureGate, type FeatureGate } from './features.js';
import { MessageBus } from '../confirmation-bus/message-bus.js'; import { MessageBus } from '../confirmation-bus/message-bus.js';
import type { EventEmitter } from 'node:events'; import type { EventEmitter } from 'node:events';
import { PolicyEngine } from '../policy/policy-engine.js'; import { PolicyEngine } from '../policy/policy-engine.js';
@@ -584,6 +585,8 @@ export interface ConfigParameters {
tracker?: boolean; tracker?: boolean;
planSettings?: PlanSettings; planSettings?: PlanSettings;
modelSteering?: boolean; modelSteering?: boolean;
features?: Record<string, boolean>;
featureGates?: string;
onModelChange?: (model: string) => void; onModelChange?: (model: string) => void;
mcpEnabled?: boolean; mcpEnabled?: boolean;
extensionsEnabled?: boolean; extensionsEnabled?: boolean;
@@ -731,6 +734,7 @@ export class Config implements McpContext {
private readonly useAlternateBuffer: boolean; private readonly useAlternateBuffer: boolean;
private shellExecutionConfig: ShellExecutionConfig; private shellExecutionConfig: ShellExecutionConfig;
private readonly extensionManagement: boolean = true; private readonly extensionManagement: boolean = true;
private readonly enablePromptCompletion: boolean = false;
private readonly truncateToolOutputThreshold: number; private readonly truncateToolOutputThreshold: number;
private compressionTruncationCounter = 0; private compressionTruncationCounter = 0;
private initialized = false; private initialized = false;
@@ -791,7 +795,8 @@ export class Config implements McpContext {
private disabledSkills: string[]; private disabledSkills: string[];
private readonly adminSkillsEnabled: boolean; private readonly adminSkillsEnabled: boolean;
private readonly experimentalJitContext: boolean; private readonly featureGate: FeatureGate;
private readonly disableLLMCorrection: boolean; private readonly disableLLMCorrection: boolean;
private readonly planEnabled: boolean; private readonly planEnabled: boolean;
private readonly trackerEnabled: boolean; private readonly trackerEnabled: boolean;
@@ -881,7 +886,6 @@ export class Config implements McpContext {
this.model = params.model; this.model = params.model;
this.disableLoopDetection = params.disableLoopDetection ?? false; this.disableLoopDetection = params.disableLoopDetection ?? false;
this._activeModel = params.model; this._activeModel = params.model;
this.enableAgents = params.enableAgents ?? false;
this.agents = params.agents ?? {}; this.agents = params.agents ?? {};
this.disableLLMCorrection = params.disableLLMCorrection ?? true; this.disableLLMCorrection = params.disableLLMCorrection ?? true;
this.planEnabled = params.plan ?? false; this.planEnabled = params.plan ?? false;
@@ -891,6 +895,41 @@ export class Config implements McpContext {
this.skillsSupport = params.skillsSupport ?? true; this.skillsSupport = params.skillsSupport ?? true;
this.disabledSkills = params.disabledSkills ?? []; this.disabledSkills = params.disabledSkills ?? [];
this.adminSkillsEnabled = params.adminSkillsEnabled ?? true; this.adminSkillsEnabled = params.adminSkillsEnabled ?? true;
// Initialize FeatureGate with precedence:
// 1. CLI Flags (params.featureGates)
// 2. Env Var (GEMINI_FEATURE_GATES)
// 3. User Settings (params.features)
// 4. Legacy Experimental Settings
const gate = DefaultFeatureGate.deepCopy();
if (params.features) {
gate.setFromMap(params.features);
}
// Map legacy experimental flags to features if not already set
const legacyMap: Record<string, boolean | undefined> = {
toolOutputMasking: params.toolOutputMasking?.enabled,
enableAgents: params.enableAgents,
extensionManagement: params.extensionManagement,
plan: params.plan,
jitContext: params.experimentalJitContext,
zedIntegration: params.experimentalZedIntegration,
};
for (const [key, value] of Object.entries(legacyMap)) {
if (value !== undefined && params.features?.[key] === undefined) {
gate.setFromMap({ [key]: value });
}
}
const envGates = process.env['GEMINI_FEATURE_GATES'];
if (envGates) {
gate.set(envGates);
}
if (params.featureGates) {
gate.set(params.featureGates);
}
this.featureGate = gate;
this.modelAvailabilityService = new ModelAvailabilityService(); this.modelAvailabilityService = new ModelAvailabilityService();
this.experimentalJitContext = params.experimentalJitContext ?? false; this.experimentalJitContext = params.experimentalJitContext ?? false;
this.modelSteering = params.modelSteering ?? false; this.modelSteering = params.modelSteering ?? false;
@@ -959,7 +998,6 @@ export class Config implements McpContext {
params.enableShellOutputEfficiency ?? true; params.enableShellOutputEfficiency ?? true;
this.shellToolInactivityTimeout = this.shellToolInactivityTimeout =
(params.shellToolInactivityTimeout ?? 300) * 1000; // 5 minutes (params.shellToolInactivityTimeout ?? 300) * 1000; // 5 minutes
this.extensionManagement = params.extensionManagement ?? true;
this.enableExtensionReloading = params.enableExtensionReloading ?? false; this.enableExtensionReloading = params.enableExtensionReloading ?? false;
this.storage = new Storage(this.targetDir, this.sessionId); this.storage = new Storage(this.targetDir, this.sessionId);
this.storage.setCustomPlansDir(params.planSettings?.directory); this.storage.setCustomPlansDir(params.planSettings?.directory);
@@ -1092,6 +1130,13 @@ export class Config implements McpContext {
return this.initialized; return this.initialized;
} }
/**
* Returns true if the feature is enabled.
*/
isFeatureEnabled(key: string): boolean {
return this.featureGate.enabled(key);
}
/** /**
* Dedups initialization requests using a shared promise that is only resolved * Dedups initialization requests using a shared promise that is only resolved
* once. * once.
@@ -1163,7 +1208,7 @@ export class Config implements McpContext {
} }
}); });
if (!this.interactive || this.acpMode) { if (!this.interactive || this.acpMode || this.getExperimentalZedIntegration()) {
await this.mcpInitializationPromise; await this.mcpInitializationPromise;
} }
@@ -1193,7 +1238,7 @@ export class Config implements McpContext {
await this.hookSystem.initialize(); await this.hookSystem.initialize();
} }
if (this.experimentalJitContext) { if (this.isFeatureEnabled('jitContext')) {
this.contextManager = new ContextManager(this); this.contextManager = new ContextManager(this);
await this.contextManager.refresh(); await this.contextManager.refresh();
} }
@@ -1853,7 +1898,7 @@ export class Config implements McpContext {
} }
getUserMemory(): string | HierarchicalMemory { getUserMemory(): string | HierarchicalMemory {
if (this.experimentalJitContext && this.contextManager) { if (this.isFeatureEnabled('jitContext') && this.contextManager) {
return { return {
global: this.contextManager.getGlobalMemory(), global: this.contextManager.getGlobalMemory(),
extension: this.contextManager.getExtensionMemory(), extension: this.contextManager.getExtensionMemory(),
@@ -1867,7 +1912,7 @@ export class Config implements McpContext {
* Refreshes the MCP context, including memory, tools, and system instructions. * Refreshes the MCP context, including memory, tools, and system instructions.
*/ */
async refreshMcpContext(): Promise<void> { async refreshMcpContext(): Promise<void> {
if (this.experimentalJitContext && this.contextManager) { if (this.isFeatureEnabled('jitContext') && this.contextManager) {
await this.contextManager.refresh(); await this.contextManager.refresh();
} else { } else {
const { refreshServerHierarchicalMemory } = await import( const { refreshServerHierarchicalMemory } = await import(
@@ -1898,7 +1943,7 @@ export class Config implements McpContext {
} }
isJitContextEnabled(): boolean { isJitContextEnabled(): boolean {
return this.experimentalJitContext; return this.isFeatureEnabled('jitContext');
} }
isModelSteeringEnabled(): boolean { isModelSteeringEnabled(): boolean {
@@ -1906,7 +1951,7 @@ export class Config implements McpContext {
} }
getToolOutputMaskingEnabled(): boolean { getToolOutputMaskingEnabled(): boolean {
return this.toolOutputMasking.enabled; return this.isFeatureEnabled('toolOutputMasking');
} }
async getToolOutputMaskingConfig(): Promise<ToolOutputMaskingConfig> { async getToolOutputMaskingConfig(): Promise<ToolOutputMaskingConfig> {
@@ -1930,7 +1975,7 @@ export class Config implements McpContext {
: undefined; : undefined;
return { return {
enabled: this.toolOutputMasking.enabled, enabled: this.getToolOutputMaskingEnabled(),
toolProtectionThreshold: toolProtectionThreshold:
parsedProtection !== undefined && !isNaN(parsedProtection) parsedProtection !== undefined && !isNaN(parsedProtection)
? parsedProtection ? parsedProtection
@@ -1945,7 +1990,7 @@ export class Config implements McpContext {
} }
getGeminiMdFileCount(): number { getGeminiMdFileCount(): number {
if (this.experimentalJitContext && this.contextManager) { if (this.isFeatureEnabled('jitContext') && this.contextManager) {
return this.contextManager.getLoadedPaths().size; return this.contextManager.getLoadedPaths().size;
} }
return this.geminiMdFileCount; return this.geminiMdFileCount;
@@ -1956,7 +2001,7 @@ export class Config implements McpContext {
} }
getGeminiMdFilePaths(): string[] { getGeminiMdFilePaths(): string[] {
if (this.experimentalJitContext && this.contextManager) { if (this.isFeatureEnabled('jitContext') && this.contextManager) {
return Array.from(this.contextManager.getLoadedPaths()); return Array.from(this.contextManager.getLoadedPaths());
} }
return this.geminiMdFilePaths; return this.geminiMdFilePaths;
@@ -2040,6 +2085,42 @@ export class Config implements McpContext {
} }
} }
/**
* Synchronizes enter/exit plan mode tools based on current mode.
*/
syncPlanModeTools(): void {
const isPlanMode = this.getApprovalMode() === ApprovalMode.PLAN;
const registry = this.getToolRegistry();
if (isPlanMode) {
if (registry.getTool(ENTER_PLAN_MODE_TOOL_NAME)) {
registry.unregisterTool(ENTER_PLAN_MODE_TOOL_NAME);
}
if (!registry.getTool(EXIT_PLAN_MODE_TOOL_NAME)) {
registry.registerTool(new ExitPlanModeTool(this, this.messageBus));
}
} else {
if (registry.getTool(EXIT_PLAN_MODE_TOOL_NAME)) {
registry.unregisterTool(EXIT_PLAN_MODE_TOOL_NAME);
}
if (this.isPlanEnabled()) {
if (!registry.getTool(ENTER_PLAN_MODE_TOOL_NAME)) {
registry.registerTool(new EnterPlanModeTool(this, this.messageBus));
}
} else {
if (registry.getTool(ENTER_PLAN_MODE_TOOL_NAME)) {
registry.unregisterTool(ENTER_PLAN_MODE_TOOL_NAME);
}
}
}
if (this.geminiClient?.isInitialized()) {
this.geminiClient.setTools().catch((err) => {
debugLogger.error('Failed to update tools', err);
});
}
}
/** /**
* Logs the duration of the current approval mode. * Logs the duration of the current approval mode.
*/ */
@@ -2246,6 +2327,10 @@ export class Config implements McpContext {
} }
} }
getExperimentalZedIntegration(): boolean {
return this.isFeatureEnabled('zedIntegration');
}
getListExtensions(): boolean { getListExtensions(): boolean {
return this.listExtensions; return this.listExtensions;
} }
@@ -2259,7 +2344,7 @@ export class Config implements McpContext {
} }
getExtensionManagement(): boolean { getExtensionManagement(): boolean {
return this.extensionManagement; return this.isFeatureEnabled('extensionManagement');
} }
getExtensions(): GeminiCLIExtension[] { getExtensions(): GeminiCLIExtension[] {
@@ -2285,7 +2370,7 @@ export class Config implements McpContext {
} }
isPlanEnabled(): boolean { isPlanEnabled(): boolean {
return this.planEnabled; return this.isFeatureEnabled('plan');
} }
isTrackerEnabled(): boolean { isTrackerEnabled(): boolean {
@@ -2305,7 +2390,7 @@ export class Config implements McpContext {
} }
isAgentsEnabled(): boolean { isAgentsEnabled(): boolean {
return this.enableAgents; return this.isFeatureEnabled('enableAgents');
} }
isEventDrivenSchedulerEnabled(): boolean { isEventDrivenSchedulerEnabled(): boolean {
+304
View File
@@ -0,0 +1,304 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { it, expect, describe, vi } from 'vitest';
import { DefaultFeatureGate, FeatureStage } from './features.js';
import { debugLogger } from '../utils/debugLogger.js';
describe('FeatureGate', () => {
it('should resolve default values', () => {
const gate = DefaultFeatureGate.deepCopy();
gate.add({
testAlpha: [
{
default: false,
lockToDefault: false,
preRelease: FeatureStage.Alpha,
},
],
testBeta: [
{ default: true, lockToDefault: false, preRelease: FeatureStage.Beta },
],
});
expect(gate.enabled('testAlpha')).toBe(false);
expect(gate.enabled('testBeta')).toBe(true);
});
it('should infer default values from stage', () => {
const gate = DefaultFeatureGate.deepCopy();
gate.add({
autoAlpha: [{ lockToDefault: false, preRelease: FeatureStage.Alpha }],
autoBeta: [{ lockToDefault: false, preRelease: FeatureStage.Beta }],
autoGA: [{ lockToDefault: true, preRelease: FeatureStage.GA }],
autoDeprecated: [
{ lockToDefault: false, preRelease: FeatureStage.Deprecated },
],
});
expect(gate.enabled('autoAlpha')).toBe(false);
expect(gate.enabled('autoBeta')).toBe(true);
expect(gate.enabled('autoGA')).toBe(true);
expect(gate.enabled('autoDeprecated')).toBe(false);
});
it('should infer lockToDefault from stage', () => {
const gate = DefaultFeatureGate.deepCopy();
gate.add({
autoLockedGA: [{ preRelease: FeatureStage.GA }],
autoUnlockedAlpha: [{ preRelease: FeatureStage.Alpha }],
});
// Attempt to disable both
gate.setFromMap({ autoLockedGA: false, autoUnlockedAlpha: true });
// GA should remain enabled (locked)
expect(gate.enabled('autoLockedGA')).toBe(true);
// Alpha should respect override (unlocked)
expect(gate.enabled('autoUnlockedAlpha')).toBe(true);
});
it('should respect explicit default even if stage default differs', () => {
const gate = DefaultFeatureGate.deepCopy();
gate.add({
offBeta: [
{ default: false, lockToDefault: false, preRelease: FeatureStage.Beta },
],
onAlpha: [
{ default: true, lockToDefault: false, preRelease: FeatureStage.Alpha },
],
});
expect(gate.enabled('offBeta')).toBe(false);
expect(gate.enabled('onAlpha')).toBe(true);
});
it('should respect manual overrides', () => {
const gate = DefaultFeatureGate.deepCopy();
gate.add({
testAlpha: [
{
default: false,
lockToDefault: false,
preRelease: FeatureStage.Alpha,
},
],
});
gate.setFromMap({ testAlpha: true });
expect(gate.enabled('testAlpha')).toBe(true);
});
it('should respect lockToDefault', () => {
const gate = DefaultFeatureGate.deepCopy();
gate.add({
testGA: [
{ default: true, lockToDefault: true, preRelease: FeatureStage.GA },
],
});
// Attempt to disable GA feature
gate.setFromMap({ testGA: false });
expect(gate.enabled('testGA')).toBe(true);
});
it('should respect allAlpha/allBeta toggles', () => {
const gate = DefaultFeatureGate.deepCopy();
gate.add({
alpha1: [
{
default: false,
lockToDefault: false,
preRelease: FeatureStage.Alpha,
},
],
alpha2: [
{
default: false,
lockToDefault: false,
preRelease: FeatureStage.Alpha,
},
],
beta1: [
{ default: true, lockToDefault: false, preRelease: FeatureStage.Beta },
],
});
// Enable all alpha, disable all beta
gate.setFromMap({ allAlpha: true, allBeta: false });
expect(gate.enabled('alpha1')).toBe(true);
expect(gate.enabled('alpha2')).toBe(true);
expect(gate.enabled('beta1')).toBe(false);
// Individual override should still win
gate.setFromMap({ alpha1: false });
expect(gate.enabled('alpha1')).toBe(false);
});
it('should parse comma-separated strings', () => {
const gate = DefaultFeatureGate.deepCopy();
gate.add({
feat1: [
{
default: false,
lockToDefault: false,
preRelease: FeatureStage.Alpha,
},
],
feat2: [
{ default: true, lockToDefault: false, preRelease: FeatureStage.Beta },
],
});
gate.set('feat1=true,feat2=false');
expect(gate.enabled('feat1')).toBe(true);
expect(gate.enabled('feat2')).toBe(false);
});
it('should handle case-insensitive boolean values in set', () => {
const gate = DefaultFeatureGate.deepCopy();
gate.add({
feat1: [
{
default: false,
lockToDefault: false,
preRelease: FeatureStage.Alpha,
},
],
feat2: [
{ default: true, lockToDefault: false, preRelease: FeatureStage.Beta },
],
});
gate.set('feat1=TRUE,feat2=FaLsE');
expect(gate.enabled('feat1')).toBe(true);
expect(gate.enabled('feat2')).toBe(false);
});
it('should ignore whitespace in set', () => {
const gate = DefaultFeatureGate.deepCopy();
gate.add({
feat1: [
{
default: false,
lockToDefault: false,
preRelease: FeatureStage.Alpha,
},
],
});
gate.set(' feat1 = true ');
expect(gate.enabled('feat1')).toBe(true);
});
it('should return default if feature is unknown', () => {
const gate = DefaultFeatureGate.deepCopy();
// unknownFeature is not added
expect(gate.enabled('unknownFeature')).toBe(false);
});
it('should respect precedence: Lock > Override > Stage > Default', () => {
const gate = DefaultFeatureGate.deepCopy();
gate.add({
// Locked GA feature
featLocked: [
{ default: true, lockToDefault: true, preRelease: FeatureStage.GA },
],
// Alpha feature
featAlpha: [
{
default: false,
lockToDefault: false,
preRelease: FeatureStage.Alpha,
},
],
});
// 1. Lock wins over override
gate.setFromMap({ featLocked: false });
expect(gate.enabled('featLocked')).toBe(true);
// 2. Override wins over Stage
gate.setFromMap({ allAlpha: true, featAlpha: false });
expect(gate.enabled('featAlpha')).toBe(false);
// 3. Stage wins over Default
gate.setFromMap({
allAlpha: true,
featAlpha: undefined as unknown as boolean,
}); // Removing specific override effectively
// Re-create to clear overrides map for cleaner test
const gate2 = DefaultFeatureGate.deepCopy();
gate2.add({
featAlpha: [
{
default: false,
lockToDefault: false,
preRelease: FeatureStage.Alpha,
},
],
});
gate2.setFromMap({ allAlpha: true });
expect(gate2.enabled('featAlpha')).toBe(true);
});
it('should use the latest feature spec', () => {
const gate = DefaultFeatureGate.deepCopy();
gate.add({
evolvedFeat: [
{
default: false,
lockToDefault: false,
preRelease: FeatureStage.Alpha,
since: '1.0',
},
{
default: true,
lockToDefault: false,
preRelease: FeatureStage.Beta,
since: '1.1',
},
],
});
// Should use the last spec (Beta, default true)
expect(gate.enabled('evolvedFeat')).toBe(true);
});
it('should log warning when using deprecated feature only once', () => {
const warnSpy = vi.spyOn(debugLogger, 'warn').mockImplementation(() => {});
const gate = DefaultFeatureGate.deepCopy();
gate.add({
deprecatedFeat: [
{
default: false,
lockToDefault: false,
preRelease: FeatureStage.Deprecated,
},
],
});
gate.setFromMap({ deprecatedFeat: true });
expect(gate.enabled('deprecatedFeat')).toBe(true);
expect(gate.enabled('deprecatedFeat')).toBe(true); // Call again
// Should only be called once
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('Feature "deprecatedFeat" is deprecated'),
);
warnSpy.mockRestore();
});
it('should perform deep copy of specs', () => {
const gate = DefaultFeatureGate.deepCopy();
const featKey = 'copiedFeat';
const initialSpecs = [{ preRelease: FeatureStage.Alpha }];
gate.add({ [featKey]: initialSpecs });
const copy = gate.deepCopy();
// Modifying original spec array should not affect copy if it was truly deep copied
// (though our implementation clones the array, not the spec objects, which is usually enough for this use case)
gate.add({
[featKey]: [{ preRelease: FeatureStage.Beta }],
});
expect(gate.enabled(featKey)).toBe(true); // Beta (default true)
expect(copy.enabled(featKey)).toBe(false); // Alpha (default false)
});
});
+286
View File
@@ -0,0 +1,286 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { debugLogger } from '../utils/debugLogger.js';
/**
* FeatureStage indicates the maturity level of a feature.
* Strictly aligned with Kubernetes Feature Gates.
*/
export enum FeatureStage {
/**
* Alpha features are disabled by default and may be unstable.
*/
Alpha = 'ALPHA',
/**
* Beta features are enabled by default and are considered stable.
*/
Beta = 'BETA',
/**
* GA features are stable and locked to enabled.
*/
GA = 'GA',
/**
* Deprecated features are scheduled for removal.
*/
Deprecated = 'DEPRECATED',
}
/**
* FeatureSpec defines the behavior and metadata of a feature at a specific version.
*/
export interface FeatureSpec {
/**
* Default enablement state.
* If not provided, defaults to:
* - Alpha: false
* - Beta: true
* - GA: true
* - Deprecated: false
*/
default?: boolean;
/**
* If true, the feature cannot be changed from its default value.
* Defaults to:
* - GA: true
* - Others: false
*/
lockToDefault?: boolean;
/**
* The maturity stage of the feature.
*/
preRelease: FeatureStage;
/**
* The version since this spec became valid.
*/
since?: string;
/**
* The version until which this spec is valid or scheduled for removal.
*/
until?: string;
/**
* Description of the feature.
*/
description?: string;
}
/**
* FeatureGate provides a read-only interface to query feature status.
*/
export interface FeatureGate {
/**
* Returns true if the feature is enabled.
*/
enabled(key: string): boolean;
/**
* Returns all known feature keys.
*/
knownFeatures(): string[];
/**
* Returns a mutable copy of the current gate.
*/
deepCopy(): MutableFeatureGate;
}
/**
* MutableFeatureGate allows registering and configuring features.
*/
export interface MutableFeatureGate extends FeatureGate {
/**
* Adds new features or updates existing ones with versioned specs.
*/
add(features: Record<string, FeatureSpec[]>): void;
/**
* Sets feature states from a comma-separated string (e.g., "Foo=true,Bar=false").
*/
set(instance: string): void;
/**
* Sets feature states from a map.
*/
setFromMap(m: Record<string, boolean>): void;
}
class FeatureGateImpl implements MutableFeatureGate {
private specs: Map<string, FeatureSpec[]> = new Map();
private overrides: Map<string, boolean> = new Map();
private warnedFeatures: Set<string> = new Set();
add(features: Record<string, FeatureSpec[]>): void {
for (const [key, specs] of Object.entries(features)) {
this.specs.set(key, specs);
}
}
set(instance: string): void {
const pairs = instance.split(',');
for (const pair of pairs) {
const eqIndex = pair.indexOf('=');
if (eqIndex !== -1) {
const key = pair.slice(0, eqIndex).trim();
const value = pair.slice(eqIndex + 1).trim();
if (key) {
this.overrides.set(key, value.toLowerCase() === 'true');
}
}
}
}
setFromMap(m: Record<string, boolean>): void {
for (const [key, value] of Object.entries(m)) {
this.overrides.set(key, value);
}
}
enabled(key: string): boolean {
const specs = this.specs.get(key);
if (!specs || specs.length === 0) {
return false;
}
// Get the latest spec (for now, just the last one in the array)
const latestSpec = specs[specs.length - 1];
const isLocked =
latestSpec.lockToDefault ?? latestSpec.preRelease === FeatureStage.GA;
if (isLocked) {
return latestSpec.default ?? true; // Locked features (GA) must be enabled unless explicitly disabled (rare)
}
const override = this.overrides.get(key);
if (override !== undefined) {
if (
latestSpec.preRelease === FeatureStage.Deprecated &&
!this.warnedFeatures.has(key)
) {
debugLogger.warn(
`[WARNING] Feature "${key}" is deprecated and will be removed in a future release.`,
);
this.warnedFeatures.add(key);
}
return override;
}
// Handle stage-wide defaults if set (e.g., allAlpha, allBeta)
if (latestSpec.preRelease === FeatureStage.Alpha) {
const allAlpha = this.overrides.get('allAlpha');
if (allAlpha !== undefined) return allAlpha;
}
if (latestSpec.preRelease === FeatureStage.Beta) {
const allBeta = this.overrides.get('allBeta');
if (allBeta !== undefined) return allBeta;
}
if (latestSpec.default !== undefined) {
return latestSpec.default;
}
// Auto-default based on stage
return (
latestSpec.preRelease === FeatureStage.Beta ||
latestSpec.preRelease === FeatureStage.GA
);
}
knownFeatures(): string[] {
return Array.from(this.specs.keys());
}
deepCopy(): MutableFeatureGate {
const copy = new FeatureGateImpl();
copy.specs = new Map(
Array.from(this.specs.entries()).map(([k, v]) => [k, [...v]]),
);
copy.overrides = new Map(this.overrides);
// warnedFeatures are not copied, we want to warn again in a new context if needed
return copy;
}
}
/**
* Global default feature gate.
*/
export const DefaultFeatureGate: MutableFeatureGate = new FeatureGateImpl();
/**
* Registry of core features.
*/
export const FeatureDefinitions: Record<string, FeatureSpec[]> = {
toolOutputMasking: [
{
preRelease: FeatureStage.Beta,
since: '0.30.0',
description: 'Enables tool output masking to save tokens.',
},
],
enableAgents: [
{
preRelease: FeatureStage.Alpha,
since: '0.30.0',
description: 'Enable local and remote subagents.',
},
],
extensionManagement: [
{
preRelease: FeatureStage.Beta,
since: '0.30.0',
description: 'Enable extension management features.',
},
],
extensionConfig: [
{
preRelease: FeatureStage.Beta,
since: '0.30.0',
description: 'Enable requesting and fetching of extension settings.',
},
],
extensionRegistry: [
{
preRelease: FeatureStage.Alpha,
since: '0.30.0',
description: 'Enable extension registry explore UI.',
},
],
extensionReloading: [
{
preRelease: FeatureStage.Alpha,
since: '0.30.0',
description:
'Enables extension loading/unloading within the CLI session.',
},
],
jitContext: [
{
preRelease: FeatureStage.Alpha,
since: '0.30.0',
description: 'Enable Just-In-Time (JIT) context loading.',
},
],
useOSC52Paste: [
{
preRelease: FeatureStage.Alpha,
since: '0.30.0',
description: 'Use OSC 52 sequence for pasting.',
},
],
plan: [
{
preRelease: FeatureStage.Alpha,
since: '0.30.0',
description: 'Enable planning features (Plan Mode and tools).',
},
],
zedIntegration: [
{
preRelease: FeatureStage.Alpha,
since: '0.30.0',
description: 'Enable Zed integration.',
},
],
};
// Register core features
DefaultFeatureGate.add(FeatureDefinitions);
+1
View File
@@ -7,6 +7,7 @@
// Export config // Export config
export * from './config/config.js'; export * from './config/config.js';
export * from './config/memory.js'; export * from './config/memory.js';
export * from './config/features.js';
export * from './config/defaultModelConfigs.js'; export * from './config/defaultModelConfigs.js';
export * from './config/models.js'; export * from './config/models.js';
export * from './config/constants.js'; export * from './config/constants.js';
+96 -2
View File
@@ -1611,8 +1611,8 @@
}, },
"experimental": { "experimental": {
"title": "Experimental", "title": "Experimental",
"description": "Setting to enable experimental features", "description": "DEPRECATED: Use the \"features\" object instead. Setting to enable experimental features",
"markdownDescription": "Setting to enable experimental features\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `{}`", "markdownDescription": "DEPRECATED: Use the \"features\" object instead. Setting to enable experimental features\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `{}`",
"default": {}, "default": {},
"type": "object", "type": "object",
"properties": { "properties": {
@@ -2040,6 +2040,100 @@
} }
}, },
"additionalProperties": false "additionalProperties": false
},
"features": {
"title": "Features",
"description": "Feature Lifecycle Management settings.",
"markdownDescription": "Feature Lifecycle Management settings.\n\n- Category: `Features`\n- Requires restart: `yes`\n- Default: `{}`",
"default": {},
"type": "object",
"properties": {
"allAlpha": {
"title": "Enable all Alpha features",
"description": "Enable all Alpha features by default.",
"markdownDescription": "Enable all Alpha features by default.\n\n- Category: `Features`\n- Requires restart: `yes`\n- Default: `false`",
"default": false,
"type": "boolean"
},
"allBeta": {
"title": "Enable all Beta features",
"description": "Enable all Beta features by default.",
"markdownDescription": "Enable all Beta features by default.\n\n- Category: `Features`\n- Requires restart: `yes`\n- Default: `true`",
"default": true,
"type": "boolean"
},
"toolOutputMasking": {
"title": "Tool Output Masking",
"description": "Enables tool output masking to save tokens.",
"markdownDescription": "Enables tool output masking to save tokens.\n\n- Category: `Features`\n- Requires restart: `yes`\n- Default: `true`",
"default": true,
"type": "boolean"
},
"enableAgents": {
"title": "Enable Agents",
"description": "Enable local and remote subagents.",
"markdownDescription": "Enable local and remote subagents.\n\n- Category: `Features`\n- Requires restart: `yes`\n- Default: `false`",
"default": false,
"type": "boolean"
},
"extensionManagement": {
"title": "Extension Management",
"description": "Enable extension management features.",
"markdownDescription": "Enable extension management features.\n\n- Category: `Features`\n- Requires restart: `yes`\n- Default: `true`",
"default": true,
"type": "boolean"
},
"extensionConfig": {
"title": "Extension Configuration",
"description": "Enable requesting and fetching of extension settings.",
"markdownDescription": "Enable requesting and fetching of extension settings.\n\n- Category: `Features`\n- Requires restart: `yes`\n- Default: `true`",
"default": true,
"type": "boolean"
},
"extensionRegistry": {
"title": "Extension Registry Explore UI",
"description": "Enable extension registry explore UI.",
"markdownDescription": "Enable extension registry explore UI.\n\n- Category: `Features`\n- Requires restart: `yes`\n- Default: `false`",
"default": false,
"type": "boolean"
},
"extensionReloading": {
"title": "Extension Reloading",
"description": "Enables extension loading/unloading within the CLI session.",
"markdownDescription": "Enables extension loading/unloading within the CLI session.\n\n- Category: `Features`\n- Requires restart: `yes`\n- Default: `false`",
"default": false,
"type": "boolean"
},
"jitContext": {
"title": "JIT Context Loading",
"description": "Enable Just-In-Time (JIT) context loading.",
"markdownDescription": "Enable Just-In-Time (JIT) context loading.\n\n- Category: `Features`\n- Requires restart: `yes`\n- Default: `false`",
"default": false,
"type": "boolean"
},
"useOSC52Paste": {
"title": "Use OSC 52 Paste",
"description": "Use OSC 52 sequence for pasting.",
"markdownDescription": "Use OSC 52 sequence for pasting.\n\n- Category: `Features`\n- Requires restart: `no`\n- Default: `false`",
"default": false,
"type": "boolean"
},
"plan": {
"title": "Plan Mode",
"description": "Enable planning features (Plan Mode and tools).",
"markdownDescription": "Enable planning features (Plan Mode and tools).\n\n- Category: `Features`\n- Requires restart: `yes`\n- Default: `false`",
"default": false,
"type": "boolean"
},
"zedIntegration": {
"title": "Zed Integration",
"description": "Enable Zed integration.",
"markdownDescription": "Enable Zed integration.\n\n- Category: `Features`\n- Requires restart: `yes`\n- Default: `false`",
"default": false,
"type": "boolean"
}
},
"additionalProperties": false
} }
}, },
"$defs": { "$defs": {
+86 -15
View File
@@ -22,6 +22,11 @@ import type {
SettingsSchemaType, SettingsSchemaType,
} from '../packages/cli/src/config/settingsSchema.js'; } from '../packages/cli/src/config/settingsSchema.js';
import {
FeatureDefinitions,
FeatureStage,
} from '../packages/core/src/config/features.js';
const START_MARKER = '<!-- SETTINGS-AUTOGEN:START -->'; const START_MARKER = '<!-- SETTINGS-AUTOGEN:START -->';
const END_MARKER = '<!-- SETTINGS-AUTOGEN:END -->'; const END_MARKER = '<!-- SETTINGS-AUTOGEN:END -->';
@@ -36,6 +41,7 @@ interface DocEntry {
defaultValue: string; defaultValue: string;
requiresRestart: boolean; requiresRestart: boolean;
enumValues?: string[]; enumValues?: string[];
stage?: string;
} }
export async function main(argv = process.argv.slice(2)) { export async function main(argv = process.argv.slice(2)) {
@@ -49,6 +55,10 @@ export async function main(argv = process.argv.slice(2)) {
); );
const docPath = path.join(repoRoot, 'docs/reference/configuration.md'); const docPath = path.join(repoRoot, 'docs/reference/configuration.md');
const cliSettingsDocPath = path.join(repoRoot, 'docs/cli/settings.md'); const cliSettingsDocPath = path.join(repoRoot, 'docs/cli/settings.md');
const featureGatesDocPath = path.join(
repoRoot,
'docs/cli/feature-lifecycle.md',
);
const { getSettingsSchema } = await loadSettingsSchemaModule(); const { getSettingsSchema } = await loadSettingsSchemaModule();
const schema = getSettingsSchema(); const schema = getSettingsSchema();
@@ -59,21 +69,31 @@ export async function main(argv = process.argv.slice(2)) {
const generatedBlock = renderSections(allSettingsSections); const generatedBlock = renderSections(allSettingsSections);
const generatedTableBlock = renderTableSections(filteredSettingsSections); const generatedTableBlock = renderTableSections(filteredSettingsSections);
const generatedFeatureGatesBlock = renderFeatureGatesTable();
await updateFile(docPath, generatedBlock, checkOnly); await updateFile(docPath, generatedBlock, checkOnly);
await updateFile(cliSettingsDocPath, generatedTableBlock, checkOnly); await updateFile(cliSettingsDocPath, generatedTableBlock, checkOnly);
await updateFile(
featureGatesDocPath,
generatedFeatureGatesBlock,
checkOnly,
'<!-- FEATURES-AUTOGEN:START -->',
'<!-- FEATURES-AUTOGEN:END -->',
);
} }
async function updateFile( async function updateFile(
filePath: string, filePath: string,
newContent: string, newContent: string,
checkOnly: boolean, checkOnly: boolean,
startMarker = START_MARKER,
endMarker = END_MARKER,
) { ) {
const doc = await readFile(filePath, 'utf8'); const doc = await readFile(filePath, 'utf8');
const injectedDoc = injectBetweenMarkers({ const injectedDoc = injectBetweenMarkers({
document: doc, document: doc,
startMarker: START_MARKER, startMarker,
endMarker: END_MARKER, endMarker,
newContent: newContent, newContent: newContent,
paddingBefore: '\n', paddingBefore: '\n',
paddingAfter: '\n', paddingAfter: '\n',
@@ -142,19 +162,31 @@ function collectEntries(
sections.set(sectionKey, []); sections.set(sectionKey, []);
} }
let defaultValue = definition.default;
let stage: string | undefined;
if (sectionKey === 'features' && FeatureDefinitions[key]) {
const specs = FeatureDefinitions[key];
const latest = specs[specs.length - 1];
stage = latest.preRelease;
defaultValue =
latest.default ??
(stage === FeatureStage.Beta || stage === FeatureStage.GA);
}
sections.get(sectionKey)!.push({ sections.get(sectionKey)!.push({
path: newPathSegments.join('.'), path: newPathSegments.join('.'),
type: formatType(definition), type: formatType(definition),
label: definition.label, label: definition.label,
category: definition.category, category: definition.category,
description: formatDescription(definition), description: formatDescription(definition),
defaultValue: formatDefaultValue(definition.default, { defaultValue: formatDefaultValue(defaultValue, {
quoteStrings: true, quoteStrings: true,
}), }),
requiresRestart: Boolean(definition.requiresRestart), requiresRestart: Boolean(definition.requiresRestart),
enumValues: definition.options?.map((option) => enumValues: definition.options?.map((option) =>
formatDefaultValue(option.value, { quoteStrings: true }), formatDefaultValue(option.value, { quoteStrings: true }),
), ),
stage,
}); });
} }
@@ -225,6 +257,10 @@ function renderSections(sections: Map<string, DocEntry[]>) {
lines.push(' - **Values:** ' + values); lines.push(' - **Values:** ' + values);
} }
if (entry.stage) {
lines.push(' - **Stage:** ' + entry.stage);
}
if (entry.requiresRestart) { if (entry.requiresRestart) {
lines.push(' - **Requires restart:** Yes'); lines.push(' - **Requires restart:** Yes');
} }
@@ -252,23 +288,34 @@ function renderTableSections(sections: Map<string, DocEntry[]>) {
} }
lines.push(`### ${title}`); lines.push(`### ${title}`);
lines.push(''); lines.push('');
lines.push('| UI Label | Setting | Description | Default |'); if (section === 'features') {
lines.push('| --- | --- | --- | --- |'); lines.push('| UI Label | Setting | Description | Default | Stage |');
lines.push('| --- | --- | --- | --- | --- |');
} else {
lines.push('| UI Label | Setting | Description | Default |');
lines.push('| --- | --- | --- | --- |');
}
for (const entry of entries) { for (const entry of entries) {
const val = entry.defaultValue.replace(/\n/g, ' '); const val = entry.defaultValue.replace(/\n/g, ' ');
const defaultVal = '`' + escapeBackticks(val) + '`'; const defaultVal = '`' + escapeBackticks(val) + '`';
lines.push( let row =
'| ' + '| ' +
entry.label + entry.label +
' | `' + ' | `' +
entry.path + entry.path +
'` | ' + '` | ' +
entry.description + entry.description +
' | ' + ' | ' +
defaultVal + defaultVal +
' |', ' |';
);
if (section === 'features') {
const stageVal = entry.stage ? '`' + entry.stage + '`' : '-';
row += ' ' + stageVal + ' |';
}
lines.push(row);
} }
lines.push(''); lines.push('');
@@ -277,6 +324,30 @@ function renderTableSections(sections: Map<string, DocEntry[]>) {
return lines.join('\n').trimEnd(); return lines.join('\n').trimEnd();
} }
function renderFeatureGatesTable() {
let markdown = '| Feature | Stage | Default | Since | Description |\n';
markdown += '| --- | --- | --- | --- | --- |\n';
const sortedFeatures = Object.entries(FeatureDefinitions).sort(
([keyA], [keyB]) => keyA.localeCompare(keyB),
);
for (const [key, specs] of sortedFeatures) {
const latest = specs[specs.length - 1];
const stage = latest.preRelease;
const isEnabled =
latest.default ??
(stage === FeatureStage.Beta || stage === FeatureStage.GA);
const defaultValue = isEnabled ? 'Enabled' : 'Disabled';
const since = latest.since || '-';
const description = latest.description || '-';
markdown += `| \`${key}\` | ${stage} | ${defaultValue} | ${since} | ${description} |\n`;
}
return markdown;
}
if (process.argv[1]) { if (process.argv[1]) {
const entryUrl = pathToFileURL(path.resolve(process.argv[1])).href; const entryUrl = pathToFileURL(path.resolve(process.argv[1])).href;
if (entryUrl === import.meta.url) { if (entryUrl === import.meta.url) {