summaryrefslogtreecommitdiff
path: root/packages/cli/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/cli/src')
-rw-r--r--packages/cli/src/config/config.ts1
-rw-r--r--packages/cli/src/config/settings.test.ts167
-rw-r--r--packages/cli/src/config/settings.ts42
-rw-r--r--packages/cli/src/gemini.test.tsx5
-rw-r--r--packages/cli/src/ui/App.test.tsx62
-rw-r--r--packages/cli/src/ui/components/AuthDialog.test.tsx14
-rw-r--r--packages/cli/src/ui/components/ThemeDialog.tsx20
7 files changed, 284 insertions, 27 deletions
diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts
index ca38814a..20a8afcf 100644
--- a/packages/cli/src/config/config.ts
+++ b/packages/cli/src/config/config.ts
@@ -168,6 +168,7 @@ async function parseArguments(): Promise<CliArgs> {
type: 'boolean',
description: 'List all available extensions and exit.',
})
+
.version(await getCliVersion()) // This will enable the --version flag based on package.json
.alias('v', 'version')
.help()
diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts
index e7565457..44de24fe 100644
--- a/packages/cli/src/config/settings.test.ts
+++ b/packages/cli/src/config/settings.test.ts
@@ -13,6 +13,7 @@ vi.mock('os', async (importOriginal) => {
return {
...actualOs,
homedir: vi.fn(() => '/mock/home/user'),
+ platform: vi.fn(() => 'linux'),
};
});
@@ -45,6 +46,7 @@ import stripJsonComments from 'strip-json-comments'; // Will be mocked separatel
import {
loadSettings,
USER_SETTINGS_PATH, // This IS the mocked path.
+ SYSTEM_SETTINGS_PATH,
SETTINGS_DIRECTORY_NAME, // This is from the original module, but used by the mock.
SettingScope,
} from './settings.js';
@@ -90,12 +92,41 @@ describe('Settings Loading and Merging', () => {
describe('loadSettings', () => {
it('should load empty settings if no files exist', () => {
const settings = loadSettings(MOCK_WORKSPACE_DIR);
+ expect(settings.system.settings).toEqual({});
expect(settings.user.settings).toEqual({});
expect(settings.workspace.settings).toEqual({});
expect(settings.merged).toEqual({});
expect(settings.errors.length).toBe(0);
});
+ it('should load system settings if only system file exists', () => {
+ (mockFsExistsSync as Mock).mockImplementation(
+ (p: fs.PathLike) => p === SYSTEM_SETTINGS_PATH,
+ );
+ const systemSettingsContent = {
+ theme: 'system-default',
+ sandbox: false,
+ };
+ (fs.readFileSync as Mock).mockImplementation(
+ (p: fs.PathOrFileDescriptor) => {
+ if (p === SYSTEM_SETTINGS_PATH)
+ return JSON.stringify(systemSettingsContent);
+ return '{}';
+ },
+ );
+
+ const settings = loadSettings(MOCK_WORKSPACE_DIR);
+
+ expect(fs.readFileSync).toHaveBeenCalledWith(
+ SYSTEM_SETTINGS_PATH,
+ 'utf-8',
+ );
+ expect(settings.system.settings).toEqual(systemSettingsContent);
+ expect(settings.user.settings).toEqual({});
+ expect(settings.workspace.settings).toEqual({});
+ expect(settings.merged).toEqual(systemSettingsContent);
+ });
+
it('should load user settings if only user file exists', () => {
const expectedUserSettingsPath = USER_SETTINGS_PATH; // Use the path actually resolved by the (mocked) module
@@ -187,6 +218,50 @@ describe('Settings Loading and Merging', () => {
});
});
+ it('should merge system, user and workspace settings, with system taking precedence over workspace, and workspace over user', () => {
+ (mockFsExistsSync as Mock).mockReturnValue(true);
+ const systemSettingsContent = {
+ theme: 'system-theme',
+ sandbox: false,
+ telemetry: { enabled: false },
+ };
+ const userSettingsContent = {
+ theme: 'dark',
+ sandbox: true,
+ contextFileName: 'USER_CONTEXT.md',
+ };
+ const workspaceSettingsContent = {
+ sandbox: false,
+ coreTools: ['tool1'],
+ contextFileName: 'WORKSPACE_CONTEXT.md',
+ };
+
+ (fs.readFileSync as Mock).mockImplementation(
+ (p: fs.PathOrFileDescriptor) => {
+ if (p === SYSTEM_SETTINGS_PATH)
+ return JSON.stringify(systemSettingsContent);
+ if (p === USER_SETTINGS_PATH)
+ return JSON.stringify(userSettingsContent);
+ if (p === MOCK_WORKSPACE_SETTINGS_PATH)
+ return JSON.stringify(workspaceSettingsContent);
+ return '';
+ },
+ );
+
+ const settings = loadSettings(MOCK_WORKSPACE_DIR);
+
+ expect(settings.system.settings).toEqual(systemSettingsContent);
+ expect(settings.user.settings).toEqual(userSettingsContent);
+ expect(settings.workspace.settings).toEqual(workspaceSettingsContent);
+ expect(settings.merged).toEqual({
+ theme: 'system-theme',
+ sandbox: false,
+ telemetry: { enabled: false },
+ coreTools: ['tool1'],
+ contextFileName: 'WORKSPACE_CONTEXT.md',
+ });
+ });
+
it('should handle contextFileName correctly when only in user settings', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
@@ -409,6 +484,50 @@ describe('Settings Loading and Merging', () => {
delete process.env.WORKSPACE_ENDPOINT;
});
+ it('should prioritize user env variables over workspace env variables if keys clash after resolution', () => {
+ const userSettingsContent = { configValue: '$SHARED_VAR' };
+ const workspaceSettingsContent = { configValue: '$SHARED_VAR' };
+
+ (mockFsExistsSync as Mock).mockReturnValue(true);
+ const originalSharedVar = process.env.SHARED_VAR;
+ // Temporarily delete to ensure a clean slate for the test's specific manipulations
+ delete process.env.SHARED_VAR;
+
+ (fs.readFileSync as Mock).mockImplementation(
+ (p: fs.PathOrFileDescriptor) => {
+ if (p === USER_SETTINGS_PATH) {
+ process.env.SHARED_VAR = 'user_value_for_user_read'; // Set for user settings read
+ return JSON.stringify(userSettingsContent);
+ }
+ if (p === MOCK_WORKSPACE_SETTINGS_PATH) {
+ process.env.SHARED_VAR = 'workspace_value_for_workspace_read'; // Set for workspace settings read
+ return JSON.stringify(workspaceSettingsContent);
+ }
+ return '{}';
+ },
+ );
+
+ const settings = loadSettings(MOCK_WORKSPACE_DIR);
+
+ expect(settings.user.settings.configValue).toBe(
+ 'user_value_for_user_read',
+ );
+ expect(settings.workspace.settings.configValue).toBe(
+ 'workspace_value_for_workspace_read',
+ );
+ // Merged should take workspace's resolved value
+ expect(settings.merged.configValue).toBe(
+ 'workspace_value_for_workspace_read',
+ );
+
+ // Restore original environment variable state
+ if (originalSharedVar !== undefined) {
+ process.env.SHARED_VAR = originalSharedVar;
+ } else {
+ delete process.env.SHARED_VAR; // Ensure it's deleted if it wasn't there before
+ }
+ });
+
it('should prioritize workspace env variables over user env variables if keys clash after resolution', () => {
const userSettingsContent = { configValue: '$SHARED_VAR' };
const workspaceSettingsContent = { configValue: '$SHARED_VAR' };
@@ -453,6 +572,48 @@ describe('Settings Loading and Merging', () => {
}
});
+ it('should prioritize system env variables over workspace env variables if keys clash after resolution', () => {
+ const workspaceSettingsContent = { configValue: '$SHARED_VAR' };
+ const systemSettingsContent = { configValue: '$SHARED_VAR' };
+
+ (mockFsExistsSync as Mock).mockReturnValue(true);
+ const originalSharedVar = process.env.SHARED_VAR;
+ // Temporarily delete to ensure a clean slate for the test's specific manipulations
+ delete process.env.SHARED_VAR;
+
+ (fs.readFileSync as Mock).mockImplementation(
+ (p: fs.PathOrFileDescriptor) => {
+ if (p === SYSTEM_SETTINGS_PATH) {
+ process.env.SHARED_VAR = 'system_value_for_system_read'; // Set for system settings read
+ return JSON.stringify(systemSettingsContent);
+ }
+ if (p === MOCK_WORKSPACE_SETTINGS_PATH) {
+ process.env.SHARED_VAR = 'workspace_value_for_workspace_read'; // Set for workspace settings read
+ return JSON.stringify(workspaceSettingsContent);
+ }
+ return '{}';
+ },
+ );
+
+ const settings = loadSettings(MOCK_WORKSPACE_DIR);
+
+ expect(settings.system.settings.configValue).toBe(
+ 'system_value_for_system_read',
+ );
+ expect(settings.workspace.settings.configValue).toBe(
+ 'workspace_value_for_workspace_read',
+ );
+ // Merged should take workspace's resolved value
+ expect(settings.merged.configValue).toBe('system_value_for_system_read');
+
+ // Restore original environment variable state
+ if (originalSharedVar !== undefined) {
+ process.env.SHARED_VAR = originalSharedVar;
+ } else {
+ delete process.env.SHARED_VAR; // Ensure it's deleted if it wasn't there before
+ }
+ });
+
it('should leave unresolved environment variables as is', () => {
const userSettingsContent = { apiKey: '$UNDEFINED_VAR' };
(mockFsExistsSync as Mock).mockImplementation(
@@ -624,10 +785,10 @@ describe('Settings Loading and Merging', () => {
'utf-8',
);
- // Workspace theme overrides user theme
- loadedSettings.setValue(SettingScope.Workspace, 'theme', 'ocean');
+ // System theme overrides user and workspace themes
+ loadedSettings.setValue(SettingScope.System, 'theme', 'ocean');
- expect(loadedSettings.workspace.settings.theme).toBe('ocean');
+ expect(loadedSettings.system.settings.theme).toBe('ocean');
expect(loadedSettings.merged.theme).toBe('ocean');
});
});
diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts
index c2d03167..133701f5 100644
--- a/packages/cli/src/config/settings.ts
+++ b/packages/cli/src/config/settings.ts
@@ -6,7 +6,7 @@
import * as fs from 'fs';
import * as path from 'path';
-import { homedir } from 'os';
+import { homedir, platform } from 'os';
import * as dotenv from 'dotenv';
import {
MCPServerConfig,
@@ -24,9 +24,22 @@ export const SETTINGS_DIRECTORY_NAME = '.gemini';
export const USER_SETTINGS_DIR = path.join(homedir(), SETTINGS_DIRECTORY_NAME);
export const USER_SETTINGS_PATH = path.join(USER_SETTINGS_DIR, 'settings.json');
+function getSystemSettingsPath(): string {
+ 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 const SYSTEM_SETTINGS_PATH = getSystemSettingsPath();
+
export enum SettingScope {
User = 'User',
Workspace = 'Workspace',
+ System = 'System',
}
export interface CheckpointingSettings {
@@ -81,16 +94,19 @@ export interface SettingsFile {
}
export class LoadedSettings {
constructor(
+ system: SettingsFile,
user: SettingsFile,
workspace: SettingsFile,
errors: SettingsError[],
) {
+ this.system = system;
this.user = user;
this.workspace = workspace;
this.errors = errors;
this._merged = this.computeMergedSettings();
}
+ readonly system: SettingsFile;
readonly user: SettingsFile;
readonly workspace: SettingsFile;
readonly errors: SettingsError[];
@@ -105,6 +121,7 @@ export class LoadedSettings {
return {
...this.user.settings,
...this.workspace.settings,
+ ...this.system.settings,
};
}
@@ -114,6 +131,8 @@ export class LoadedSettings {
return this.user;
case SettingScope.Workspace:
return this.workspace;
+ case SettingScope.System:
+ return this.system;
default:
throw new Error(`Invalid scope: ${scope}`);
}
@@ -243,10 +262,27 @@ export function loadEnvironment(): void {
*/
export function loadSettings(workspaceDir: string): LoadedSettings {
loadEnvironment();
+ let systemSettings: Settings = {};
let userSettings: Settings = {};
let workspaceSettings: Settings = {};
const settingsErrors: SettingsError[] = [];
+ // Load system settings
+ try {
+ if (fs.existsSync(SYSTEM_SETTINGS_PATH)) {
+ const systemContent = fs.readFileSync(SYSTEM_SETTINGS_PATH, 'utf-8');
+ const parsedSystemSettings = JSON.parse(
+ stripJsonComments(systemContent),
+ ) as Settings;
+ systemSettings = resolveEnvVarsInObject(parsedSystemSettings);
+ }
+ } catch (error: unknown) {
+ settingsErrors.push({
+ message: getErrorMessage(error),
+ path: SYSTEM_SETTINGS_PATH,
+ });
+ }
+
// Load user settings
try {
if (fs.existsSync(USER_SETTINGS_PATH)) {
@@ -301,6 +337,10 @@ export function loadSettings(workspaceDir: string): LoadedSettings {
return new LoadedSettings(
{
+ path: SYSTEM_SETTINGS_PATH,
+ settings: systemSettings,
+ },
+ {
path: USER_SETTINGS_PATH,
settings: userSettings,
},
diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx
index 952b4a77..20350815 100644
--- a/packages/cli/src/gemini.test.tsx
+++ b/packages/cli/src/gemini.test.tsx
@@ -112,7 +112,12 @@ describe('gemini.tsx main function', () => {
path: '/workspace/.gemini/settings.json',
settings: {},
};
+ const systemSettingsFile: SettingsFile = {
+ path: '/system/settings.json',
+ settings: {},
+ };
const mockLoadedSettings = new LoadedSettings(
+ systemSettingsFile,
userSettingsFile,
workspaceSettingsFile,
[settingsError],
diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx
index 8390dac1..22547ae1 100644
--- a/packages/cli/src/ui/App.test.tsx
+++ b/packages/cli/src/ui/App.test.tsx
@@ -185,19 +185,30 @@ describe('App UI', () => {
let currentUnmount: (() => void) | undefined;
const createMockSettings = (
- settings: Partial<Settings> = {},
+ settings: {
+ system?: Partial<Settings>;
+ user?: Partial<Settings>;
+ workspace?: Partial<Settings>;
+ } = {},
): LoadedSettings => {
+ const systemSettingsFile: SettingsFile = {
+ path: '/system/settings.json',
+ settings: settings.system || {},
+ };
const userSettingsFile: SettingsFile = {
path: '/user/settings.json',
- settings: {},
+ settings: settings.user || {},
};
const workspaceSettingsFile: SettingsFile = {
path: '/workspace/.gemini/settings.json',
- settings: {
- ...settings,
- },
+ settings: settings.workspace || {},
};
- return new LoadedSettings(userSettingsFile, workspaceSettingsFile, []);
+ return new LoadedSettings(
+ systemSettingsFile,
+ userSettingsFile,
+ workspaceSettingsFile,
+ [],
+ );
};
beforeEach(() => {
@@ -222,7 +233,7 @@ describe('App UI', () => {
mockConfig.getShowMemoryUsage.mockReturnValue(false); // Default for most tests
// Ensure a theme is set so the theme dialog does not appear.
- mockSettings = createMockSettings({ theme: 'Default' });
+ mockSettings = createMockSettings({ workspace: { theme: 'Default' } });
});
afterEach(() => {
@@ -268,8 +279,7 @@ describe('App UI', () => {
it('should display custom contextFileName in footer when set and count is 1', async () => {
mockSettings = createMockSettings({
- contextFileName: 'AGENTS.md',
- theme: 'Default',
+ workspace: { contextFileName: 'AGENTS.md', theme: 'Default' },
});
mockConfig.getGeminiMdFileCount.mockReturnValue(1);
mockConfig.getDebugMode.mockReturnValue(false);
@@ -288,8 +298,10 @@ describe('App UI', () => {
it('should display a generic message when multiple context files with different names are provided', async () => {
mockSettings = createMockSettings({
- contextFileName: ['AGENTS.md', 'CONTEXT.md'],
- theme: 'Default',
+ workspace: {
+ contextFileName: ['AGENTS.md', 'CONTEXT.md'],
+ theme: 'Default',
+ },
});
mockConfig.getGeminiMdFileCount.mockReturnValue(2);
mockConfig.getDebugMode.mockReturnValue(false);
@@ -308,8 +320,7 @@ describe('App UI', () => {
it('should display custom contextFileName with plural when set and count is > 1', async () => {
mockSettings = createMockSettings({
- contextFileName: 'MY_NOTES.TXT',
- theme: 'Default',
+ workspace: { contextFileName: 'MY_NOTES.TXT', theme: 'Default' },
});
mockConfig.getGeminiMdFileCount.mockReturnValue(3);
mockConfig.getDebugMode.mockReturnValue(false);
@@ -328,8 +339,7 @@ describe('App UI', () => {
it('should not display context file message if count is 0, even if contextFileName is set', async () => {
mockSettings = createMockSettings({
- contextFileName: 'ANY_FILE.MD',
- theme: 'Default',
+ workspace: { contextFileName: 'ANY_FILE.MD', theme: 'Default' },
});
mockConfig.getGeminiMdFileCount.mockReturnValue(0);
mockConfig.getDebugMode.mockReturnValue(false);
@@ -399,7 +409,9 @@ describe('App UI', () => {
it('should not display Tips component when hideTips is true', async () => {
mockSettings = createMockSettings({
- hideTips: true,
+ workspace: {
+ hideTips: true,
+ },
});
const { unmount } = render(
@@ -413,6 +425,24 @@ describe('App UI', () => {
expect(vi.mocked(Tips)).not.toHaveBeenCalled();
});
+ it('should show tips if system says show, but workspace and user settings say hide', async () => {
+ mockSettings = createMockSettings({
+ system: { hideTips: false },
+ user: { hideTips: true },
+ workspace: { hideTips: true },
+ });
+
+ const { unmount } = render(
+ <App
+ config={mockConfig as unknown as ServerConfig}
+ settings={mockSettings}
+ />,
+ );
+ currentUnmount = unmount;
+ await Promise.resolve();
+ expect(vi.mocked(Tips)).toHaveBeenCalled();
+ });
+
describe('when no theme is set', () => {
let originalNoColor: string | undefined;
diff --git a/packages/cli/src/ui/components/AuthDialog.test.tsx b/packages/cli/src/ui/components/AuthDialog.test.tsx
index 60a8a930..d9f1bd2c 100644
--- a/packages/cli/src/ui/components/AuthDialog.test.tsx
+++ b/packages/cli/src/ui/components/AuthDialog.test.tsx
@@ -30,6 +30,10 @@ describe('AuthDialog', () => {
const settings: LoadedSettings = new LoadedSettings(
{
+ settings: {},
+ path: '',
+ },
+ {
settings: {
selectedAuthType: AuthType.USE_GEMINI,
},
@@ -87,6 +91,12 @@ describe('AuthDialog', () => {
path: '',
},
{
+ settings: {
+ selectedAuthType: undefined,
+ },
+ path: '',
+ },
+ {
settings: {},
path: '',
},
@@ -148,6 +158,10 @@ describe('AuthDialog', () => {
const onSelect = vi.fn();
const settings: LoadedSettings = new LoadedSettings(
{
+ settings: {},
+ path: '',
+ },
+ {
settings: {
selectedAuthType: AuthType.USE_GEMINI,
},
diff --git a/packages/cli/src/ui/components/ThemeDialog.tsx b/packages/cli/src/ui/components/ThemeDialog.tsx
index 9351d5a1..ba49f8e3 100644
--- a/packages/cli/src/ui/components/ThemeDialog.tsx
+++ b/packages/cli/src/ui/components/ThemeDialog.tsx
@@ -57,6 +57,7 @@ export function ThemeDialog({
const scopeItems = [
{ label: 'User Settings', value: SettingScope.User },
{ label: 'Workspace Settings', value: SettingScope.Workspace },
+ { label: 'System Settings', value: SettingScope.System },
];
const handleThemeSelect = (themeName: string) => {
@@ -86,16 +87,21 @@ export function ThemeDialog({
}
});
+ const otherScopes = Object.values(SettingScope).filter(
+ (scope) => scope !== selectedScope,
+ );
+
+ const modifiedInOtherScopes = otherScopes.filter(
+ (scope) => settings.forScope(scope).settings.theme !== undefined,
+ );
+
let otherScopeModifiedMessage = '';
- const otherScope =
- selectedScope === SettingScope.User
- ? SettingScope.Workspace
- : SettingScope.User;
- if (settings.forScope(otherScope).settings.theme !== undefined) {
+ if (modifiedInOtherScopes.length > 0) {
+ const modifiedScopesStr = modifiedInOtherScopes.join(', ');
otherScopeModifiedMessage =
settings.forScope(selectedScope).settings.theme !== undefined
- ? `(Also modified in ${otherScope})`
- : `(Modified in ${otherScope})`;
+ ? `(Also modified in ${modifiedScopesStr})`
+ : `(Modified in ${modifiedScopesStr})`;
}
// Constants for calculating preview pane layout.