From 04f22a51b142061c5717dad6877925a0b2c0a59c Mon Sep 17 00:00:00 2001 From: Jerop Kipruto Date: Mon, 16 Feb 2026 23:48:45 -0500 Subject: [PATCH] feat(core): implement feature lifecycle management (Alpha, Beta, GA) --- .gemini/settings.json | 3 + docs/cli/feature-lifecycle.md | 193 +++++++++++ docs/cli/settings.md | 17 + docs/reference/configuration.md | 94 +++++- package.json | 8 + .../cli/src/commands/extensions/configure.ts | 6 +- packages/cli/src/config/config.test.ts | 56 ++++ packages/cli/src/config/config.ts | 33 +- packages/cli/src/config/extension-manager.ts | 23 +- packages/cli/src/config/extension.test.ts | 8 +- packages/cli/src/config/settings.test.ts | 48 +++ packages/cli/src/config/settings.ts | 53 +++ packages/cli/src/config/settingsSchema.ts | 129 +++++++- .../src/ui/components/SettingsDialog.test.tsx | 29 +- .../cli/src/ui/components/SettingsDialog.tsx | 19 +- .../components/shared/BaseSettingsDialog.tsx | 17 + packages/core/src/config/config.test.ts | 97 +++++- packages/core/src/config/config.ts | 115 ++++++- packages/core/src/config/features.test.ts | 304 ++++++++++++++++++ packages/core/src/config/features.ts | 286 ++++++++++++++++ packages/core/src/index.ts | 1 + schemas/settings.schema.json | 98 +++++- scripts/generate-settings-doc.ts | 101 +++++- 23 files changed, 1678 insertions(+), 60 deletions(-) create mode 100644 docs/cli/feature-lifecycle.md create mode 100644 packages/core/src/config/features.test.ts create mode 100644 packages/core/src/config/features.ts diff --git a/.gemini/settings.json b/.gemini/settings.json index 1a4c889066..bdd73b399e 100644 --- a/.gemini/settings.json +++ b/.gemini/settings.json @@ -4,6 +4,9 @@ "extensionReloading": true, "modelSteering": true }, + "features": { + "allAlpha": true + }, "general": { "devtools": true } diff --git a/docs/cli/feature-lifecycle.md b/docs/cli/feature-lifecycle.md new file mode 100644 index 0000000000..3aa20cd7ac --- /dev/null +++ b/docs/cli/feature-lifecycle.md @@ -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.`: 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 + + + +| 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. | + + + +## 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 = { + // ... 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 diff --git a/docs/cli/settings.md b/docs/cli/settings.md index d2680d65ad..2853d88946 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -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` | | 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` | + diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 1f1299072b..e8e4021cb2 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -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 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). + #### `policyPaths` @@ -1056,7 +1066,6 @@ their corresponding top-level category object in your `settings.json` file. `gemma3-1b-gpu-custom`. - **Default:** `"gemma3-1b-gpu-custom"` - **Requires restart:** Yes - #### `skills` - **`skills.enabled`** (boolean): @@ -1165,6 +1174,77 @@ their corresponding top-level category object in your `settings.json` file. - **`admin.skills.enabled`** (boolean): - **Description:** If false, disallows agent skills from being used. - **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 #### `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 wanting to associate it with a specific IDE instance. - 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`**: - Specifies the root directory for Gemini CLI's user-level configuration and storage. @@ -1551,13 +1635,17 @@ for that specific session. - `auto_edit`: Automatically approve edit tools (replace, write_file) while prompting for others - `yolo`: Automatically approve all tool calls (equivalent to `--yolo`) - - `plan`: Read-only mode for tool calls (requires experimental planning to - be enabled). + - `plan`: Read-only mode for tool calls (requires planning feature to be + enabled). > **Note:** This mode is currently under development and not yet fully > functional. - Cannot be used together with `--yolo`. Use `--approval-mode=yolo` instead of `--yolo` for the new unified approach. - Example: `gemini --approval-mode auto_edit` +- **`--feature-gates `**: + - 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 `**: - A comma-separated list of tool names that will bypass the confirmation dialog. diff --git a/package.json b/package.json index 8d931c1462..76bce17411 100644 --- a/package.json +++ b/package.json @@ -161,6 +161,14 @@ ], "*.{json,md}": [ "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" ] } } diff --git a/packages/cli/src/commands/extensions/configure.ts b/packages/cli/src/commands/extensions/configure.ts index a2136968b3..e6bd2b2c0f 100644 --- a/packages/cli/src/commands/extensions/configure.ts +++ b/packages/cli/src/commands/extensions/configure.ts @@ -12,7 +12,7 @@ import { configureSpecificSetting, getExtensionManager, } 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 { exitCli } from '../utils.js'; @@ -45,10 +45,10 @@ export const configureCommand: CommandModule = { const { name, setting, scope } = args; const settings = loadSettings(process.cwd()).merged; - if (!(settings.experimental?.extensionConfig ?? true)) { + if (!isFeatureEnabled(settings, 'extensionConfig')) { coreEvents.emitFeedback( '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(); return; diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index f8c857cee8..84baf62763 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -2647,6 +2647,62 @@ describe('loadCliConfig approval mode', () => { 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 --- describe('when folder is NOT trusted', () => { beforeEach(() => { diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index a1ce5b7d1c..2694ddeaf4 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -44,6 +44,7 @@ import { type MergedSettings, saveModelChange, loadSettings, + isFeatureEnabled, } from './settings.js'; import { loadSandboxConfig } from './sandboxConfig.js'; @@ -78,6 +79,7 @@ export interface CliArgs { allowedTools: string[] | undefined; acp?: boolean; experimentalAcp?: boolean; + featureGates?: string | undefined; extensions: string[] | undefined; listExtensions: boolean | undefined; resume: string | typeof RESUME_LATEST | undefined; @@ -182,6 +184,12 @@ export async function parseArguments( description: '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', { type: 'array', string: true, @@ -328,7 +336,7 @@ export async function parseArguments( return true; }); - if (settings.experimental?.extensionManagement) { + if (isFeatureEnabled(settings, 'extensionManagement')) { yargsInstance.command(extensionsCommand); } @@ -486,7 +494,7 @@ export async function loadCliConfig( .getExtensions() .find((ext) => ext.isActive && ext.plan?.directory)?.plan; - const experimentalJitContext = settings.experimental?.jitContext ?? false; + const experimentalJitContext = isFeatureEnabled(settings, 'jitContext'); let memoryContent: string | HierarchicalMemory = ''; let fileCount = 0; @@ -532,7 +540,7 @@ export async function loadCliConfig( approvalMode = ApprovalMode.AUTO_EDIT; break; case 'plan': - if (!(settings.experimental?.plan ?? false)) { + if (!isFeatureEnabled(settings, 'plan')) { debugLogger.warn( '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, model: resolvedModel, maxSessionTurns: settings.model?.maxSessionTurns, - + experimentalZedIntegration: argv.experimentalAcp || false, + features: settings.features, + featureGates: argv.featureGates, listExtensions: argv.listExtensions || false, listSessions: argv.listSessions || false, deleteSession: argv.deleteSession, enabledExtensions: argv.extensions, extensionLoader: extensionManager, - enableExtensionReloading: settings.experimental?.extensionReloading, - enableAgents: settings.experimental?.enableAgents, - plan: settings.experimental?.plan, + enableExtensionReloading: isFeatureEnabled(settings, 'extensionReloading'), + enableAgents: isFeatureEnabled(settings, 'enableAgents'), + plan: isFeatureEnabled(settings, 'plan'), tracker: settings.experimental?.taskTracker, directWebFetch: settings.experimental?.directWebFetch, planSettings: settings.general?.plan?.directory @@ -776,9 +786,14 @@ export async function loadCliConfig( enableEventDrivenScheduler: true, skillsSupport: settings.skills?.enabled ?? true, disabledSkills: settings.skills?.disabled, - experimentalJitContext: settings.experimental?.jitContext, + experimentalJitContext: isFeatureEnabled(settings, 'jitContext'), modelSteering: settings.experimental?.modelSteering, - toolOutputMasking: settings.experimental?.toolOutputMasking, + toolOutputMasking: + (settings.features as Record | undefined)?.[ + 'toolOutputMasking' + ] !== undefined + ? { enabled: isFeatureEnabled(settings, 'toolOutputMasking') } + : settings.experimental?.toolOutputMasking, noBrowser: !!process.env['NO_BROWSER'], summarizeToolOutput: settings.model?.summarizeToolOutput, ideMode, diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts index 678350ba49..351972052f 100644 --- a/packages/cli/src/config/extension-manager.ts +++ b/packages/cli/src/config/extension-manager.ts @@ -9,7 +9,11 @@ import * as path from 'node:path'; import { stat } from 'node:fs/promises'; import chalk from 'chalk'; 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 { loadInstallMetadata, type ExtensionConfig } from './extension.js'; import { @@ -323,7 +327,10 @@ Would you like to attempt to install via "git clone" instead?`, } await fs.promises.mkdir(destinationPath, { recursive: true }); - if (this.requestSetting && this.settings.experimental.extensionConfig) { + if ( + this.requestSetting && + isFeatureEnabled(this.settings, 'extensionConfig') + ) { if (isUpdate) { await maybePromptForSettings( 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( newExtensionConfig, extensionId, @@ -677,7 +687,7 @@ Would you like to attempt to install via "git clone" instead?`, let userSettings: Record = {}; let workspaceSettings: Record = {}; - if (this.settings.experimental.extensionConfig) { + if (isFeatureEnabled(this.settings, 'extensionConfig')) { userSettings = await getScopedEnvContents( config, extensionId, @@ -697,7 +707,10 @@ Would you like to attempt to install via "git clone" instead?`, config = resolveEnvVarsInObject(config, customEnv); const resolvedSettings: ResolvedExtensionSetting[] = []; - if (config.settings && this.settings.experimental.extensionConfig) { + if ( + config.settings && + isFeatureEnabled(this.settings, 'extensionConfig') + ) { for (const setting of config.settings) { const value = customEnv[setting.envVar]; let scope: 'user' | 'workspace' | undefined; diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts index f8e66bf8e2..5c1153cc23 100644 --- a/packages/cli/src/config/extension.test.ts +++ b/packages/cli/src/config/extension.test.ts @@ -206,7 +206,13 @@ describe('extension tests', () => { }); vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir); const settings = loadSettings(tempWorkspaceDir).merged; - settings.experimental.extensionConfig = true; + if (settings.features) { + (settings.features as Record)['extensionConfig'] = true; + } else { + (settings as unknown as Record)['features'] = { + extensionConfig: true, + }; + } extensionManager = new ExtensionManager({ workspaceDir: tempWorkspaceDir, requestConsent: mockRequestConsent, diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index 5589ef11ba..c63ea291c1 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -76,6 +76,8 @@ import { LoadedSettings, sanitizeEnvVar, createTestMergedSettings, + isFeatureEnabled, + type MergedSettings, } from './settings.js'; import { FatalConfigError, @@ -3119,4 +3121,50 @@ describe('LoadedSettings Isolation and Serializability', () => { }).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); + }); + }); }); diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 422dda6115..254eb91e24 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -18,6 +18,8 @@ import { coreEvents, homedir, type AdminControlsSettings, + DefaultFeatureGate, + type FeatureGate, } from '@google/gemini-cli-core'; import stripJsonComments from 'strip-json-comments'; import { DefaultLight } from '../ui/themes/builtin/light/default-light.js'; @@ -43,6 +45,57 @@ export { getSettingsSchema, }; +const featureGateCache = new WeakMap(); + +/** + * 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 = {}; + + // 1. Experimental (Low Priority) + const experimental = settings.experimental as Record; + 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)['enabled'] === + 'boolean' + ) { + overrides['toolOutputMasking'] = Boolean( + (toolOutputMasking as Record)['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 { customDeepMerge } from '../utils/deepMerge.js'; import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js'; diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index fbc50e8b39..4c2e96289c 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1687,7 +1687,9 @@ const SETTINGS_SCHEMA = { category: 'Experimental', requiresRestart: true, default: {}, - description: 'Setting to enable experimental features', + ignoreInDocs: true, + description: + 'DEPRECATED: Use the "features" object instead. Setting to enable experimental features', showInDialog: false, properties: { toolOutputMasking: { @@ -1708,7 +1710,7 @@ const SETTINGS_SCHEMA = { requiresRestart: true, default: true, description: 'Enables tool output masking to save tokens.', - showInDialog: true, + showInDialog: false, }, toolProtectionThreshold: { type: 'number', @@ -1825,7 +1827,7 @@ const SETTINGS_SCHEMA = { requiresRestart: true, default: false, description: 'Enable planning features (Plan Mode and tools).', - showInDialog: true, + showInDialog: false, }, taskTracker: { 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; export type SettingsSchemaType = typeof SETTINGS_SCHEMA; diff --git a/packages/cli/src/ui/components/SettingsDialog.test.tsx b/packages/cli/src/ui/components/SettingsDialog.test.tsx index be99dfcc26..aaca29e2ab 100644 --- a/packages/cli/src/ui/components/SettingsDialog.test.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.test.tsx @@ -415,7 +415,7 @@ describe('SettingsDialog', () => { await waitFor(() => { // Should wrap to last setting (without relying on exact bullet character) - expect(lastFrame()).toContain('Hook Notifications'); + expect(lastFrame()).toContain('Zed Integration'); }); unmount(); @@ -753,6 +753,33 @@ describe('SettingsDialog', () => { }); 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( + + + , + ); + + 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 () => { const settings = createMockSettings({ user: { diff --git a/packages/cli/src/ui/components/SettingsDialog.tsx b/packages/cli/src/ui/components/SettingsDialog.tsx index 23e8a55a7d..e62635159b 100644 --- a/packages/cli/src/ui/components/SettingsDialog.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.tsx @@ -33,7 +33,12 @@ import { type SettingsValue, TOGGLE_TYPES, } 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 { @@ -244,6 +249,14 @@ export function SettingsDialog({ // The inline editor needs a string but non primitive settings like Arrays and Objects exist 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 { key, label: definition?.label || key, @@ -252,8 +265,10 @@ export function SettingsDialog({ displayValue, isGreyedOut, scopeMessage, - rawValue, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + rawValue: rawValue as string | number | boolean | undefined, editValue, + stage, }; }); }, [settingKeys, selectedScope, settings]); diff --git a/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx b/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx index 05cef4fcf2..eef22fd2c8 100644 --- a/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx +++ b/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx @@ -26,6 +26,7 @@ import { import { useKeypress, type Key } from '../../hooks/useKeypress.js'; import { keyMatchers, Command } from '../../keyMatchers.js'; import { formatCommand } from '../../utils/keybindingUtils.js'; +import { FeatureStage } from '@google/gemini-cli-core'; /** * Represents a single item in the settings dialog. @@ -49,6 +50,8 @@ export interface SettingsDialogItem { rawValue?: SettingsValue; /** Optional pre-formatted edit buffer value for complex types */ 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} > {item.label} + {item.stage && item.stage !== FeatureStage.GA && ( + + {' '} + [{item.stage}]{' '} + + )} {item.scopeMessage && ( {' '} diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index da30b13377..bc89c181b7 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -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 baseParams: ConfigParameters = { sessionId: 'test', diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index e4c0fef6eb..d70ee60d7d 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -114,6 +114,7 @@ import { WorkspaceContext } from '../utils/workspaceContext.js'; import { Storage } from './storage.js'; import type { ShellExecutionConfig } from '../services/shellExecutionService.js'; import { FileExclusions } from '../utils/ignorePatterns.js'; +import { DefaultFeatureGate, type FeatureGate } from './features.js'; import { MessageBus } from '../confirmation-bus/message-bus.js'; import type { EventEmitter } from 'node:events'; import { PolicyEngine } from '../policy/policy-engine.js'; @@ -584,6 +585,8 @@ export interface ConfigParameters { tracker?: boolean; planSettings?: PlanSettings; modelSteering?: boolean; + features?: Record; + featureGates?: string; onModelChange?: (model: string) => void; mcpEnabled?: boolean; extensionsEnabled?: boolean; @@ -731,6 +734,7 @@ export class Config implements McpContext { private readonly useAlternateBuffer: boolean; private shellExecutionConfig: ShellExecutionConfig; private readonly extensionManagement: boolean = true; + private readonly enablePromptCompletion: boolean = false; private readonly truncateToolOutputThreshold: number; private compressionTruncationCounter = 0; private initialized = false; @@ -791,7 +795,8 @@ export class Config implements McpContext { private disabledSkills: string[]; private readonly adminSkillsEnabled: boolean; - private readonly experimentalJitContext: boolean; + private readonly featureGate: FeatureGate; + private readonly disableLLMCorrection: boolean; private readonly planEnabled: boolean; private readonly trackerEnabled: boolean; @@ -881,7 +886,6 @@ export class Config implements McpContext { this.model = params.model; this.disableLoopDetection = params.disableLoopDetection ?? false; this._activeModel = params.model; - this.enableAgents = params.enableAgents ?? false; this.agents = params.agents ?? {}; this.disableLLMCorrection = params.disableLLMCorrection ?? true; this.planEnabled = params.plan ?? false; @@ -891,6 +895,41 @@ export class Config implements McpContext { this.skillsSupport = params.skillsSupport ?? true; this.disabledSkills = params.disabledSkills ?? []; 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 = { + 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.experimentalJitContext = params.experimentalJitContext ?? false; this.modelSteering = params.modelSteering ?? false; @@ -959,7 +998,6 @@ export class Config implements McpContext { params.enableShellOutputEfficiency ?? true; this.shellToolInactivityTimeout = (params.shellToolInactivityTimeout ?? 300) * 1000; // 5 minutes - this.extensionManagement = params.extensionManagement ?? true; this.enableExtensionReloading = params.enableExtensionReloading ?? false; this.storage = new Storage(this.targetDir, this.sessionId); this.storage.setCustomPlansDir(params.planSettings?.directory); @@ -1092,6 +1130,13 @@ export class Config implements McpContext { 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 * 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; } @@ -1193,7 +1238,7 @@ export class Config implements McpContext { await this.hookSystem.initialize(); } - if (this.experimentalJitContext) { + if (this.isFeatureEnabled('jitContext')) { this.contextManager = new ContextManager(this); await this.contextManager.refresh(); } @@ -1853,7 +1898,7 @@ export class Config implements McpContext { } getUserMemory(): string | HierarchicalMemory { - if (this.experimentalJitContext && this.contextManager) { + if (this.isFeatureEnabled('jitContext') && this.contextManager) { return { global: this.contextManager.getGlobalMemory(), extension: this.contextManager.getExtensionMemory(), @@ -1867,7 +1912,7 @@ export class Config implements McpContext { * Refreshes the MCP context, including memory, tools, and system instructions. */ async refreshMcpContext(): Promise { - if (this.experimentalJitContext && this.contextManager) { + if (this.isFeatureEnabled('jitContext') && this.contextManager) { await this.contextManager.refresh(); } else { const { refreshServerHierarchicalMemory } = await import( @@ -1898,7 +1943,7 @@ export class Config implements McpContext { } isJitContextEnabled(): boolean { - return this.experimentalJitContext; + return this.isFeatureEnabled('jitContext'); } isModelSteeringEnabled(): boolean { @@ -1906,7 +1951,7 @@ export class Config implements McpContext { } getToolOutputMaskingEnabled(): boolean { - return this.toolOutputMasking.enabled; + return this.isFeatureEnabled('toolOutputMasking'); } async getToolOutputMaskingConfig(): Promise { @@ -1930,7 +1975,7 @@ export class Config implements McpContext { : undefined; return { - enabled: this.toolOutputMasking.enabled, + enabled: this.getToolOutputMaskingEnabled(), toolProtectionThreshold: parsedProtection !== undefined && !isNaN(parsedProtection) ? parsedProtection @@ -1945,7 +1990,7 @@ export class Config implements McpContext { } getGeminiMdFileCount(): number { - if (this.experimentalJitContext && this.contextManager) { + if (this.isFeatureEnabled('jitContext') && this.contextManager) { return this.contextManager.getLoadedPaths().size; } return this.geminiMdFileCount; @@ -1956,7 +2001,7 @@ export class Config implements McpContext { } getGeminiMdFilePaths(): string[] { - if (this.experimentalJitContext && this.contextManager) { + if (this.isFeatureEnabled('jitContext') && this.contextManager) { return Array.from(this.contextManager.getLoadedPaths()); } 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. */ @@ -2246,6 +2327,10 @@ export class Config implements McpContext { } } + getExperimentalZedIntegration(): boolean { + return this.isFeatureEnabled('zedIntegration'); + } + getListExtensions(): boolean { return this.listExtensions; } @@ -2259,7 +2344,7 @@ export class Config implements McpContext { } getExtensionManagement(): boolean { - return this.extensionManagement; + return this.isFeatureEnabled('extensionManagement'); } getExtensions(): GeminiCLIExtension[] { @@ -2285,7 +2370,7 @@ export class Config implements McpContext { } isPlanEnabled(): boolean { - return this.planEnabled; + return this.isFeatureEnabled('plan'); } isTrackerEnabled(): boolean { @@ -2305,7 +2390,7 @@ export class Config implements McpContext { } isAgentsEnabled(): boolean { - return this.enableAgents; + return this.isFeatureEnabled('enableAgents'); } isEventDrivenSchedulerEnabled(): boolean { diff --git a/packages/core/src/config/features.test.ts b/packages/core/src/config/features.test.ts new file mode 100644 index 0000000000..b8b190fe6f --- /dev/null +++ b/packages/core/src/config/features.test.ts @@ -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) + }); +}); diff --git a/packages/core/src/config/features.ts b/packages/core/src/config/features.ts new file mode 100644 index 0000000000..d8218d7e3a --- /dev/null +++ b/packages/core/src/config/features.ts @@ -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): 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): void; +} + +class FeatureGateImpl implements MutableFeatureGate { + private specs: Map = new Map(); + private overrides: Map = new Map(); + private warnedFeatures: Set = new Set(); + + add(features: Record): 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): 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 = { + 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); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c4a9965e41..e2262c9d85 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -7,6 +7,7 @@ // Export config export * from './config/config.js'; export * from './config/memory.js'; +export * from './config/features.js'; export * from './config/defaultModelConfigs.js'; export * from './config/models.js'; export * from './config/constants.js'; diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index 36816079ca..9a18c283ed 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -1611,8 +1611,8 @@ }, "experimental": { "title": "Experimental", - "description": "Setting to enable experimental features", - "markdownDescription": "Setting to enable experimental features\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `{}`", + "description": "DEPRECATED: Use the \"features\" object instead. Setting to enable experimental features", + "markdownDescription": "DEPRECATED: Use the \"features\" object instead. Setting to enable experimental features\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `{}`", "default": {}, "type": "object", "properties": { @@ -2040,6 +2040,100 @@ } }, "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": { diff --git a/scripts/generate-settings-doc.ts b/scripts/generate-settings-doc.ts index 1d27eb962a..49f9d11430 100644 --- a/scripts/generate-settings-doc.ts +++ b/scripts/generate-settings-doc.ts @@ -22,6 +22,11 @@ import type { SettingsSchemaType, } from '../packages/cli/src/config/settingsSchema.js'; +import { + FeatureDefinitions, + FeatureStage, +} from '../packages/core/src/config/features.js'; + const START_MARKER = ''; const END_MARKER = ''; @@ -36,6 +41,7 @@ interface DocEntry { defaultValue: string; requiresRestart: boolean; enumValues?: string[]; + stage?: string; } 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 cliSettingsDocPath = path.join(repoRoot, 'docs/cli/settings.md'); + const featureGatesDocPath = path.join( + repoRoot, + 'docs/cli/feature-lifecycle.md', + ); const { getSettingsSchema } = await loadSettingsSchemaModule(); const schema = getSettingsSchema(); @@ -59,21 +69,31 @@ export async function main(argv = process.argv.slice(2)) { const generatedBlock = renderSections(allSettingsSections); const generatedTableBlock = renderTableSections(filteredSettingsSections); + const generatedFeatureGatesBlock = renderFeatureGatesTable(); await updateFile(docPath, generatedBlock, checkOnly); await updateFile(cliSettingsDocPath, generatedTableBlock, checkOnly); + await updateFile( + featureGatesDocPath, + generatedFeatureGatesBlock, + checkOnly, + '', + '', + ); } async function updateFile( filePath: string, newContent: string, checkOnly: boolean, + startMarker = START_MARKER, + endMarker = END_MARKER, ) { const doc = await readFile(filePath, 'utf8'); const injectedDoc = injectBetweenMarkers({ document: doc, - startMarker: START_MARKER, - endMarker: END_MARKER, + startMarker, + endMarker, newContent: newContent, paddingBefore: '\n', paddingAfter: '\n', @@ -142,19 +162,31 @@ function collectEntries( 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({ path: newPathSegments.join('.'), type: formatType(definition), label: definition.label, category: definition.category, description: formatDescription(definition), - defaultValue: formatDefaultValue(definition.default, { + defaultValue: formatDefaultValue(defaultValue, { quoteStrings: true, }), requiresRestart: Boolean(definition.requiresRestart), enumValues: definition.options?.map((option) => formatDefaultValue(option.value, { quoteStrings: true }), ), + stage, }); } @@ -225,6 +257,10 @@ function renderSections(sections: Map) { lines.push(' - **Values:** ' + values); } + if (entry.stage) { + lines.push(' - **Stage:** ' + entry.stage); + } + if (entry.requiresRestart) { lines.push(' - **Requires restart:** Yes'); } @@ -252,23 +288,34 @@ function renderTableSections(sections: Map) { } lines.push(`### ${title}`); lines.push(''); - lines.push('| UI Label | Setting | Description | Default |'); - lines.push('| --- | --- | --- | --- |'); + if (section === 'features') { + 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) { const val = entry.defaultValue.replace(/\n/g, ' '); const defaultVal = '`' + escapeBackticks(val) + '`'; - lines.push( + let row = '| ' + - entry.label + - ' | `' + - entry.path + - '` | ' + - entry.description + - ' | ' + - defaultVal + - ' |', - ); + entry.label + + ' | `' + + entry.path + + '` | ' + + entry.description + + ' | ' + + defaultVal + + ' |'; + + if (section === 'features') { + const stageVal = entry.stage ? '`' + entry.stage + '`' : '-'; + row += ' ' + stageVal + ' |'; + } + + lines.push(row); } lines.push(''); @@ -277,6 +324,30 @@ function renderTableSections(sections: Map) { 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]) { const entryUrl = pathToFileURL(path.resolve(process.argv[1])).href; if (entryUrl === import.meta.url) {