diff --git a/docs/cli/themes.md b/docs/cli/themes.md
index 25d6123c..ca87d2ec 100644
--- a/docs/cli/themes.md
+++ b/docs/cli/themes.md
@@ -28,6 +28,8 @@ Gemini CLI comes with a selection of pre-defined themes, which you can list usin
3. Using the arrow keys, select a theme. Some interfaces might offer a live preview or highlight as you select.
4. Confirm your selection to apply the theme.
+**Note:** If a theme is defined in your `settings.json` file (either by name or by a file path), you must remove the `"theme"` setting from the file before you can change the theme using the `/theme` command.
+
### Theme Persistence
Selected themes are saved in Gemini CLI's [configuration](./configuration.md) so your preference is remembered across sessions.
@@ -105,6 +107,46 @@ You can use either hex codes (e.g., `#FF0000`) **or** standard CSS color names (
You can define multiple custom themes by adding more entries to the `customThemes` object.
+### Loading Themes from a File
+
+In addition to defining custom themes in `settings.json`, you can also load a theme directly from a JSON file by specifying the file path in your `settings.json`. This is useful for sharing themes or keeping them separate from your main configuration.
+
+To load a theme from a file, set the `theme` property in your `settings.json` to the path of your theme file:
+
+```json
+{
+ "theme": "/path/to/your/theme.json"
+}
+```
+
+The theme file must be a valid JSON file that follows the same structure as a custom theme defined in `settings.json`.
+
+**Example `my-theme.json`:**
+
+```json
+{
+ "name": "My File Theme",
+ "type": "custom",
+ "Background": "#282A36",
+ "Foreground": "#F8F8F2",
+ "LightBlue": "#82AAFF",
+ "AccentBlue": "#61AFEF",
+ "AccentPurple": "#BD93F9",
+ "AccentCyan": "#8BE9FD",
+ "AccentGreen": "#50FA7B",
+ "AccentYellow": "#F1FA8C",
+ "AccentRed": "#FF5555",
+ "Comment": "#6272A4",
+ "Gray": "#ABB2BF",
+ "DiffAdded": "#A6E3A1",
+ "DiffRemoved": "#F38BA8",
+ "DiffModified": "#89B4FA",
+ "GradientColors": ["#4796E4", "#847ACE", "#C3677F"]
+}
+```
+
+**Security Note:** For your safety, Gemini CLI will only load theme files that are located within your home directory. If you attempt to load a theme from outside your home directory, a warning will be displayed and the theme will not be loaded. This is to prevent loading potentially malicious theme files from untrusted sources.
+
### Example Custom Theme
diff --git a/packages/cli/src/ui/themes/theme-manager.test.ts b/packages/cli/src/ui/themes/theme-manager.test.ts
index 91e51bf9..183b203c 100644
--- a/packages/cli/src/ui/themes/theme-manager.test.ts
+++ b/packages/cli/src/ui/themes/theme-manager.test.ts
@@ -9,9 +9,22 @@ if (process.env['NO_COLOR'] !== undefined) {
delete process.env['NO_COLOR'];
}
-import { describe, it, expect, beforeEach } from 'vitest';
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { themeManager, DEFAULT_THEME } from './theme-manager.js';
import { CustomTheme } from './theme.js';
+import * as fs from 'node:fs';
+import * as os from 'node:os';
+import type * as osActual from 'node:os';
+
+vi.mock('node:fs');
+vi.mock('node:os', async (importOriginal) => {
+ const actualOs = await importOriginal();
+ return {
+ ...actualOs,
+ homedir: vi.fn(),
+ platform: vi.fn(() => 'linux'),
+ };
+});
const validCustomTheme: CustomTheme = {
type: 'custom',
@@ -38,6 +51,10 @@ describe('ThemeManager', () => {
themeManager.setActiveTheme(DEFAULT_THEME.name);
});
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
it('should load valid custom themes', () => {
themeManager.loadCustomThemes({ MyCustomTheme: validCustomTheme });
expect(themeManager.getCustomThemeNames()).toContain('MyCustomTheme');
@@ -96,4 +113,69 @@ describe('ThemeManager', () => {
process.env['NO_COLOR'] = original;
}
});
+
+ describe('when loading a theme from a file', () => {
+ const mockThemePath = './my-theme.json';
+ const mockTheme: CustomTheme = {
+ ...validCustomTheme,
+ name: 'My File Theme',
+ };
+
+ beforeEach(() => {
+ vi.mocked(os.homedir).mockReturnValue('/home/user');
+ vi.spyOn(fs, 'realpathSync').mockImplementation((p) => p as string);
+ });
+
+ it('should load a theme from a valid file path', () => {
+ vi.spyOn(fs, 'existsSync').mockReturnValue(true);
+ vi.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(mockTheme));
+
+ const result = themeManager.setActiveTheme('/home/user/my-theme.json');
+
+ expect(result).toBe(true);
+ const activeTheme = themeManager.getActiveTheme();
+ expect(activeTheme.name).toBe('My File Theme');
+ expect(fs.readFileSync).toHaveBeenCalledWith(
+ expect.stringContaining('my-theme.json'),
+ 'utf-8',
+ );
+ });
+
+ it('should not load a theme if the file does not exist', () => {
+ vi.spyOn(fs, 'existsSync').mockReturnValue(false);
+
+ const result = themeManager.setActiveTheme(mockThemePath);
+
+ expect(result).toBe(false);
+ expect(themeManager.getActiveTheme().name).toBe(DEFAULT_THEME.name);
+ });
+
+ it('should not load a theme from a file with invalid JSON', () => {
+ vi.spyOn(fs, 'existsSync').mockReturnValue(true);
+ vi.spyOn(fs, 'readFileSync').mockReturnValue('invalid json');
+
+ const result = themeManager.setActiveTheme(mockThemePath);
+
+ expect(result).toBe(false);
+ expect(themeManager.getActiveTheme().name).toBe(DEFAULT_THEME.name);
+ });
+
+ it('should not load a theme from an untrusted file path and log a message', () => {
+ vi.spyOn(fs, 'existsSync').mockReturnValue(true);
+ vi.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(mockTheme));
+ const consoleWarnSpy = vi
+ .spyOn(console, 'warn')
+ .mockImplementation(() => {});
+
+ const result = themeManager.setActiveTheme('/untrusted/my-theme.json');
+
+ expect(result).toBe(false);
+ expect(themeManager.getActiveTheme().name).toBe(DEFAULT_THEME.name);
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
+ expect.stringContaining('is outside your home directory'),
+ );
+
+ consoleWarnSpy.mockRestore();
+ });
+ });
});
diff --git a/packages/cli/src/ui/themes/theme-manager.ts b/packages/cli/src/ui/themes/theme-manager.ts
index 5258bf76..26b0fd3b 100644
--- a/packages/cli/src/ui/themes/theme-manager.ts
+++ b/packages/cli/src/ui/themes/theme-manager.ts
@@ -15,6 +15,9 @@ import { DefaultLight } from './default-light.js';
import { DefaultDark } from './default.js';
import { ShadesOfPurple } from './shades-of-purple.js';
import { XCode } from './xcode.js';
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import * as os from 'node:os';
import {
Theme,
ThemeType,
@@ -128,10 +131,22 @@ class ThemeManager {
if (process.env['NO_COLOR']) {
return NoColorTheme;
}
- // Ensure the active theme is always valid (fall back to default if not)
- if (!this.activeTheme || !this.findThemeByName(this.activeTheme.name)) {
- this.activeTheme = DEFAULT_THEME;
+
+ if (this.activeTheme) {
+ const isBuiltIn = this.availableThemes.some(
+ (t) => t.name === this.activeTheme.name,
+ );
+ const isCustom = [...this.customThemes.values()].includes(
+ this.activeTheme,
+ );
+
+ if (isBuiltIn || isCustom) {
+ return this.activeTheme;
+ }
}
+
+ // Fallback to default if no active theme or if it's no longer valid.
+ this.activeTheme = DEFAULT_THEME;
return this.activeTheme;
}
@@ -215,6 +230,73 @@ class ThemeManager {
return this.findThemeByName(themeName);
}
+ private isPath(themeName: string): boolean {
+ return (
+ themeName.endsWith('.json') ||
+ themeName.startsWith('.') ||
+ path.isAbsolute(themeName)
+ );
+ }
+
+ private loadThemeFromFile(themePath: string): Theme | undefined {
+ try {
+ // realpathSync resolves the path and throws if it doesn't exist.
+ const canonicalPath = fs.realpathSync(path.resolve(themePath));
+
+ // 1. Check cache using the canonical path.
+ if (this.customThemes.has(canonicalPath)) {
+ return this.customThemes.get(canonicalPath);
+ }
+
+ // 2. Perform security check.
+ const homeDir = path.resolve(os.homedir());
+ if (!canonicalPath.startsWith(homeDir)) {
+ console.warn(
+ `Theme file at "${themePath}" is outside your home directory. ` +
+ `Only load themes from trusted sources.`,
+ );
+ return undefined;
+ }
+
+ // 3. Read, parse, and validate the theme file.
+ const themeContent = fs.readFileSync(canonicalPath, 'utf-8');
+ const customThemeConfig = JSON.parse(themeContent) as CustomTheme;
+
+ const validation = validateCustomTheme(customThemeConfig);
+ if (!validation.isValid) {
+ console.warn(
+ `Invalid custom theme from file "${themePath}": ${validation.error}`,
+ );
+ return undefined;
+ }
+
+ if (validation.warning) {
+ console.warn(`Theme from "${themePath}": ${validation.warning}`);
+ }
+
+ // 4. Create and cache the theme.
+ const themeWithDefaults: CustomTheme = {
+ ...DEFAULT_THEME.colors,
+ ...customThemeConfig,
+ name: customThemeConfig.name || canonicalPath,
+ type: 'custom',
+ };
+
+ const theme = createCustomTheme(themeWithDefaults);
+ this.customThemes.set(canonicalPath, theme); // Cache by canonical path
+ return theme;
+ } catch (error) {
+ // Any error in the process (file not found, bad JSON, etc.) is caught here.
+ // We can return undefined silently for file-not-found, and warn for others.
+ if (
+ !(error instanceof Error && 'code' in error && error.code === 'ENOENT')
+ ) {
+ console.warn(`Could not load theme from file "${themePath}":`, error);
+ }
+ return undefined;
+ }
+ }
+
findThemeByName(themeName: string | undefined): Theme | undefined {
if (!themeName) {
return DEFAULT_THEME;
@@ -228,8 +310,18 @@ class ThemeManager {
return builtInTheme;
}
- // Then check custom themes
- return this.customThemes.get(themeName);
+ // Then check custom themes that have been loaded from settings, or file paths
+ if (this.isPath(themeName)) {
+ return this.loadThemeFromFile(themeName);
+ }
+
+ if (this.customThemes.has(themeName)) {
+ return this.customThemes.get(themeName);
+ }
+
+ // If it's not a built-in, not in cache, and not a valid file path,
+ // it's not a valid theme.
+ return undefined;
}
}