487 lines
14 KiB
TypeScript
487 lines
14 KiB
TypeScript
/**
|
|
* @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<K extends keyof Settings>(
|
|
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<T>(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);
|
|
}
|
|
}
|