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 Custom theme example 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; } }