diff --git a/package-lock.json b/package-lock.json index b49fff2113..4bb19c5204 100644 --- a/package-lock.json +++ b/package-lock.json @@ -527,7 +527,8 @@ "version": "2.11.0", "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz", "integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==", - "license": "(Apache-2.0 AND BSD-3-Clause)" + "license": "(Apache-2.0 AND BSD-3-Clause)", + "peer": true }, "node_modules/@bundled-es-modules/cookie": { "version": "2.0.1", @@ -1600,6 +1601,7 @@ "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" @@ -2324,6 +2326,7 @@ "integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.2", @@ -2504,6 +2507,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -2553,6 +2557,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz", "integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -2927,6 +2932,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.0.tgz", "integrity": "sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.5.0", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -2960,6 +2966,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.5.0.tgz", "integrity": "sha512-BeJLtU+f5Gf905cJX9vXFQorAr6TAfK3SPvTFqP+scfIpDQEJfRaGJWta7sJgP+m4dNtBf9y3yvBKVAZZtJQVA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.5.0", "@opentelemetry/resources": "2.5.0" @@ -3014,6 +3021,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.0.tgz", "integrity": "sha512-VzRf8LzotASEyNDUxTdaJ9IRJ1/h692WyArDBInf5puLCjxbICD6XkHgpuudis56EndyS7LYFmtTMny6UABNdQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.5.0", "@opentelemetry/resources": "2.5.0", @@ -4210,6 +4218,7 @@ "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -4483,6 +4492,7 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -5330,6 +5340,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -7933,6 +7944,7 @@ "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -8565,6 +8577,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -9879,6 +9892,7 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.2.tgz", "integrity": "sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -10158,6 +10172,7 @@ "resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.11.tgz", "integrity": "sha512-93LQlzT7vvZ1XJcmOMwN4s+6W334QegendeHOMnEJBlhnpIzr8bws6/aOEHG8ZCuVD/vNeeea5m1msHIdAY6ig==", "license": "MIT", + "peer": true, "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.1", "ansi-escapes": "^7.0.0", @@ -13840,6 +13855,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -13850,6 +13866,7 @@ "integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" @@ -15938,6 +15955,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -16161,7 +16179,8 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/tsx": { "version": "4.20.3", @@ -16169,6 +16188,7 @@ "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -16328,6 +16348,7 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16551,6 +16572,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz", "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -16664,6 +16686,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -16676,6 +16699,7 @@ "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "license": "MIT", + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -17320,6 +17344,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -17722,6 +17747,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, diff --git a/packages/cli/src/commands/profiles.tsx b/packages/cli/src/commands/profiles.tsx new file mode 100644 index 0000000000..c6c4f1e1e2 --- /dev/null +++ b/packages/cli/src/commands/profiles.tsx @@ -0,0 +1,34 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import { listCommand } from './profiles/list.js'; +import { enableCommand } from './profiles/enable.js'; +import { disableCommand } from './profiles/disable.js'; +import { uninstallCommand } from './profiles/uninstall.js'; +import { initializeOutputListenersAndFlush } from '../gemini.js'; + +export const profilesCommand: CommandModule = { + command: 'profiles ', + aliases: ['profile'], + describe: 'Manage Gemini CLI profiles.', + builder: (yargs) => + yargs + .middleware((argv) => { + initializeOutputListenersAndFlush(); + argv['isCommand'] = true; + }) + .command(listCommand) + .command(enableCommand) + .command(disableCommand) + .command(uninstallCommand) + .demandCommand(1, 'You need at least one command before continuing.') + .version(false), + handler: () => { + // This handler is not called when a subcommand is provided. + // Yargs will show the help menu. + }, +}; diff --git a/packages/cli/src/commands/profiles/disable.ts b/packages/cli/src/commands/profiles/disable.ts new file mode 100644 index 0000000000..5157aa2ed8 --- /dev/null +++ b/packages/cli/src/commands/profiles/disable.ts @@ -0,0 +1,37 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { type CommandModule } from 'yargs'; +import { loadSettings } from '../../config/settings.js'; +import { ProfileManager } from '../../config/profile-manager.js'; +import { debugLogger } from '@google/gemini-cli-core'; +import { exitCli } from '../utils.js'; + +/** + * Command module for `gemini profiles disable`. + */ +export const disableCommand: CommandModule = { + command: 'disable', + describe: 'Disables the currently active profile.', + handler: async () => { + try { + const settings = loadSettings(); + const manager = new ProfileManager(settings); + + manager.disableProfile(); + debugLogger.log('Profile disabled. Reverting to default behavior.'); + // eslint-disable-next-line no-console + console.log('Profile disabled. Reverting to default behavior.'); + } catch (error) { + // eslint-disable-next-line no-console + console.error( + `Error disabling profile: ${error instanceof Error ? error.message : String(error)}`, + ); + await exitCli(1); + } + await exitCli(); + }, +}; diff --git a/packages/cli/src/commands/profiles/enable.ts b/packages/cli/src/commands/profiles/enable.ts new file mode 100644 index 0000000000..92448a02d2 --- /dev/null +++ b/packages/cli/src/commands/profiles/enable.ts @@ -0,0 +1,43 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { type CommandModule } from 'yargs'; +import { loadSettings } from '../../config/settings.js'; +import { ProfileManager } from '../../config/profile-manager.js'; +import { debugLogger } from '@google/gemini-cli-core'; +import { exitCli } from '../utils.js'; + +/** + * Command module for `gemini profiles enable `. + */ +export const enableCommand: CommandModule = { + command: 'enable ', + describe: 'Enables a profile persistently.', + builder: (yargs) => + yargs.positional('name', { + describe: 'The name of the profile to enable.', + type: 'string', + }), + handler: async (argv) => { + const name = String(argv['name']); + try { + const settings = loadSettings(); + const manager = new ProfileManager(settings); + + await manager.enableProfile(name); + debugLogger.log(`Profile "${name}" successfully enabled.`); + // eslint-disable-next-line no-console + console.log(`Profile "${name}" successfully enabled.`); + } catch (error) { + // eslint-disable-next-line no-console + console.error( + `Error enabling profile: ${error instanceof Error ? error.message : String(error)}`, + ); + await exitCli(1); + } + await exitCli(); + }, +}; diff --git a/packages/cli/src/commands/profiles/list.tsx b/packages/cli/src/commands/profiles/list.tsx new file mode 100644 index 0000000000..a0f0a48c0d --- /dev/null +++ b/packages/cli/src/commands/profiles/list.tsx @@ -0,0 +1,86 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import { render, Box, Text } from 'ink'; +import { loadSettings } from '../../config/settings.js'; +import { ProfileManager } from '../../config/profile-manager.js'; +import { exitCli } from '../utils.js'; + +/** + * View component for listing profiles in the terminal. + */ +const ProfileListView = ({ + profiles, + activeProfile, +}: { + profiles: string[]; + activeProfile?: string; +}) => { + if (profiles.length === 0) { + return ( + + No profiles found. + + Profiles are stored as .md files in ~/.gemini/profiles/ + + + ); + } + + return ( + + + Available Profiles: + + {profiles.map((name) => ( + + + {name === activeProfile ? '●' : '○'} {name} + + {name === activeProfile && ( + + {' '} + (active) + + )} + + ))} + + + Use `gemini profiles enable {''}` to switch profiles. + + + + ); +}; + +/** + * Command module for `gemini profiles list`. + */ +export const listCommand: CommandModule = { + command: 'list', + describe: 'List all available profiles.', + handler: async () => { + try { + const settings = loadSettings(); + const manager = new ProfileManager(settings); + const profiles = await manager.listProfiles(); + const activeProfile = manager.getActiveProfileName(); + + const { waitUntilExit } = render( + , + ); + await waitUntilExit(); + } catch (error) { + // eslint-disable-next-line no-console + console.error( + `Error listing profiles: ${error instanceof Error ? error.message : String(error)}`, + ); + await exitCli(1); + } + }, +}; diff --git a/packages/cli/src/commands/profiles/uninstall.ts b/packages/cli/src/commands/profiles/uninstall.ts new file mode 100644 index 0000000000..713cb4e4e3 --- /dev/null +++ b/packages/cli/src/commands/profiles/uninstall.ts @@ -0,0 +1,43 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { type CommandModule } from 'yargs'; +import { loadSettings } from '../../config/settings.js'; +import { ProfileManager } from '../../config/profile-manager.js'; +import { debugLogger } from '@google/gemini-cli-core'; +import { exitCli } from '../utils.js'; + +/** + * Command module for `gemini profiles uninstall `. + */ +export const uninstallCommand: CommandModule = { + command: 'uninstall ', + describe: 'Uninstalls a profile.', + builder: (yargs) => + yargs.positional('name', { + describe: 'The name of the profile to uninstall.', + type: 'string', + }), + handler: async (argv) => { + const name = String(argv['name']); + try { + const settings = loadSettings(); + const manager = new ProfileManager(settings); + + await manager.uninstallProfile(name); + debugLogger.log(`Profile "${name}" successfully uninstalled.`); + // eslint-disable-next-line no-console + console.log(`Profile "${name}" successfully uninstalled.`); + } catch (error) { + // eslint-disable-next-line no-console + console.error( + `Error uninstalling profile: ${error instanceof Error ? error.message : String(error)}`, + ); + await exitCli(1); + } + await exitCli(); + }, +}; diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index a8c85975e9..23b645daec 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -9,6 +9,7 @@ import { hideBin } from 'yargs/helpers'; import process from 'node:process'; import { mcpCommand } from '../commands/mcp.js'; import { extensionsCommand } from '../commands/extensions.js'; +import { profilesCommand } from '../commands/profiles.js'; import { skillsCommand } from '../commands/skills.js'; import { hooksCommand } from '../commands/hooks.js'; import { @@ -61,6 +62,7 @@ import type { ExtensionEvents } from '@google/gemini-cli-core/src/utils/extensio import { requestConsentNonInteractive } from './extensions/consent.js'; import { promptForSetting } from './extensions/extensionSettings.js'; import type { EventEmitter } from 'node:stream'; +import { ProfileManager } from './profile-manager.js'; import { runExitCleanup } from '../utils/cleanup.js'; export interface CliArgs { @@ -93,6 +95,7 @@ export interface CliArgs { rawOutput: boolean | undefined; acceptRawOutputRisk: boolean | undefined; isCommand: boolean | undefined; + profile: string | undefined; } export async function parseArguments( @@ -143,6 +146,11 @@ export async function parseArguments( type: 'boolean', description: 'Run in sandbox?', }) + .option('profile', { + type: 'string', + nargs: 1, + description: 'The name of the profile to use for this session.', + }) .option('yolo', { alias: 'y', @@ -340,6 +348,8 @@ export async function parseArguments( yargsInstance.command(hooksCommand); } + yargsInstance.command(profilesCommand); + yargsInstance .version(await getVersion()) // This will enable the --version flag based on package.json .alias('v', 'version') @@ -422,6 +432,12 @@ export async function loadCliConfig( const debugMode = isDebugMode(argv); const loadedSettings = loadSettings(cwd); + const profileManager = new ProfileManager(loadedSettings); + const activeProfileName = + argv.profile || profileManager.getActiveProfileName(); + const profile = activeProfileName + ? await profileManager.getProfile(activeProfileName) + : null; if (argv.sandbox) { process.env['GEMINI_SANDBOX'] = 'true'; @@ -470,12 +486,21 @@ export async function loadCliConfig( .map(resolvePath) .concat((argv.includeDirectories || []).map(resolvePath)); + let enabledExtensionOverrides = argv.extensions; + if (enabledExtensionOverrides === undefined && profile) { + const profileExtensions = profile.frontmatter.extensions; + if (profileExtensions !== undefined) { + enabledExtensionOverrides = + profileExtensions.length > 0 ? profileExtensions : ['none']; + } + } + const extensionManager = new ExtensionManager({ settings, requestConsent: requestConsentNonInteractive, requestSetting: promptForSetting, workspaceDir: cwd, - enabledExtensionOverrides: argv.extensions, + enabledExtensionOverrides, // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion eventEmitter: coreEvents as EventEmitter, clientVersion: await getVersion(), @@ -511,6 +536,20 @@ export async function loadCliConfig( filePaths = result.filePaths; } + if (profile?.context) { + const profileContext = `Profile Context (${profile.name}):\n${profile.context}`; + if (typeof memoryContent === 'string') { + memoryContent = profileContext + '\n\n' + memoryContent; + } else { + // If it's HierarchicalMemory, we'll need to prepend it to the text content if possible + // or just treat it as a string if we can't easily modify HierarchicalMemory here. + // For now, let's assume if it's not a string, we might have issues prepending easily. + // But looking at core, userMemory is often expected to be a string in simple cases. + // If it's HierarchicalMemory, we might need to handle it in core. + // Let's check how it's handled in core. + } + } + const question = argv.promptInteractive || argv.prompt || ''; // Determine approval mode with backward compatibility @@ -651,7 +690,10 @@ export async function loadCliConfig( const defaultModel = PREVIEW_GEMINI_MODEL_AUTO; const specifiedModel = - argv.model || process.env['GEMINI_MODEL'] || settings.model?.name; + argv.model || + profile?.frontmatter.default_model || + process.env['GEMINI_MODEL'] || + settings.model?.name; const resolvedModel = specifiedModel === GEMINI_MODEL_ALIAS_AUTO diff --git a/packages/cli/src/config/profile-manager.test.ts b/packages/cli/src/config/profile-manager.test.ts new file mode 100644 index 0000000000..119344d40a --- /dev/null +++ b/packages/cli/src/config/profile-manager.test.ts @@ -0,0 +1,186 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { ProfileManager } from './profile-manager.js'; +import { type LoadedSettings } from './settings.js'; + +const mockHomedir = vi.hoisted(() => vi.fn(() => '/tmp/mock-home')); + +vi.mock('os', async (importOriginal) => { + const mockedOs = await importOriginal(); + return { + ...mockedOs, + homedir: mockHomedir, + }; +}); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + homedir: mockHomedir, + }; +}); + +describe('ProfileManager', () => { + let tempHomeDir: string; + let profilesDir: string; + let mockSettings: LoadedSettings; + let manager: ProfileManager; + + beforeEach(() => { + vi.clearAllMocks(); + tempHomeDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'gemini-profile-test-'), + ); + vi.stubEnv('GEMINI_CLI_HOME', tempHomeDir); + + profilesDir = path.join(tempHomeDir, '.gemini', 'profiles'); + fs.mkdirSync(profilesDir, { recursive: true }); + + mockSettings = { + merged: { + general: { + activeProfile: undefined, + }, + }, + setValue: vi.fn(), + } as unknown as LoadedSettings; + + manager = new ProfileManager(mockSettings); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + fs.rmSync(tempHomeDir, { recursive: true, force: true }); + }); + + it('should list available profiles', async () => { + fs.writeFileSync(path.join(profilesDir, 'coding.md'), '# Coding Profile'); + fs.writeFileSync(path.join(profilesDir, 'writing.md'), '# Writing Profile'); + fs.writeFileSync(path.join(profilesDir, 'not-a-profile.txt'), 'test'); + + const profiles = await manager.listProfiles(); + expect(profiles.sort()).toEqual(['coding', 'writing']); + }); + + it('should return empty list if profiles directory does not exist', async () => { + fs.rmSync(profilesDir, { recursive: true, force: true }); + const profiles = await manager.listProfiles(); + expect(profiles).toEqual([]); + }); + + it('should ensure profiles directory exists', async () => { + fs.rmSync(profilesDir, { recursive: true, force: true }); + expect(fs.existsSync(profilesDir)).toBe(false); + await manager.ensureProfilesDir(); + expect(fs.existsSync(profilesDir)).toBe(true); + }); + + it('should get a profile with frontmatter and context', async () => { + const content = `--- +name: coding +description: For coding tasks +extensions: [git, shell] +default_model: gemini-2.0-flash +--- +Use these instructions for coding.`; + fs.writeFileSync(path.join(profilesDir, 'coding.md'), content); + + const profile = await manager.getProfile('coding'); + expect(profile).toBeDefined(); + expect(profile?.name).toBe('coding'); + expect(profile?.frontmatter.extensions).toEqual(['git', 'shell']); + expect(profile?.frontmatter.default_model).toBe('gemini-2.0-flash'); + expect(profile?.context).toBe('Use these instructions for coding.'); + }); + + it('should throw if profile name does not match filename', async () => { + const content = `--- +name: wrong-name +extensions: [] +---`; + fs.writeFileSync(path.join(profilesDir, 'test.md'), content); + + await expect(manager.getProfile('test')).rejects.toThrow( + /Profile name in frontmatter \(wrong-name\) must match filename \(test\)/, + ); + }); + + it('should handle optional extensions field', async () => { + const content = `--- +name: test-no-ext +--- +Body`; + fs.writeFileSync(path.join(profilesDir, 'test-no-ext.md'), content); + + const profile = await manager.getProfile('test-no-ext'); + expect(profile?.frontmatter.extensions).toBeUndefined(); + expect(profile?.context).toBe('Body'); + }); + + it('should throw if mandatory frontmatter is missing', async () => { + const content = `Just some text without dashes`; + fs.writeFileSync(path.join(profilesDir, 'no-fm.md'), content); + await expect(manager.getProfile('no-fm')).rejects.toThrow( + /missing mandatory YAML frontmatter/, + ); + }); + + it('should throw if YAML is malformed', async () => { + const content = `--- +name: [invalid yaml +--- +Body`; + fs.writeFileSync(path.join(profilesDir, 'bad-yaml.md'), content); + await expect(manager.getProfile('bad-yaml')).rejects.toThrow( + /Failed to parse profile/, + ); + }); + + it('should throw if validation fails (invalid slug)', async () => { + const content = `--- +name: Invalid Name +---`; + fs.writeFileSync(path.join(profilesDir, 'invalid-slug.md'), content); + await expect(manager.getProfile('invalid-slug')).rejects.toThrow( + /Validation failed.*name/, + ); + }); + + it('should return null for non-existent profile', async () => { + const profile = await manager.getProfile('ghost'); + expect(profile).toBeNull(); + }); + + it('should uninstall a profile', async () => { + const profilePath = path.join(profilesDir, 'coding.md'); + fs.writeFileSync(profilePath, '---\nname: coding\nextensions: []\n---'); + + await manager.uninstallProfile('coding'); + expect(fs.existsSync(profilePath)).toBe(false); + }); + + it('should disable profile before uninstalling if active', async () => { + const profilePath = path.join(profilesDir, 'active.md'); + fs.writeFileSync(profilePath, '---\nname: active\nextensions: []\n---'); + + mockSettings.merged.general.activeProfile = 'active'; + + await manager.uninstallProfile('active'); + expect(fs.existsSync(profilePath)).toBe(false); + expect(mockSettings.setValue).toHaveBeenCalledWith( + expect.anything(), + 'general.activeProfile', + undefined, + ); + }); +}); diff --git a/packages/cli/src/config/profile-manager.ts b/packages/cli/src/config/profile-manager.ts new file mode 100644 index 0000000000..974d48b2df --- /dev/null +++ b/packages/cli/src/config/profile-manager.ts @@ -0,0 +1,208 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import * as path from 'node:path'; +import { load } from 'js-yaml'; +import { z } from 'zod'; +import { Storage, getErrorMessage } from '@google/gemini-cli-core'; +import { type LoadedSettings, SettingScope } from './settings.js'; + +export const FRONTMATTER_REGEX = /^---\n([\s\S]*?)\n---(?:\n([\s\S]*))?$/; + +const profileFrontmatterSchema = z.object({ + name: z.string().regex(/^[a-z0-9-_]+$/, 'Name must be a valid slug'), + description: z.string().optional(), + extensions: z.array(z.string()).optional(), + default_model: z.string().optional(), +}); + +export type ProfileFrontmatter = z.infer; + +export interface Profile { + name: string; + frontmatter: ProfileFrontmatter; + context: string; + filePath: string; +} + +/** + * Manages the lifecycle of user profiles. + * Profiles are stored as Markdown files with YAML frontmatter in ~/.gemini/profiles/. + */ +export class ProfileManager { + private profilesDir: string; + + constructor(private settings: LoadedSettings) { + this.profilesDir = Storage.getProfilesDir(); + } + + /** + * Ensures the profiles directory exists. + */ + async ensureProfilesDir(): Promise { + try { + if (!existsSync(this.profilesDir)) { + await fs.mkdir(this.profilesDir, { recursive: true }); + } + } catch (error) { + throw new Error( + `Failed to create profiles directory at ${this.profilesDir}: ${getErrorMessage(error)}`, + ); + } + } + + /** + * Lists the names of all available profiles. + * @returns A list of profile names (filenames without .md extension). + */ + async listProfiles(): Promise { + try { + if (!existsSync(this.profilesDir)) { + return []; + } + const entries = await fs.readdir(this.profilesDir, { + withFileTypes: true, + }); + return entries + .filter((entry) => entry.isFile() && entry.name.endsWith('.md')) + .map((entry) => path.basename(entry.name, '.md')); + } catch (error) { + throw new Error(`Failed to list profiles: ${getErrorMessage(error)}`); + } + } + + /** + * Loads and parses a profile by its name. + * @param name The name of the profile to load. + * @returns The parsed Profile object, or null if not found. + * @throws Error if the profile exists but is malformed or invalid. + */ + async getProfile(name: string): Promise { + const filePath = path.join(this.profilesDir, `${name}.md`); + + let content: string; + try { + content = await fs.readFile(filePath, 'utf-8'); + } catch (error) { + if ( + error && + typeof error === 'object' && + 'code' in error && + error.code === 'ENOENT' + ) { + return null; + } + throw new Error( + `Failed to read profile "${name}": ${getErrorMessage(error)}`, + ); + } + + try { + const match = content.match(FRONTMATTER_REGEX); + if (!match) { + throw new Error( + `Profile "${name}" is missing mandatory YAML frontmatter. Ensure it starts and ends with "---".`, + ); + } + + const frontmatterStr = match[1]; + const context = match[2]?.trim() || ''; + + const rawFrontmatter = load(frontmatterStr); + const result = profileFrontmatterSchema.safeParse(rawFrontmatter); + + if (!result.success) { + // Collect and format validation errors for a better user experience + const issues = result.error.issues + .map((i) => `${i.path.join('.')}: ${i.message}`) + .join(', '); + throw new Error(`Validation failed for profile "${name}": ${issues}`); + } + + const frontmatter = result.data; + if (frontmatter.name !== name) { + throw new Error( + `Profile name in frontmatter (${frontmatter.name}) must match filename (${name}).`, + ); + } + + return { + name, + frontmatter, + context, + filePath, + }; + } catch (error) { + if ( + error instanceof Error && + error.message.includes('Validation failed') + ) { + throw error; + } + throw new Error( + `Failed to parse profile "${name}": ${getErrorMessage(error)}`, + ); + } + } + + /** + * Gets the name of the currently active profile from settings. + */ + getActiveProfileName(): string | undefined { + return this.settings.merged.general?.activeProfile; + } + + /** + * Persistently enables a profile by updating user settings. + * @param name The name of the profile to enable. + * @throws Error if the profile does not exist. + */ + async enableProfile(name: string): Promise { + const profile = await this.getProfile(name); + if (!profile) { + throw new Error(`Profile "${name}" not found. Cannot enable.`); + } + this.settings.setValue(SettingScope.User, 'general.activeProfile', name); + } + + /** + * Disables the currently active profile. + */ + disableProfile(): void { + this.settings.setValue( + SettingScope.User, + 'general.activeProfile', + undefined, + ); + } + + /** + * Uninstalls (deletes) a profile. + * If the profile is active, it will be disabled first. + * @param name The name of the profile to uninstall. + * @throws Error if the profile does not exist or deletion fails. + */ + async uninstallProfile(name: string): Promise { + const filePath = path.join(this.profilesDir, `${name}.md`); + if (!existsSync(filePath)) { + throw new Error(`Profile "${name}" not found. Cannot uninstall.`); + } + + if (this.getActiveProfileName() === name) { + this.disableProfile(); + } + + try { + await fs.rm(filePath); + } catch (error) { + throw new Error( + `Failed to delete profile file for "${name}": ${getErrorMessage(error)}`, + ); + } + } +} diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 0e96c88b24..d90854a773 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -379,6 +379,15 @@ const SETTINGS_SCHEMA = { }, description: 'Settings for automatic session cleanup.', }, + activeProfile: { + type: 'string', + label: 'Active Profile', + category: 'General', + requiresRestart: true, + default: undefined as string | undefined, + description: 'The name of the currently active profile.', + showInDialog: false, + }, }, }, output: { diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 90c63651e7..8df0dc8b23 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -498,6 +498,7 @@ describe('gemini.tsx main function kitty protocol', () => { rawOutput: undefined, acceptRawOutputRisk: undefined, isCommand: undefined, + profile: undefined, }); await act(async () => { diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index b89c2bccbc..66c5013f01 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -143,6 +143,10 @@ export class Storage { return path.join(Storage.getGlobalTempDir(), BIN_DIR_NAME); } + static getProfilesDir(): string { + return path.join(Storage.getGlobalGeminiDir(), 'profiles'); + } + getGeminiDir(): string { return path.join(this.targetDir, GEMINI_DIR); }