mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-10 14:10:37 -07:00
1207 lines
36 KiB
TypeScript
1207 lines
36 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import { platform } from 'node:os';
|
|
import * as dotenv from 'dotenv';
|
|
import process from 'node:process';
|
|
import {
|
|
CoreEvent,
|
|
FatalConfigError,
|
|
GEMINI_DIR,
|
|
getErrorMessage,
|
|
Storage,
|
|
coreEvents,
|
|
homedir,
|
|
type AdminControlsSettings,
|
|
createCache,
|
|
} from '@google/gemini-cli-core';
|
|
import stripJsonComments from 'strip-json-comments';
|
|
import { DefaultLight } from '../ui/themes/builtin/light/default-light.js';
|
|
import { DefaultDark } from '../ui/themes/builtin/dark/default-dark.js';
|
|
import { isWorkspaceTrusted } from './trustedFolders.js';
|
|
import {
|
|
type Settings,
|
|
type MergedSettings,
|
|
type MemoryImportFormat,
|
|
type MergeStrategy,
|
|
type SettingsSchema,
|
|
type SettingDefinition,
|
|
getSettingsSchema,
|
|
} from './settingsSchema.js';
|
|
|
|
export {
|
|
type Settings,
|
|
type MergedSettings,
|
|
type MemoryImportFormat,
|
|
type MergeStrategy,
|
|
type SettingsSchema,
|
|
type SettingDefinition,
|
|
getSettingsSchema,
|
|
};
|
|
|
|
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
|
|
import { customDeepMerge } from '../utils/deepMerge.js';
|
|
import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js';
|
|
import {
|
|
validateSettings,
|
|
formatValidationError,
|
|
} from './settings-validation.js';
|
|
|
|
export function getMergeStrategyForPath(
|
|
path: string[],
|
|
): MergeStrategy | undefined {
|
|
let current: SettingDefinition | undefined = undefined;
|
|
let currentSchema: SettingsSchema | undefined = getSettingsSchema();
|
|
let parent: SettingDefinition | undefined = undefined;
|
|
|
|
for (const key of path) {
|
|
if (!currentSchema || !currentSchema[key]) {
|
|
// Key not found in schema - check if parent has additionalProperties
|
|
if (parent?.additionalProperties?.mergeStrategy) {
|
|
return parent.additionalProperties.mergeStrategy;
|
|
}
|
|
return undefined;
|
|
}
|
|
parent = current;
|
|
current = currentSchema[key];
|
|
currentSchema = current.properties;
|
|
}
|
|
|
|
return current?.mergeStrategy;
|
|
}
|
|
|
|
export const USER_SETTINGS_PATH = Storage.getGlobalSettingsPath();
|
|
export const USER_SETTINGS_DIR = path.dirname(USER_SETTINGS_PATH);
|
|
export const DEFAULT_EXCLUDED_ENV_VARS = ['DEBUG', 'DEBUG_MODE'];
|
|
|
|
const AUTH_ENV_VAR_WHITELIST = [
|
|
'GEMINI_API_KEY',
|
|
'GOOGLE_API_KEY',
|
|
'GOOGLE_CLOUD_PROJECT',
|
|
'GOOGLE_CLOUD_LOCATION',
|
|
];
|
|
|
|
/**
|
|
* Sanitizes an environment variable value to prevent shell injection.
|
|
* Restricts values to a safe character set: alphanumeric, -, _, ., /
|
|
*/
|
|
export function sanitizeEnvVar(value: string): string {
|
|
return value.replace(/[^a-zA-Z0-9\-_./]/g, '');
|
|
}
|
|
|
|
export function getSystemSettingsPath(): string {
|
|
if (process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH']) {
|
|
return process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH'];
|
|
}
|
|
if (platform() === 'darwin') {
|
|
return '/Library/Application Support/GeminiCli/settings.json';
|
|
} else if (platform() === 'win32') {
|
|
return 'C:\\ProgramData\\gemini-cli\\settings.json';
|
|
} else {
|
|
return '/etc/gemini-cli/settings.json';
|
|
}
|
|
}
|
|
|
|
export function getSystemDefaultsPath(): string {
|
|
if (process.env['GEMINI_CLI_SYSTEM_DEFAULTS_PATH']) {
|
|
return process.env['GEMINI_CLI_SYSTEM_DEFAULTS_PATH'];
|
|
}
|
|
return path.join(
|
|
path.dirname(getSystemSettingsPath()),
|
|
'system-defaults.json',
|
|
);
|
|
}
|
|
|
|
export type { DnsResolutionOrder } from './settingsSchema.js';
|
|
|
|
export enum SettingScope {
|
|
User = 'User',
|
|
Workspace = 'Workspace',
|
|
System = 'System',
|
|
SystemDefaults = 'SystemDefaults',
|
|
// Note that this scope is not supported in the settings dialog at this time,
|
|
// it is only supported for extensions.
|
|
Session = 'Session',
|
|
}
|
|
|
|
/**
|
|
* A type representing the settings scopes that are supported for LoadedSettings.
|
|
*/
|
|
export type LoadableSettingScope =
|
|
| SettingScope.User
|
|
| SettingScope.Workspace
|
|
| SettingScope.System
|
|
| SettingScope.SystemDefaults;
|
|
|
|
/**
|
|
* The actual values of the loadable settings scopes.
|
|
*/
|
|
const _loadableSettingScopes = [
|
|
SettingScope.User,
|
|
SettingScope.Workspace,
|
|
SettingScope.System,
|
|
SettingScope.SystemDefaults,
|
|
];
|
|
|
|
/**
|
|
* A type guard function that checks if `scope` is a loadable settings scope,
|
|
* and allows promotion to the `LoadableSettingsScope` type based on the result.
|
|
*/
|
|
export function isLoadableSettingScope(
|
|
scope: SettingScope,
|
|
): scope is LoadableSettingScope {
|
|
return _loadableSettingScopes.includes(scope);
|
|
}
|
|
|
|
export interface CheckpointingSettings {
|
|
enabled?: boolean;
|
|
}
|
|
|
|
export interface SummarizeToolOutputSettings {
|
|
tokenBudget?: number;
|
|
}
|
|
|
|
export type LoadingPhrasesMode = 'tips' | 'witty' | 'all' | 'off';
|
|
|
|
export interface AccessibilitySettings {
|
|
/** @deprecated Use ui.loadingPhrases instead. */
|
|
enableLoadingPhrases?: boolean;
|
|
screenReader?: boolean;
|
|
}
|
|
|
|
export interface SessionRetentionSettings {
|
|
/** Enable automatic session cleanup */
|
|
enabled?: boolean;
|
|
|
|
/** Maximum age of sessions to keep (e.g., "30d", "7d", "24h", "1w") */
|
|
maxAge?: string;
|
|
|
|
/** Alternative: Maximum number of sessions to keep (most recent) */
|
|
maxCount?: number;
|
|
|
|
/** Minimum retention period (safety limit, defaults to "1d") */
|
|
minRetention?: string;
|
|
}
|
|
|
|
export interface SettingsError {
|
|
message: string;
|
|
path: string;
|
|
severity: 'error' | 'warning';
|
|
}
|
|
|
|
export interface SettingsFile {
|
|
settings: Settings;
|
|
originalSettings: Settings;
|
|
path: string;
|
|
rawJson?: string;
|
|
readOnly?: boolean;
|
|
}
|
|
|
|
function setNestedProperty(
|
|
obj: Record<string, unknown>,
|
|
path: string,
|
|
value: unknown,
|
|
) {
|
|
const keys = path.split('.');
|
|
const lastKey = keys.pop();
|
|
if (!lastKey) return;
|
|
|
|
let current: Record<string, unknown> = obj;
|
|
for (const key of keys) {
|
|
if (current[key] === undefined) {
|
|
current[key] = {};
|
|
}
|
|
const next = current[key];
|
|
if (typeof next === 'object' && next !== null) {
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
current = next as Record<string, unknown>;
|
|
} else {
|
|
// This path is invalid, so we stop.
|
|
return;
|
|
}
|
|
}
|
|
current[lastKey] = value;
|
|
}
|
|
|
|
export function getDefaultsFromSchema(
|
|
schema: SettingsSchema = getSettingsSchema(),
|
|
): Settings {
|
|
const defaults: Record<string, unknown> = {};
|
|
for (const key in schema) {
|
|
const definition = schema[key];
|
|
if (definition.properties) {
|
|
defaults[key] = getDefaultsFromSchema(definition.properties);
|
|
} else if (definition.default !== undefined) {
|
|
defaults[key] = definition.default;
|
|
}
|
|
}
|
|
return defaults as Settings;
|
|
}
|
|
|
|
export function mergeSettings(
|
|
system: Settings,
|
|
systemDefaults: Settings,
|
|
user: Settings,
|
|
workspace: Settings,
|
|
isTrusted: boolean,
|
|
): MergedSettings {
|
|
const safeWorkspace = isTrusted ? workspace : ({} as Settings);
|
|
const schemaDefaults = getDefaultsFromSchema();
|
|
|
|
// Settings are merged with the following precedence (last one wins for
|
|
// single values):
|
|
// 1. Schema Defaults (Built-in)
|
|
// 2. System Defaults
|
|
// 3. User Settings
|
|
// 4. Workspace Settings
|
|
// 5. System Settings (as overrides)
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
return customDeepMerge(
|
|
getMergeStrategyForPath,
|
|
schemaDefaults,
|
|
systemDefaults,
|
|
user,
|
|
safeWorkspace,
|
|
system,
|
|
) as MergedSettings;
|
|
}
|
|
|
|
/**
|
|
* Creates a fully populated MergedSettings object for testing purposes.
|
|
* It merges the provided overrides with the default settings from the schema.
|
|
*
|
|
* @param overrides Partial settings to override the defaults.
|
|
* @returns A complete MergedSettings object.
|
|
*/
|
|
export function createTestMergedSettings(
|
|
overrides: Partial<Settings> = {},
|
|
): MergedSettings {
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
return customDeepMerge(
|
|
getMergeStrategyForPath,
|
|
getDefaultsFromSchema(),
|
|
overrides,
|
|
) as MergedSettings;
|
|
}
|
|
|
|
/**
|
|
* An immutable snapshot of settings state.
|
|
* Used with useSyncExternalStore for reactive updates.
|
|
*/
|
|
export interface LoadedSettingsSnapshot {
|
|
system: SettingsFile;
|
|
systemDefaults: SettingsFile;
|
|
user: SettingsFile;
|
|
workspace: SettingsFile;
|
|
isTrusted: boolean;
|
|
errors: SettingsError[];
|
|
merged: MergedSettings;
|
|
}
|
|
|
|
export class LoadedSettings {
|
|
constructor(
|
|
system: SettingsFile,
|
|
systemDefaults: SettingsFile,
|
|
user: SettingsFile,
|
|
workspace: SettingsFile,
|
|
isTrusted: boolean,
|
|
errors: SettingsError[] = [],
|
|
) {
|
|
this.system = system;
|
|
this.systemDefaults = systemDefaults;
|
|
this.user = user;
|
|
this._workspaceFile = workspace;
|
|
this.isTrusted = isTrusted;
|
|
this.workspace = isTrusted
|
|
? workspace
|
|
: this.createEmptyWorkspace(workspace);
|
|
this.errors = errors;
|
|
this._merged = this.computeMergedSettings();
|
|
this._snapshot = this.computeSnapshot();
|
|
}
|
|
|
|
readonly system: SettingsFile;
|
|
readonly systemDefaults: SettingsFile;
|
|
readonly user: SettingsFile;
|
|
workspace: SettingsFile;
|
|
isTrusted: boolean;
|
|
readonly errors: SettingsError[];
|
|
|
|
private _workspaceFile: SettingsFile;
|
|
private _merged: MergedSettings;
|
|
private _snapshot: LoadedSettingsSnapshot;
|
|
private _remoteAdminSettings: Partial<Settings> | undefined;
|
|
|
|
get merged(): MergedSettings {
|
|
return this._merged;
|
|
}
|
|
|
|
setTrusted(isTrusted: boolean): void {
|
|
if (this.isTrusted === isTrusted) {
|
|
return;
|
|
}
|
|
this.isTrusted = isTrusted;
|
|
this.workspace = isTrusted
|
|
? this._workspaceFile
|
|
: this.createEmptyWorkspace(this._workspaceFile);
|
|
this._merged = this.computeMergedSettings();
|
|
coreEvents.emitSettingsChanged();
|
|
}
|
|
|
|
private createEmptyWorkspace(workspace: SettingsFile): SettingsFile {
|
|
return {
|
|
...workspace,
|
|
settings: {},
|
|
originalSettings: {},
|
|
};
|
|
}
|
|
|
|
private computeMergedSettings(): MergedSettings {
|
|
const merged = mergeSettings(
|
|
this.system.settings,
|
|
this.systemDefaults.settings,
|
|
this.user.settings,
|
|
this.workspace.settings,
|
|
this.isTrusted,
|
|
);
|
|
|
|
// Remote admin settings always take precedence and file-based admin settings
|
|
// are ignored.
|
|
const adminSettingSchema = getSettingsSchema().admin;
|
|
if (adminSettingSchema?.properties) {
|
|
const adminSchema = adminSettingSchema.properties;
|
|
const adminDefaults = getDefaultsFromSchema(adminSchema);
|
|
|
|
// The final admin settings are the defaults overridden by remote settings.
|
|
// Any admin settings from files are ignored.
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
merged.admin = customDeepMerge(
|
|
(path: string[]) => getMergeStrategyForPath(['admin', ...path]),
|
|
adminDefaults,
|
|
this._remoteAdminSettings?.admin ?? {},
|
|
) as MergedSettings['admin'];
|
|
}
|
|
return merged;
|
|
}
|
|
|
|
private computeSnapshot(): LoadedSettingsSnapshot {
|
|
const cloneSettingsFile = (file: SettingsFile): SettingsFile => ({
|
|
path: file.path,
|
|
rawJson: file.rawJson,
|
|
settings: structuredClone(file.settings),
|
|
originalSettings: structuredClone(file.originalSettings),
|
|
});
|
|
return {
|
|
system: cloneSettingsFile(this.system),
|
|
systemDefaults: cloneSettingsFile(this.systemDefaults),
|
|
user: cloneSettingsFile(this.user),
|
|
workspace: cloneSettingsFile(this.workspace),
|
|
isTrusted: this.isTrusted,
|
|
errors: [...this.errors],
|
|
merged: structuredClone(this._merged),
|
|
};
|
|
}
|
|
|
|
// Passing this along with getSnapshot to useSyncExternalStore allows for idiomatic reactivity on settings changes
|
|
// React will pass a listener fn into this subscribe fn
|
|
// that listener fn will perform an object identity check on the snapshot and trigger a React re render if the snapshot has changed
|
|
subscribe(listener: () => void): () => void {
|
|
coreEvents.on(CoreEvent.SettingsChanged, listener);
|
|
return () => coreEvents.off(CoreEvent.SettingsChanged, listener);
|
|
}
|
|
|
|
getSnapshot(): LoadedSettingsSnapshot {
|
|
return this._snapshot;
|
|
}
|
|
|
|
forScope(scope: LoadableSettingScope): SettingsFile {
|
|
switch (scope) {
|
|
case SettingScope.User:
|
|
return this.user;
|
|
case SettingScope.Workspace:
|
|
return this.workspace;
|
|
case SettingScope.System:
|
|
return this.system;
|
|
case SettingScope.SystemDefaults:
|
|
return this.systemDefaults;
|
|
default:
|
|
throw new Error(`Invalid scope: ${scope}`);
|
|
}
|
|
}
|
|
|
|
private isPersistable(settingsFile: SettingsFile): boolean {
|
|
return !settingsFile.readOnly;
|
|
}
|
|
|
|
setValue(scope: LoadableSettingScope, key: string, value: unknown): void {
|
|
const settingsFile = this.forScope(scope);
|
|
|
|
// Clone value to prevent reference sharing
|
|
const valueToSet =
|
|
typeof value === 'object' && value !== null
|
|
? structuredClone(value)
|
|
: value;
|
|
|
|
setNestedProperty(settingsFile.settings, key, valueToSet);
|
|
|
|
if (this.isPersistable(settingsFile)) {
|
|
// Use a fresh clone for originalSettings to ensure total independence
|
|
setNestedProperty(
|
|
settingsFile.originalSettings,
|
|
key,
|
|
structuredClone(valueToSet),
|
|
);
|
|
saveSettings(settingsFile);
|
|
}
|
|
|
|
this._merged = this.computeMergedSettings();
|
|
this._snapshot = this.computeSnapshot();
|
|
coreEvents.emitSettingsChanged();
|
|
}
|
|
|
|
setRemoteAdminSettings(remoteSettings: AdminControlsSettings): void {
|
|
const admin: Settings['admin'] = {};
|
|
const { strictModeDisabled, mcpSetting, cliFeatureSetting } =
|
|
remoteSettings;
|
|
|
|
if (Object.keys(remoteSettings).length === 0) {
|
|
this._remoteAdminSettings = { admin };
|
|
this._merged = this.computeMergedSettings();
|
|
return;
|
|
}
|
|
|
|
admin.secureModeEnabled = !strictModeDisabled;
|
|
admin.mcp = {
|
|
enabled: mcpSetting?.mcpEnabled,
|
|
config: mcpSetting?.mcpConfig?.mcpServers,
|
|
};
|
|
admin.extensions = {
|
|
enabled: cliFeatureSetting?.extensionsSetting?.extensionsEnabled,
|
|
};
|
|
admin.skills = {
|
|
enabled: cliFeatureSetting?.unmanagedCapabilitiesEnabled,
|
|
};
|
|
|
|
this._remoteAdminSettings = { admin };
|
|
this._merged = this.computeMergedSettings();
|
|
}
|
|
}
|
|
|
|
function findEnvFile(startDir: string): string | null {
|
|
let currentDir = path.resolve(startDir);
|
|
while (true) {
|
|
// prefer gemini-specific .env under GEMINI_DIR
|
|
const geminiEnvPath = path.join(currentDir, GEMINI_DIR, '.env');
|
|
if (fs.existsSync(geminiEnvPath)) {
|
|
return geminiEnvPath;
|
|
}
|
|
const envPath = path.join(currentDir, '.env');
|
|
if (fs.existsSync(envPath)) {
|
|
return envPath;
|
|
}
|
|
const parentDir = path.dirname(currentDir);
|
|
if (parentDir === currentDir || !parentDir) {
|
|
// check .env under home as fallback, again preferring gemini-specific .env
|
|
const homeGeminiEnvPath = path.join(homedir(), GEMINI_DIR, '.env');
|
|
if (fs.existsSync(homeGeminiEnvPath)) {
|
|
return homeGeminiEnvPath;
|
|
}
|
|
const homeEnvPath = path.join(homedir(), '.env');
|
|
if (fs.existsSync(homeEnvPath)) {
|
|
return homeEnvPath;
|
|
}
|
|
return null;
|
|
}
|
|
currentDir = parentDir;
|
|
}
|
|
}
|
|
|
|
export function setUpCloudShellEnvironment(
|
|
envFilePath: string | null,
|
|
isTrusted: boolean,
|
|
isSandboxed: boolean,
|
|
): void {
|
|
// Special handling for GOOGLE_CLOUD_PROJECT in Cloud Shell:
|
|
// Because GOOGLE_CLOUD_PROJECT in Cloud Shell tracks the project
|
|
// set by the user using "gcloud config set project" we do not want to
|
|
// use its value. So, unless the user overrides GOOGLE_CLOUD_PROJECT in
|
|
// one of the .env files, we set the Cloud Shell-specific default here.
|
|
let value = 'cloudshell-gca';
|
|
|
|
if (envFilePath && fs.existsSync(envFilePath)) {
|
|
const envFileContent = fs.readFileSync(envFilePath);
|
|
const parsedEnv = dotenv.parse(envFileContent);
|
|
if (parsedEnv['GOOGLE_CLOUD_PROJECT']) {
|
|
// .env file takes precedence in Cloud Shell
|
|
value = parsedEnv['GOOGLE_CLOUD_PROJECT'];
|
|
if (!isTrusted && isSandboxed) {
|
|
value = sanitizeEnvVar(value);
|
|
}
|
|
}
|
|
}
|
|
process.env['GOOGLE_CLOUD_PROJECT'] = value;
|
|
}
|
|
|
|
export function loadEnvironment(
|
|
settings: Settings,
|
|
workspaceDir: string,
|
|
isWorkspaceTrustedFn = isWorkspaceTrusted,
|
|
): void {
|
|
const envFilePath = findEnvFile(workspaceDir);
|
|
const trustResult = isWorkspaceTrustedFn(settings, workspaceDir);
|
|
|
|
const isTrusted = trustResult.isTrusted ?? false;
|
|
// Check settings OR check process.argv directly since this might be called
|
|
// before arguments are fully parsed. This is a best-effort sniffing approach
|
|
// that happens early in the CLI lifecycle. It is designed to detect the
|
|
// sandbox flag before the full command-line parser is initialized to ensure
|
|
// security constraints are applied when loading environment variables.
|
|
const args = process.argv.slice(2);
|
|
const doubleDashIndex = args.indexOf('--');
|
|
const relevantArgs =
|
|
doubleDashIndex === -1 ? args : args.slice(0, doubleDashIndex);
|
|
|
|
const isSandboxed =
|
|
!!settings.tools?.sandbox ||
|
|
relevantArgs.includes('-s') ||
|
|
relevantArgs.includes('--sandbox');
|
|
|
|
// Cloud Shell environment variable handling
|
|
if (process.env['CLOUD_SHELL'] === 'true') {
|
|
setUpCloudShellEnvironment(envFilePath, isTrusted, isSandboxed);
|
|
}
|
|
|
|
if (envFilePath) {
|
|
// Manually parse and load environment variables to handle exclusions correctly.
|
|
// This avoids modifying environment variables that were already set from the shell.
|
|
try {
|
|
const envFileContent = fs.readFileSync(envFilePath, 'utf-8');
|
|
const parsedEnv = dotenv.parse(envFileContent);
|
|
|
|
const excludedVars =
|
|
settings?.advanced?.excludedEnvVars || DEFAULT_EXCLUDED_ENV_VARS;
|
|
const isProjectEnvFile = !envFilePath.includes(GEMINI_DIR);
|
|
|
|
for (const key in parsedEnv) {
|
|
if (Object.hasOwn(parsedEnv, key)) {
|
|
let value = parsedEnv[key];
|
|
// If the workspace is untrusted but we are sandboxed, only allow whitelisted variables.
|
|
if (!isTrusted && isSandboxed) {
|
|
if (!AUTH_ENV_VAR_WHITELIST.includes(key)) {
|
|
continue;
|
|
}
|
|
// Sanitize the value for untrusted sources
|
|
value = sanitizeEnvVar(value);
|
|
}
|
|
|
|
// If it's a project .env file, skip loading excluded variables.
|
|
if (isProjectEnvFile && excludedVars.includes(key)) {
|
|
continue;
|
|
}
|
|
|
|
// Load variable only if it's not already set in the environment.
|
|
if (!Object.hasOwn(process.env, key)) {
|
|
process.env[key] = value;
|
|
}
|
|
}
|
|
}
|
|
} catch (_e) {
|
|
// Errors are ignored to match the behavior of `dotenv.config({ quiet: true })`.
|
|
}
|
|
}
|
|
}
|
|
|
|
// Cache to store the results of loadSettings to avoid redundant disk I/O.
|
|
const settingsCache = createCache<string, LoadedSettings>({
|
|
storage: 'map',
|
|
defaultTtl: 10000, // 10 seconds
|
|
});
|
|
|
|
/**
|
|
* Resets the settings cache. Used exclusively for test isolation.
|
|
* @internal
|
|
*/
|
|
export function resetSettingsCacheForTesting() {
|
|
settingsCache.clear();
|
|
}
|
|
|
|
/**
|
|
* Loads settings from user and workspace directories.
|
|
* Project settings override user settings.
|
|
*/
|
|
export function loadSettings(
|
|
workspaceDir: string = process.cwd(),
|
|
): LoadedSettings {
|
|
const normalizedWorkspaceDir = path.resolve(workspaceDir);
|
|
return settingsCache.getOrCreate(normalizedWorkspaceDir, () =>
|
|
_doLoadSettings(normalizedWorkspaceDir),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Internal implementation of the settings loading logic.
|
|
*/
|
|
function _doLoadSettings(workspaceDir: string): LoadedSettings {
|
|
let systemSettings: Settings = {};
|
|
let systemDefaultSettings: Settings = {};
|
|
let userSettings: Settings = {};
|
|
let workspaceSettings: Settings = {};
|
|
const settingsErrors: SettingsError[] = [];
|
|
const systemSettingsPath = getSystemSettingsPath();
|
|
const systemDefaultsPath = getSystemDefaultsPath();
|
|
|
|
const storage = new Storage(workspaceDir);
|
|
const workspaceSettingsPath = storage.getWorkspaceSettingsPath();
|
|
|
|
const load = (filePath: string): { settings: Settings; rawJson?: string } => {
|
|
try {
|
|
if (fs.existsSync(filePath)) {
|
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
const rawSettings: unknown = JSON.parse(stripJsonComments(content));
|
|
|
|
if (
|
|
typeof rawSettings !== 'object' ||
|
|
rawSettings === null ||
|
|
Array.isArray(rawSettings)
|
|
) {
|
|
settingsErrors.push({
|
|
message: 'Settings file is not a valid JSON object.',
|
|
path: filePath,
|
|
severity: 'error',
|
|
});
|
|
return { settings: {} };
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
const settingsObject = rawSettings as Record<string, unknown>;
|
|
|
|
// Validate settings structure with Zod
|
|
const validationResult = validateSettings(settingsObject);
|
|
if (!validationResult.success && validationResult.error) {
|
|
const errorMessage = formatValidationError(
|
|
validationResult.error,
|
|
filePath,
|
|
);
|
|
settingsErrors.push({
|
|
message: errorMessage,
|
|
path: filePath,
|
|
severity: 'warning',
|
|
});
|
|
}
|
|
|
|
return { settings: settingsObject as Settings, rawJson: content };
|
|
}
|
|
} catch (error: unknown) {
|
|
settingsErrors.push({
|
|
message: getErrorMessage(error),
|
|
path: filePath,
|
|
severity: 'error',
|
|
});
|
|
}
|
|
return { settings: {} };
|
|
};
|
|
|
|
const systemResult = load(systemSettingsPath);
|
|
const systemDefaultsResult = load(systemDefaultsPath);
|
|
const userResult = load(USER_SETTINGS_PATH);
|
|
|
|
let workspaceResult: { settings: Settings; rawJson?: string } = {
|
|
settings: {} as Settings,
|
|
rawJson: undefined,
|
|
};
|
|
if (!storage.isWorkspaceHomeDir()) {
|
|
workspaceResult = load(workspaceSettingsPath);
|
|
}
|
|
|
|
const systemOriginalSettings = structuredClone(systemResult.settings);
|
|
const systemDefaultsOriginalSettings = structuredClone(
|
|
systemDefaultsResult.settings,
|
|
);
|
|
const userOriginalSettings = structuredClone(userResult.settings);
|
|
const workspaceOriginalSettings = structuredClone(workspaceResult.settings);
|
|
|
|
// Environment variables for runtime use
|
|
systemSettings = resolveEnvVarsInObject(systemResult.settings);
|
|
systemDefaultSettings = resolveEnvVarsInObject(systemDefaultsResult.settings);
|
|
userSettings = resolveEnvVarsInObject(userResult.settings);
|
|
workspaceSettings = resolveEnvVarsInObject(workspaceResult.settings);
|
|
|
|
// Support legacy theme names
|
|
if (userSettings.ui?.theme === 'VS') {
|
|
userSettings.ui.theme = DefaultLight.name;
|
|
} else if (userSettings.ui?.theme === 'VS2015') {
|
|
userSettings.ui.theme = DefaultDark.name;
|
|
}
|
|
if (workspaceSettings.ui?.theme === 'VS') {
|
|
workspaceSettings.ui.theme = DefaultLight.name;
|
|
} else if (workspaceSettings.ui?.theme === 'VS2015') {
|
|
workspaceSettings.ui.theme = DefaultDark.name;
|
|
}
|
|
|
|
// For the initial trust check, we can only use user and system settings.
|
|
const initialTrustCheckSettings = customDeepMerge(
|
|
getMergeStrategyForPath,
|
|
getDefaultsFromSchema(),
|
|
systemDefaultSettings,
|
|
userSettings,
|
|
systemSettings,
|
|
);
|
|
const isTrusted =
|
|
isWorkspaceTrusted(initialTrustCheckSettings as Settings, workspaceDir)
|
|
.isTrusted ?? false;
|
|
|
|
// Create a temporary merged settings object to pass to loadEnvironment.
|
|
const tempMergedSettings = mergeSettings(
|
|
systemSettings,
|
|
systemDefaultSettings,
|
|
userSettings,
|
|
workspaceSettings,
|
|
isTrusted,
|
|
);
|
|
|
|
// loadEnvironment depends on settings so we have to create a temp version of
|
|
// the settings to avoid a cycle
|
|
loadEnvironment(tempMergedSettings, workspaceDir);
|
|
|
|
// Check for any fatal errors before proceeding
|
|
const fatalErrors = settingsErrors.filter((e) => e.severity === 'error');
|
|
if (fatalErrors.length > 0) {
|
|
const errorMessages = fatalErrors.map(
|
|
(error) => `Error in ${error.path}: ${error.message}`,
|
|
);
|
|
throw new FatalConfigError(
|
|
`${errorMessages.join('\n')}\nPlease fix the configuration file(s) and try again.`,
|
|
);
|
|
}
|
|
|
|
const loadedSettings = new LoadedSettings(
|
|
{
|
|
path: systemSettingsPath,
|
|
settings: systemSettings,
|
|
originalSettings: systemOriginalSettings,
|
|
rawJson: systemResult.rawJson,
|
|
readOnly: true,
|
|
},
|
|
{
|
|
path: systemDefaultsPath,
|
|
settings: systemDefaultSettings,
|
|
originalSettings: systemDefaultsOriginalSettings,
|
|
rawJson: systemDefaultsResult.rawJson,
|
|
readOnly: true,
|
|
},
|
|
{
|
|
path: USER_SETTINGS_PATH,
|
|
settings: userSettings,
|
|
originalSettings: userOriginalSettings,
|
|
rawJson: userResult.rawJson,
|
|
readOnly: false,
|
|
},
|
|
{
|
|
path: storage.isWorkspaceHomeDir() ? '' : workspaceSettingsPath,
|
|
settings: workspaceSettings,
|
|
originalSettings: workspaceOriginalSettings,
|
|
rawJson: workspaceResult.rawJson,
|
|
readOnly: storage.isWorkspaceHomeDir(),
|
|
},
|
|
isTrusted,
|
|
settingsErrors,
|
|
);
|
|
|
|
// Automatically migrate deprecated settings when loading.
|
|
migrateDeprecatedSettings(loadedSettings);
|
|
|
|
return loadedSettings;
|
|
}
|
|
|
|
/**
|
|
* Migrates deprecated settings to their new counterparts.
|
|
*
|
|
* Deprecated settings are removed from settings files by default.
|
|
*
|
|
* @returns true if any changes were made and need to be saved.
|
|
*/
|
|
export function migrateDeprecatedSettings(
|
|
loadedSettings: LoadedSettings,
|
|
removeDeprecated = true,
|
|
): boolean {
|
|
let anyModified = false;
|
|
const systemWarnings: Map<LoadableSettingScope, string[]> = new Map();
|
|
|
|
/**
|
|
* Helper to migrate a boolean setting and track it if it's deprecated.
|
|
*/
|
|
const migrateBoolean = (
|
|
settings: Record<string, unknown>,
|
|
oldKey: string,
|
|
newKey: string,
|
|
prefix: string,
|
|
foundDeprecated?: string[],
|
|
): boolean => {
|
|
let modified = false;
|
|
const oldValue = settings[oldKey];
|
|
const newValue = settings[newKey];
|
|
|
|
if (typeof oldValue === 'boolean') {
|
|
if (foundDeprecated) {
|
|
foundDeprecated.push(prefix ? `${prefix}.${oldKey}` : oldKey);
|
|
}
|
|
if (typeof newValue === 'boolean') {
|
|
// Both exist, trust the new one
|
|
if (removeDeprecated) {
|
|
delete settings[oldKey];
|
|
modified = true;
|
|
}
|
|
} else {
|
|
// Only old exists, migrate to new (inverted)
|
|
settings[newKey] = !oldValue;
|
|
if (removeDeprecated) {
|
|
delete settings[oldKey];
|
|
}
|
|
modified = true;
|
|
}
|
|
}
|
|
return modified;
|
|
};
|
|
|
|
const processScope = (scope: LoadableSettingScope) => {
|
|
const settingsFile = loadedSettings.forScope(scope);
|
|
const settings = settingsFile.settings;
|
|
const foundDeprecated: string[] = [];
|
|
|
|
// Migrate general settings
|
|
const generalSettings = settings.general as
|
|
| Record<string, unknown>
|
|
| undefined;
|
|
if (generalSettings) {
|
|
const newGeneral = { ...generalSettings };
|
|
let modified = false;
|
|
|
|
modified =
|
|
migrateBoolean(
|
|
newGeneral,
|
|
'disableAutoUpdate',
|
|
'enableAutoUpdate',
|
|
'general',
|
|
foundDeprecated,
|
|
) || modified;
|
|
modified =
|
|
migrateBoolean(
|
|
newGeneral,
|
|
'disableUpdateNag',
|
|
'enableAutoUpdateNotification',
|
|
'general',
|
|
foundDeprecated,
|
|
) || modified;
|
|
|
|
if (modified) {
|
|
loadedSettings.setValue(scope, 'general', newGeneral);
|
|
if (!settingsFile.readOnly) {
|
|
anyModified = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Migrate ui settings
|
|
const uiSettings = settings.ui as Record<string, unknown> | undefined;
|
|
if (uiSettings) {
|
|
const newUi = { ...uiSettings };
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
const accessibilitySettings = newUi['accessibility'] as
|
|
| Record<string, unknown>
|
|
| undefined;
|
|
|
|
if (accessibilitySettings) {
|
|
const newAccessibility = { ...accessibilitySettings };
|
|
if (
|
|
migrateBoolean(
|
|
newAccessibility,
|
|
'disableLoadingPhrases',
|
|
'enableLoadingPhrases',
|
|
'ui.accessibility',
|
|
foundDeprecated,
|
|
)
|
|
) {
|
|
newUi['accessibility'] = newAccessibility;
|
|
loadedSettings.setValue(scope, 'ui', newUi);
|
|
if (!settingsFile.readOnly) {
|
|
anyModified = true;
|
|
}
|
|
}
|
|
|
|
// Migrate enableLoadingPhrases: false → loadingPhrases: 'off'
|
|
const enableLP = newAccessibility['enableLoadingPhrases'];
|
|
if (
|
|
typeof enableLP === 'boolean' &&
|
|
newUi['loadingPhrases'] === undefined
|
|
) {
|
|
if (!enableLP) {
|
|
newUi['loadingPhrases'] = 'off';
|
|
loadedSettings.setValue(scope, 'ui', newUi);
|
|
if (!settingsFile.readOnly) {
|
|
anyModified = true;
|
|
}
|
|
}
|
|
foundDeprecated.push('ui.accessibility.enableLoadingPhrases');
|
|
}
|
|
}
|
|
}
|
|
|
|
// Migrate context settings
|
|
const contextSettings = settings.context as
|
|
| Record<string, unknown>
|
|
| undefined;
|
|
if (contextSettings) {
|
|
const newContext = { ...contextSettings };
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
const fileFilteringSettings = newContext['fileFiltering'] as
|
|
| Record<string, unknown>
|
|
| undefined;
|
|
|
|
if (fileFilteringSettings) {
|
|
const newFileFiltering = { ...fileFilteringSettings };
|
|
if (
|
|
migrateBoolean(
|
|
newFileFiltering,
|
|
'disableFuzzySearch',
|
|
'enableFuzzySearch',
|
|
'context.fileFiltering',
|
|
foundDeprecated,
|
|
)
|
|
) {
|
|
newContext['fileFiltering'] = newFileFiltering;
|
|
loadedSettings.setValue(scope, 'context', newContext);
|
|
if (!settingsFile.readOnly) {
|
|
anyModified = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Migrate tools settings
|
|
const toolsSettings = settings.tools as Record<string, unknown> | undefined;
|
|
if (toolsSettings) {
|
|
if (toolsSettings['approvalMode'] !== undefined) {
|
|
foundDeprecated.push('tools.approvalMode');
|
|
|
|
const generalSettings =
|
|
(settings.general as Record<string, unknown> | undefined) || {};
|
|
const newGeneral = { ...generalSettings };
|
|
|
|
// Only set defaultApprovalMode if it's not already set
|
|
if (newGeneral['defaultApprovalMode'] === undefined) {
|
|
newGeneral['defaultApprovalMode'] = toolsSettings['approvalMode'];
|
|
loadedSettings.setValue(scope, 'general', newGeneral);
|
|
if (!settingsFile.readOnly) {
|
|
anyModified = true;
|
|
}
|
|
}
|
|
|
|
if (removeDeprecated) {
|
|
const newTools = { ...toolsSettings };
|
|
delete newTools['approvalMode'];
|
|
loadedSettings.setValue(scope, 'tools', newTools);
|
|
if (!settingsFile.readOnly) {
|
|
anyModified = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Migrate experimental agent settings
|
|
const experimentalModified = migrateExperimentalSettings(
|
|
settings,
|
|
loadedSettings,
|
|
scope,
|
|
removeDeprecated,
|
|
foundDeprecated,
|
|
);
|
|
|
|
if (experimentalModified) {
|
|
if (!settingsFile.readOnly) {
|
|
anyModified = true;
|
|
}
|
|
}
|
|
|
|
if (settingsFile.readOnly && foundDeprecated.length > 0) {
|
|
systemWarnings.set(scope, foundDeprecated);
|
|
}
|
|
};
|
|
|
|
processScope(SettingScope.User);
|
|
processScope(SettingScope.Workspace);
|
|
processScope(SettingScope.System);
|
|
processScope(SettingScope.SystemDefaults);
|
|
|
|
if (systemWarnings.size > 0) {
|
|
for (const [scope, flags] of systemWarnings) {
|
|
const scopeName =
|
|
scope === SettingScope.SystemDefaults
|
|
? 'system default'
|
|
: scope.toLowerCase();
|
|
coreEvents.emitFeedback(
|
|
'warning',
|
|
`The ${scopeName} configuration contains deprecated settings: [${flags.join(', ')}]. These could not be migrated automatically as system settings are read-only. Please update the system configuration manually.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
return anyModified;
|
|
}
|
|
|
|
export function saveSettings(settingsFile: SettingsFile): void {
|
|
// Clear the entire cache on any save.
|
|
settingsCache.clear();
|
|
|
|
try {
|
|
// Ensure the directory exists
|
|
const dirPath = path.dirname(settingsFile.path);
|
|
if (!fs.existsSync(dirPath)) {
|
|
fs.mkdirSync(dirPath, { recursive: true });
|
|
}
|
|
|
|
const settingsToSave = settingsFile.originalSettings;
|
|
|
|
// Use the format-preserving update function
|
|
updateSettingsFilePreservingFormat(
|
|
settingsFile.path,
|
|
settingsToSave as Record<string, unknown>,
|
|
);
|
|
} catch (error) {
|
|
coreEvents.emitFeedback(
|
|
'error',
|
|
'There was an error saving your latest settings changes.',
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
|
|
export function saveModelChange(
|
|
loadedSettings: LoadedSettings,
|
|
model: string,
|
|
): void {
|
|
try {
|
|
loadedSettings.setValue(SettingScope.User, 'model.name', model);
|
|
} catch (error) {
|
|
coreEvents.emitFeedback(
|
|
'error',
|
|
'There was an error saving your preferred model.',
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
|
|
function migrateExperimentalSettings(
|
|
settings: Settings,
|
|
loadedSettings: LoadedSettings,
|
|
scope: LoadableSettingScope,
|
|
removeDeprecated: boolean,
|
|
foundDeprecated?: string[],
|
|
): boolean {
|
|
const experimentalSettings = settings.experimental as
|
|
| Record<string, unknown>
|
|
| undefined;
|
|
|
|
if (experimentalSettings) {
|
|
const agentsSettings = {
|
|
...(settings.agents as Record<string, unknown> | undefined),
|
|
};
|
|
const agentsOverrides = {
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
...((agentsSettings['overrides'] as Record<string, unknown>) || {}),
|
|
};
|
|
let modified = false;
|
|
|
|
const migrateExperimental = (
|
|
oldKey: string,
|
|
migrateFn: (oldValue: Record<string, unknown>) => void,
|
|
) => {
|
|
const old = experimentalSettings[oldKey];
|
|
if (old) {
|
|
foundDeprecated?.push(`experimental.${oldKey}`);
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
migrateFn(old as Record<string, unknown>);
|
|
modified = true;
|
|
}
|
|
};
|
|
|
|
// Migrate codebaseInvestigatorSettings -> agents.overrides.codebase_investigator
|
|
migrateExperimental('codebaseInvestigatorSettings', (old) => {
|
|
const override = {
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
...(agentsOverrides['codebase_investigator'] as
|
|
| Record<string, unknown>
|
|
| undefined),
|
|
};
|
|
|
|
if (old['enabled'] !== undefined) override['enabled'] = old['enabled'];
|
|
|
|
const runConfig = {
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
...(override['runConfig'] as Record<string, unknown> | undefined),
|
|
};
|
|
if (old['maxNumTurns'] !== undefined)
|
|
runConfig['maxTurns'] = old['maxNumTurns'];
|
|
if (old['maxTimeMinutes'] !== undefined)
|
|
runConfig['maxTimeMinutes'] = old['maxTimeMinutes'];
|
|
if (Object.keys(runConfig).length > 0) override['runConfig'] = runConfig;
|
|
|
|
if (old['model'] !== undefined || old['thinkingBudget'] !== undefined) {
|
|
const modelConfig = {
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
...(override['modelConfig'] as Record<string, unknown> | undefined),
|
|
};
|
|
if (old['model'] !== undefined) modelConfig['model'] = old['model'];
|
|
if (old['thinkingBudget'] !== undefined) {
|
|
const generateContentConfig = {
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
...(modelConfig['generateContentConfig'] as
|
|
| Record<string, unknown>
|
|
| undefined),
|
|
};
|
|
const thinkingConfig = {
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
...(generateContentConfig['thinkingConfig'] as
|
|
| Record<string, unknown>
|
|
| undefined),
|
|
};
|
|
thinkingConfig['thinkingBudget'] = old['thinkingBudget'];
|
|
generateContentConfig['thinkingConfig'] = thinkingConfig;
|
|
modelConfig['generateContentConfig'] = generateContentConfig;
|
|
}
|
|
override['modelConfig'] = modelConfig;
|
|
}
|
|
|
|
agentsOverrides['codebase_investigator'] = override;
|
|
});
|
|
|
|
// Migrate cliHelpAgentSettings -> agents.overrides.cli_help
|
|
migrateExperimental('cliHelpAgentSettings', (old) => {
|
|
const override = {
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
...(agentsOverrides['cli_help'] as Record<string, unknown> | undefined),
|
|
};
|
|
if (old['enabled'] !== undefined) override['enabled'] = old['enabled'];
|
|
agentsOverrides['cli_help'] = override;
|
|
});
|
|
|
|
if (modified) {
|
|
agentsSettings['overrides'] = agentsOverrides;
|
|
loadedSettings.setValue(scope, 'agents', agentsSettings);
|
|
|
|
if (removeDeprecated) {
|
|
const newExperimental = { ...experimentalSettings };
|
|
delete newExperimental['codebaseInvestigatorSettings'];
|
|
delete newExperimental['cliHelpAgentSettings'];
|
|
loadedSettings.setValue(scope, 'experimental', newExperimental);
|
|
}
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|