/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import * as fs from 'fs'; import * as path from 'path'; import { homedir, platform } from 'os'; import * as dotenv from 'dotenv'; import { GEMINI_CONFIG_DIR as GEMINI_DIR, getErrorMessage, Storage, } from '@google/gemini-cli-core'; import stripJsonComments from 'strip-json-comments'; import { DefaultLight } from '../ui/themes/default-light.js'; import { DefaultDark } from '../ui/themes/default.js'; import { isWorkspaceTrusted } from './trustedFolders.js'; import { Settings, MemoryImportFormat } from './settingsSchema.js'; export type { Settings, MemoryImportFormat }; export const SETTINGS_DIRECTORY_NAME = '.gemini'; 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']; 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 type { DnsResolutionOrder } from './settingsSchema.js'; export enum SettingScope { User = 'User', Workspace = 'Workspace', System = 'System', } export interface CheckpointingSettings { enabled?: boolean; } export interface SummarizeToolOutputSettings { tokenBudget?: number; } export interface AccessibilitySettings { disableLoadingPhrases?: boolean; screenReader?: boolean; } export interface SettingsError { message: string; path: string; } export interface SettingsFile { settings: Settings; path: string; } function mergeSettings( system: Settings, user: Settings, workspace: Settings, isTrusted: boolean, ): Settings { const safeWorkspace = isTrusted ? workspace : ({} as Settings); // folderTrust is not supported at workspace level. // eslint-disable-next-line @typescript-eslint/no-unused-vars const { folderTrust, ...safeWorkspaceWithoutFolderTrust } = safeWorkspace; return { ...user, ...safeWorkspaceWithoutFolderTrust, ...system, customThemes: { ...(user.customThemes || {}), ...(safeWorkspace.customThemes || {}), ...(system.customThemes || {}), }, mcpServers: { ...(user.mcpServers || {}), ...(safeWorkspace.mcpServers || {}), ...(system.mcpServers || {}), }, includeDirectories: [ ...(system.includeDirectories || []), ...(user.includeDirectories || []), ...(safeWorkspace.includeDirectories || []), ], chatCompression: { ...(system.chatCompression || {}), ...(user.chatCompression || {}), ...(safeWorkspace.chatCompression || {}), }, }; } export class LoadedSettings { constructor( system: SettingsFile, user: SettingsFile, workspace: SettingsFile, errors: SettingsError[], isTrusted: boolean, ) { this.system = system; this.user = user; this.workspace = workspace; this.errors = errors; this.isTrusted = isTrusted; this._merged = this.computeMergedSettings(); } readonly system: SettingsFile; readonly user: SettingsFile; readonly workspace: SettingsFile; readonly errors: SettingsError[]; readonly isTrusted: boolean; private _merged: Settings; get merged(): Settings { return this._merged; } private computeMergedSettings(): Settings { return mergeSettings( this.system.settings, this.user.settings, this.workspace.settings, this.isTrusted, ); } forScope(scope: SettingScope): SettingsFile { switch (scope) { case SettingScope.User: return this.user; case SettingScope.Workspace: return this.workspace; case SettingScope.System: return this.system; default: throw new Error(`Invalid scope: ${scope}`); } } setValue( scope: SettingScope, key: K, value: Settings[K], ): void { const settingsFile = this.forScope(scope); settingsFile.settings[key] = value; this._merged = this.computeMergedSettings(); saveSettings(settingsFile); } } function resolveEnvVarsInString(value: string): string { const envVarRegex = /\$(?:(\w+)|{([^}]+)})/g; // Find $VAR_NAME or ${VAR_NAME} return value.replace(envVarRegex, (match, varName1, varName2) => { const varName = varName1 || varName2; if (process && process.env && typeof process.env[varName] === 'string') { return process.env[varName]!; } return match; }); } function resolveEnvVarsInObject(obj: T): T { if ( obj === null || obj === undefined || typeof obj === 'boolean' || typeof obj === 'number' ) { return obj; } if (typeof obj === 'string') { return resolveEnvVarsInString(obj) as unknown as T; } if (Array.isArray(obj)) { return obj.map((item) => resolveEnvVarsInObject(item)) as unknown as T; } if (typeof obj === 'object') { const newObj = { ...obj } as T; for (const key in newObj) { if (Object.prototype.hasOwnProperty.call(newObj, key)) { newObj[key] = resolveEnvVarsInObject(newObj[key]); } } return newObj; } return obj; } 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): 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. 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 process.env['GOOGLE_CLOUD_PROJECT'] = parsedEnv['GOOGLE_CLOUD_PROJECT']; } else { // If not in .env, set to default and override global process.env['GOOGLE_CLOUD_PROJECT'] = 'cloudshell-gca'; } } else { // If no .env file, set to default and override global process.env['GOOGLE_CLOUD_PROJECT'] = 'cloudshell-gca'; } } export function loadEnvironment(settings?: Settings): void { const envFilePath = findEnvFile(process.cwd()); // Cloud Shell environment variable handling if (process.env['CLOUD_SHELL'] === 'true') { setUpCloudShellEnvironment(envFilePath); } // If no settings provided, try to load workspace settings for exclusions let resolvedSettings = settings; if (!resolvedSettings) { const workspaceSettingsPath = new Storage( process.cwd(), ).getWorkspaceSettingsPath(); try { if (fs.existsSync(workspaceSettingsPath)) { const workspaceContent = fs.readFileSync( workspaceSettingsPath, 'utf-8', ); const parsedWorkspaceSettings = JSON.parse( stripJsonComments(workspaceContent), ) as Settings; resolvedSettings = resolveEnvVarsInObject(parsedWorkspaceSettings); } } catch (_e) { // Ignore errors loading workspace settings } } 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 = resolvedSettings?.excludedProjectEnvVars || DEFAULT_EXCLUDED_ENV_VARS; const isProjectEnvFile = !envFilePath.includes(GEMINI_DIR); for (const key in parsedEnv) { if (Object.hasOwn(parsedEnv, key)) { // 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] = parsedEnv[key]; } } } } catch (_e) { // Errors are ignored to match the behavior of `dotenv.config({ quiet: true })`. } } } /** * Loads settings from user and workspace directories. * Project settings override user settings. */ export function loadSettings(workspaceDir: string): LoadedSettings { let systemSettings: Settings = {}; let userSettings: Settings = {}; let workspaceSettings: Settings = {}; const settingsErrors: SettingsError[] = []; const systemSettingsPath = getSystemSettingsPath(); // Resolve paths to their canonical representation to handle symlinks const resolvedWorkspaceDir = path.resolve(workspaceDir); const resolvedHomeDir = path.resolve(homedir()); let realWorkspaceDir = resolvedWorkspaceDir; try { // fs.realpathSync gets the "true" path, resolving any symlinks realWorkspaceDir = fs.realpathSync(resolvedWorkspaceDir); } catch (_e) { // This is okay. The path might not exist yet, and that's a valid state. } // We expect homedir to always exist and be resolvable. const realHomeDir = fs.realpathSync(resolvedHomeDir); const workspaceSettingsPath = new Storage( workspaceDir, ).getWorkspaceSettingsPath(); // Load system settings try { if (fs.existsSync(systemSettingsPath)) { const systemContent = fs.readFileSync(systemSettingsPath, 'utf-8'); systemSettings = JSON.parse(stripJsonComments(systemContent)) as Settings; } } catch (error: unknown) { settingsErrors.push({ message: getErrorMessage(error), path: systemSettingsPath, }); } // Load user settings try { if (fs.existsSync(USER_SETTINGS_PATH)) { const userContent = fs.readFileSync(USER_SETTINGS_PATH, 'utf-8'); userSettings = JSON.parse(stripJsonComments(userContent)) as Settings; // Support legacy theme names if (userSettings.theme && userSettings.theme === 'VS') { userSettings.theme = DefaultLight.name; } else if (userSettings.theme && userSettings.theme === 'VS2015') { userSettings.theme = DefaultDark.name; } } } catch (error: unknown) { settingsErrors.push({ message: getErrorMessage(error), path: USER_SETTINGS_PATH, }); } if (realWorkspaceDir !== realHomeDir) { // Load workspace settings try { if (fs.existsSync(workspaceSettingsPath)) { const projectContent = fs.readFileSync(workspaceSettingsPath, 'utf-8'); workspaceSettings = JSON.parse( stripJsonComments(projectContent), ) as Settings; if (workspaceSettings.theme && workspaceSettings.theme === 'VS') { workspaceSettings.theme = DefaultLight.name; } else if ( workspaceSettings.theme && workspaceSettings.theme === 'VS2015' ) { workspaceSettings.theme = DefaultDark.name; } } } catch (error: unknown) { settingsErrors.push({ message: getErrorMessage(error), path: workspaceSettingsPath, }); } } // For the initial trust check, we can only use user and system settings. const initialTrustCheckSettings = { ...systemSettings, ...userSettings }; const isTrusted = isWorkspaceTrusted(initialTrustCheckSettings) ?? true; // Create a temporary merged settings object to pass to loadEnvironment. const tempMergedSettings = mergeSettings( systemSettings, userSettings, workspaceSettings, isTrusted, ); // loadEnviroment depends on settings so we have to create a temp version of // the settings to avoid a cycle loadEnvironment(tempMergedSettings); // Now that the environment is loaded, resolve variables in the settings. systemSettings = resolveEnvVarsInObject(systemSettings); userSettings = resolveEnvVarsInObject(userSettings); workspaceSettings = resolveEnvVarsInObject(workspaceSettings); // Create LoadedSettings first const loadedSettings = new LoadedSettings( { path: systemSettingsPath, settings: systemSettings, }, { path: USER_SETTINGS_PATH, settings: userSettings, }, { path: workspaceSettingsPath, settings: workspaceSettings, }, settingsErrors, isTrusted, ); // Validate chatCompression settings const chatCompression = loadedSettings.merged.chatCompression; const threshold = chatCompression?.contextPercentageThreshold; if ( threshold != null && (typeof threshold !== 'number' || threshold < 0 || threshold > 1) ) { console.warn( `Invalid value for chatCompression.contextPercentageThreshold: "${threshold}". Please use a value between 0 and 1. Using default compression settings.`, ); delete loadedSettings.merged.chatCompression; } return loadedSettings; } export function saveSettings(settingsFile: SettingsFile): void { try { // Ensure the directory exists const dirPath = path.dirname(settingsFile.path); if (!fs.existsSync(dirPath)) { fs.mkdirSync(dirPath, { recursive: true }); } fs.writeFileSync( settingsFile.path, JSON.stringify(settingsFile.settings, null, 2), 'utf-8', ); } catch (error) { console.error('Error saving user settings file:', error); } }