From 0ec0c6ec08c7b6b1afab75ee9da280b84cec513e Mon Sep 17 00:00:00 2001 From: Rahul Kamat Date: Wed, 11 Mar 2026 17:45:26 -0700 Subject: [PATCH] feat(profiles): refactor profiles to centralize logic in core Centralized profile loading and management in packages/core. Updated CLI commands to use core ProfileManager. Implemented persistent profile selection in loadCliConfig. Standardized profile format as Markdown with YAML frontmatter. Verified multi-extension loading with 'coder' profile. --- packages/cli/src/commands/profiles/disable.ts | 7 +- packages/cli/src/commands/profiles/enable.ts | 15 +- packages/cli/src/commands/profiles/install.ts | 9 +- packages/cli/src/commands/profiles/link.ts | 9 +- packages/cli/src/commands/profiles/list.tsx | 10 +- .../cli/src/commands/profiles/uninstall.ts | 8 +- packages/cli/src/config/config.ts | 27 +- .../cli/src/config/profile-manager.test.ts | 310 ------------------ packages/cli/src/config/profile-manager.ts | 292 ----------------- packages/cli/src/core/initializer.test.ts | 9 + packages/cli/src/core/initializer.ts | 5 + .../cli/src/services/BuiltinCommandLoader.ts | 2 + .../cli/src/ui/commands/profilesCommand.ts | 147 +++++++++ .../src/ui/components/HistoryItemDisplay.tsx | 8 + .../src/ui/components/views/ProfilesList.tsx | 67 ++++ packages/cli/src/ui/types.ts | 12 +- packages/core/src/commands/profiles.ts | 26 ++ packages/core/src/config/config.ts | 37 ++- packages/core/src/index.ts | 3 + .../core/src/profiles/profileLoader.test.ts | 96 ++++++ packages/core/src/profiles/profileLoader.ts | 131 ++++++++ .../core/src/profiles/profileManager.test.ts | 98 ++++++ packages/core/src/profiles/profileManager.ts | 167 ++++++++++ packages/core/src/prompts/promptProvider.ts | 1 + packages/core/src/prompts/snippets.legacy.ts | 11 + packages/core/src/prompts/snippets.ts | 11 + 26 files changed, 873 insertions(+), 645 deletions(-) delete mode 100644 packages/cli/src/config/profile-manager.test.ts delete mode 100644 packages/cli/src/config/profile-manager.ts create mode 100644 packages/cli/src/ui/commands/profilesCommand.ts create mode 100644 packages/cli/src/ui/components/views/ProfilesList.tsx create mode 100644 packages/core/src/commands/profiles.ts create mode 100644 packages/core/src/profiles/profileLoader.test.ts create mode 100644 packages/core/src/profiles/profileLoader.ts create mode 100644 packages/core/src/profiles/profileManager.test.ts create mode 100644 packages/core/src/profiles/profileManager.ts diff --git a/packages/cli/src/commands/profiles/disable.ts b/packages/cli/src/commands/profiles/disable.ts index 5157aa2ed8..9ff1b3329e 100644 --- a/packages/cli/src/commands/profiles/disable.ts +++ b/packages/cli/src/commands/profiles/disable.ts @@ -5,8 +5,7 @@ */ import { type CommandModule } from 'yargs'; -import { loadSettings } from '../../config/settings.js'; -import { ProfileManager } from '../../config/profile-manager.js'; +import { loadSettings, SettingScope } from '../../config/settings.js'; import { debugLogger } from '@google/gemini-cli-core'; import { exitCli } from '../utils.js'; @@ -19,9 +18,7 @@ export const disableCommand: CommandModule = { handler: async () => { try { const settings = loadSettings(); - const manager = new ProfileManager(settings); - - manager.disableProfile(); + settings.setValue(SettingScope.User, 'general.activeProfile', undefined); debugLogger.log('Profile disabled. Reverting to default behavior.'); // eslint-disable-next-line no-console console.log('Profile disabled. Reverting to default behavior.'); diff --git a/packages/cli/src/commands/profiles/enable.ts b/packages/cli/src/commands/profiles/enable.ts index 92448a02d2..b98d5dfcb9 100644 --- a/packages/cli/src/commands/profiles/enable.ts +++ b/packages/cli/src/commands/profiles/enable.ts @@ -5,9 +5,8 @@ */ 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 { loadSettings, SettingScope } from '../../config/settings.js'; +import { Storage, ProfileManager, debugLogger } from '@google/gemini-cli-core'; import { exitCli } from '../utils.js'; /** @@ -25,9 +24,15 @@ export const enableCommand: CommandModule = { const name = String(argv['name']); try { const settings = loadSettings(); - const manager = new ProfileManager(settings); + const profilesDir = Storage.getProfilesDir(); + const manager = new ProfileManager(profilesDir); + await manager.load(); - await manager.enableProfile(name); + if (!manager.getProfile(name)) { + throw new Error(`Profile "${name}" not found.`); + } + + settings.setValue(SettingScope.User, 'general.activeProfile', name); debugLogger.log(`Profile "${name}" successfully enabled.`); // eslint-disable-next-line no-console console.log(`Profile "${name}" successfully enabled.`); diff --git a/packages/cli/src/commands/profiles/install.ts b/packages/cli/src/commands/profiles/install.ts index 182113dece..9cd4191465 100644 --- a/packages/cli/src/commands/profiles/install.ts +++ b/packages/cli/src/commands/profiles/install.ts @@ -6,10 +6,8 @@ import type { CommandModule } from 'yargs'; import chalk from 'chalk'; -import { debugLogger } from '@google/gemini-cli-core'; +import { debugLogger, Storage, ProfileManager } from '@google/gemini-cli-core'; import { getErrorMessage } from '../../utils/errors.js'; -import { ProfileManager } from '../../config/profile-manager.js'; -import { loadSettings } from '../../config/settings.js'; import { exitCli } from '../utils.js'; interface InstallArgs { @@ -18,9 +16,8 @@ interface InstallArgs { export async function handleInstall(args: InstallArgs) { try { - const settings = loadSettings(); - const profileManager = new ProfileManager(settings); - const profile = await profileManager.installProfile(args.path); + const manager = new ProfileManager(Storage.getProfilesDir()); + const profile = await manager.installProfile(args.path); debugLogger.log( chalk.green(`Profile "${profile.name}" installed successfully.`), ); diff --git a/packages/cli/src/commands/profiles/link.ts b/packages/cli/src/commands/profiles/link.ts index 5ade430563..ef532eff78 100644 --- a/packages/cli/src/commands/profiles/link.ts +++ b/packages/cli/src/commands/profiles/link.ts @@ -6,10 +6,8 @@ import type { CommandModule } from 'yargs'; import chalk from 'chalk'; -import { debugLogger } from '@google/gemini-cli-core'; +import { debugLogger, Storage, ProfileManager } from '@google/gemini-cli-core'; import { getErrorMessage } from '../../utils/errors.js'; -import { ProfileManager } from '../../config/profile-manager.js'; -import { loadSettings } from '../../config/settings.js'; import { exitCli } from '../utils.js'; interface LinkArgs { @@ -18,9 +16,8 @@ interface LinkArgs { export async function handleLink(args: LinkArgs) { try { - const settings = loadSettings(); - const profileManager = new ProfileManager(settings); - const profile = await profileManager.linkProfile(args.path); + const manager = new ProfileManager(Storage.getProfilesDir()); + const profile = await manager.linkProfile(args.path); debugLogger.log( chalk.green(`Profile "${profile.name}" linked successfully.`), ); diff --git a/packages/cli/src/commands/profiles/list.tsx b/packages/cli/src/commands/profiles/list.tsx index a0f0a48c0d..d34cf64572 100644 --- a/packages/cli/src/commands/profiles/list.tsx +++ b/packages/cli/src/commands/profiles/list.tsx @@ -6,8 +6,8 @@ import type { CommandModule } from 'yargs'; import { render, Box, Text } from 'ink'; +import { Storage, ProfileManager } from '@google/gemini-cli-core'; import { loadSettings } from '../../config/settings.js'; -import { ProfileManager } from '../../config/profile-manager.js'; import { exitCli } from '../utils.js'; /** @@ -67,9 +67,11 @@ export const listCommand: CommandModule = { handler: async () => { try { const settings = loadSettings(); - const manager = new ProfileManager(settings); - const profiles = await manager.listProfiles(); - const activeProfile = manager.getActiveProfileName(); + const profilesDir = Storage.getProfilesDir(); + const manager = new ProfileManager(profilesDir); + await manager.load(); + const profiles = manager.getProfileNames(); + const activeProfile = settings.merged.general?.activeProfile; const { waitUntilExit } = render( , diff --git a/packages/cli/src/commands/profiles/uninstall.ts b/packages/cli/src/commands/profiles/uninstall.ts index 713cb4e4e3..670fe6385f 100644 --- a/packages/cli/src/commands/profiles/uninstall.ts +++ b/packages/cli/src/commands/profiles/uninstall.ts @@ -5,9 +5,7 @@ */ 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 { Storage, ProfileManager, debugLogger } from '@google/gemini-cli-core'; import { exitCli } from '../utils.js'; /** @@ -24,8 +22,8 @@ export const uninstallCommand: CommandModule = { handler: async (argv) => { const name = String(argv['name']); try { - const settings = loadSettings(); - const manager = new ProfileManager(settings); + const manager = new ProfileManager(Storage.getProfilesDir()); + await manager.load(); await manager.uninstallProfile(name); debugLogger.log(`Profile "${name}" successfully uninstalled.`); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 42e818fe1b..57965f5dcc 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -39,6 +39,8 @@ import { type HookDefinition, type HookEventName, type OutputFormat, + Storage as CoreStorage, + ProfileManager as CoreProfileManager, } from '@google/gemini-cli-core'; import { type Settings, @@ -62,7 +64,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'; +// Removed legacy profile manager import import { runExitCleanup } from '../utils/cleanup.js'; export interface CliArgs { @@ -436,12 +438,15 @@ export async function loadCliConfig( const { cwd = process.cwd(), projectHooks } = options; const debugMode = isDebugMode(argv); - const loadedSettings = loadSettings(cwd); - const profileManager = new ProfileManager(loadedSettings); + const profilesDir = CoreStorage.getProfilesDir(); + const coreProfileManager = new CoreProfileManager(profilesDir); + await coreProfileManager.load(); const activeProfileName = - argv.profiles || profileManager.getActiveProfileName(); + argv.profiles || + settings.general?.activeProfile || + coreProfileManager.getActiveProfileName(); const profile = activeProfileName - ? await profileManager.getProfile(activeProfileName) + ? coreProfileManager.getProfile(activeProfileName) : null; if (argv.sandbox) { @@ -493,7 +498,7 @@ export async function loadCliConfig( let enabledExtensionOverrides = argv.extensions; if (enabledExtensionOverrides === undefined && profile) { - const profileExtensions = profile.frontmatter.extensions; + const profileExtensions = profile.extensions; if (profileExtensions !== undefined) { enabledExtensionOverrides = profileExtensions.length > 0 ? profileExtensions : ['none']; @@ -541,8 +546,8 @@ export async function loadCliConfig( filePaths = result.filePaths; } - if (profile?.context) { - const profileContext = `Profile Context (${profile.name}):\n${profile.context}`; + if (profile?.body) { + const profileContext = `Profile Context (${profile.name}):\n${profile.body}`; if (typeof memoryContent === 'string') { memoryContent = profileContext + '\n\n' + memoryContent; } else { @@ -696,7 +701,7 @@ export async function loadCliConfig( const defaultModel = PREVIEW_GEMINI_MODEL_AUTO; const specifiedModel = argv.model || - profile?.frontmatter.default_model || + profile?.default_model || process.env['GEMINI_MODEL'] || settings.model?.name; @@ -768,6 +773,8 @@ export async function loadCliConfig( extensionsEnabled, agents: settings.agents, adminSkillsEnabled, + profilesDir, + activeProfile: activeProfileName || undefined, allowedMcpServers: mcpEnabled ? (argv.allowedMcpServerNames ?? settings.mcp?.allowed) : undefined, @@ -865,7 +872,7 @@ export async function loadCliConfig( hooks: settings.hooks || {}, disabledHooks: settings.hooksConfig?.disabled || [], projectHooks: projectHooks || {}, - onModelChange: (model: string) => saveModelChange(loadedSettings, model), + onModelChange: (model: string) => saveModelChange(loadSettings(cwd), model), onReload: async () => { const refreshedSettings = loadSettings(cwd); return { diff --git a/packages/cli/src/config/profile-manager.test.ts b/packages/cli/src/config/profile-manager.test.ts deleted file mode 100644 index c225ab762b..0000000000 --- a/packages/cli/src/config/profile-manager.test.ts +++ /dev/null @@ -1,310 +0,0 @@ -/** - * @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, - ); - }); -}); - -describe('ProfileManager Installation and Linking', () => { - let tempHomeDir: string; - let profilesDir: string; - let mockSettings: LoadedSettings; - let manager: ProfileManager; - let sourceDir: string; - - beforeEach(() => { - vi.clearAllMocks(); - tempHomeDir = fs.mkdtempSync( - path.join(os.tmpdir(), 'gemini-profile-test-home-'), - ); - sourceDir = fs.mkdtempSync( - path.join(os.tmpdir(), 'gemini-profile-test-source-'), - ); - 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 }); - fs.rmSync(sourceDir, { recursive: true, force: true }); - }); - - it('should install a profile by copying', async () => { - const sourcePath = path.join(sourceDir, 'new-profile.md'); - const content = `--- -name: new-profile ---- -Body`; - fs.writeFileSync(sourcePath, content); - - const profile = await manager.installProfile(sourcePath); - expect(profile.name).toBe('new-profile'); - - const installedPath = path.join(profilesDir, 'new-profile.md'); - expect(fs.existsSync(installedPath)).toBe(true); - expect(fs.readFileSync(installedPath, 'utf-8')).toBe(content); - expect(fs.lstatSync(installedPath).isSymbolicLink()).toBe(false); - }); - - it('should link a profile by creating a symlink', async () => { - const sourcePath = path.join(sourceDir, 'linked-profile.md'); - const content = `--- -name: linked-profile ---- -Body`; - fs.writeFileSync(sourcePath, content); - - const profile = await manager.linkProfile(sourcePath); - expect(profile.name).toBe('linked-profile'); - - const linkedPath = path.join(profilesDir, 'linked-profile.md'); - expect(fs.existsSync(linkedPath)).toBe(true); - expect(fs.lstatSync(linkedPath).isSymbolicLink()).toBe(true); - expect(fs.readlinkSync(linkedPath)).toBe(sourcePath); - }); - - it('should throw if installing when profile already exists', async () => { - const sourcePath = path.join(sourceDir, 'existing.md'); - fs.writeFileSync(sourcePath, '---\nname: existing\n---'); - fs.writeFileSync(path.join(profilesDir, 'existing.md'), 'orig'); - - await expect(manager.installProfile(sourcePath)).rejects.toThrow( - /Profile "existing" already exists/, - ); - }); - - it('should throw if source file does not exist', async () => { - await expect(manager.installProfile('/non/existent')).rejects.toThrow( - /Source profile file not found/, - ); - }); - - it('should throw if source file is invalid', async () => { - const sourcePath = path.join(sourceDir, 'invalid.md'); - fs.writeFileSync(sourcePath, 'not a profile'); - - await expect(manager.installProfile(sourcePath)).rejects.toThrow( - /missing mandatory YAML frontmatter/, - ); - }); - - it('should throw if linking when profile already exists', async () => { - const sourcePath = path.join(sourceDir, 'existing-link.md'); - fs.writeFileSync(sourcePath, '---\nname: existing-link\n---'); - fs.writeFileSync(path.join(profilesDir, 'existing-link.md'), 'orig'); - - await expect(manager.linkProfile(sourcePath)).rejects.toThrow( - /Profile "existing-link" already exists/, - ); - }); - - it('should uninstall a linked profile (delete link but keep source)', async () => { - const sourcePath = path.join(sourceDir, 'linked.md'); - fs.writeFileSync(sourcePath, '---\nname: linked\n---'); - await manager.linkProfile(sourcePath); - - const linkPath = path.join(profilesDir, 'linked.md'); - expect(fs.existsSync(linkPath)).toBe(true); - expect(fs.lstatSync(linkPath).isSymbolicLink()).toBe(true); - - await manager.uninstallProfile('linked'); - expect(fs.existsSync(linkPath)).toBe(false); - expect(fs.existsSync(sourcePath)).toBe(true); - }); - - it('should throw if source file is missing during linking', async () => { - await expect(manager.linkProfile('/non/existent')).rejects.toThrow( - /Source profile file not found/, - ); - }); -}); diff --git a/packages/cli/src/config/profile-manager.ts b/packages/cli/src/config/profile-manager.ts deleted file mode 100644 index 8bb6c9843e..0000000000 --- a/packages/cli/src/config/profile-manager.ts +++ /dev/null @@ -1,292 +0,0 @@ -/** - * @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)}`, - ); - } - } - - /** - * Installs a profile by copying it from a source path to the profiles directory. - * @param sourcePath Path to the profile file. - * @returns The installed Profile. - */ - async installProfile(sourcePath: string): Promise { - const absoluteSource = path.resolve(sourcePath); - if (!existsSync(absoluteSource)) { - throw new Error(`Source profile file not found at ${absoluteSource}`); - } - - // Read and validate first - const content = await fs.readFile(absoluteSource, 'utf-8'); - const match = content.match(FRONTMATTER_REGEX); - if (!match) { - throw new Error('Source file is missing mandatory YAML frontmatter.'); - } - - const frontmatterStr = match[1]; - const rawFrontmatter = load(frontmatterStr); - const result = profileFrontmatterSchema.safeParse(rawFrontmatter); - if (!result.success) { - throw new Error( - `Invalid profile frontmatter at ${absoluteSource}: ${result.error.issues.map((i) => i.message).join(', ')}`, - ); - } - - const name = result.data.name; - const destPath = path.join(this.profilesDir, `${name}.md`); - - if (existsSync(destPath)) { - throw new Error( - `Profile "${name}" already exists. Please uninstall it first or rename the source profile.`, - ); - } - - await this.ensureProfilesDir(); - await fs.copyFile(absoluteSource, destPath); - - return (await this.getProfile(name))!; - } - - /** - * Links a profile by creating a symlink in the profiles directory. - * @param sourcePath Path to the profile file. - * @returns The linked Profile. - */ - async linkProfile(sourcePath: string): Promise { - const absoluteSource = path.resolve(sourcePath); - if (!existsSync(absoluteSource)) { - throw new Error(`Source profile file not found at ${absoluteSource}`); - } - - // Read and validate first - const content = await fs.readFile(absoluteSource, 'utf-8'); - const match = content.match(FRONTMATTER_REGEX); - if (!match) { - throw new Error('Source file is missing mandatory YAML frontmatter.'); - } - - const frontmatterStr = match[1]; - const rawFrontmatter = load(frontmatterStr); - const result = profileFrontmatterSchema.safeParse(rawFrontmatter); - if (!result.success) { - throw new Error( - `Invalid profile frontmatter at ${absoluteSource}: ${result.error.issues.map((i) => i.message).join(', ')}`, - ); - } - - const name = result.data.name; - const destPath = path.join(this.profilesDir, `${name}.md`); - - if (existsSync(destPath)) { - throw new Error( - `Profile "${name}" already exists. Please uninstall it first or rename the source profile.`, - ); - } - - await this.ensureProfilesDir(); - await fs.symlink(absoluteSource, destPath); - - return (await this.getProfile(name))!; - } -} diff --git a/packages/cli/src/core/initializer.test.ts b/packages/cli/src/core/initializer.test.ts index e4fdb2cba5..3298767c1a 100644 --- a/packages/cli/src/core/initializer.test.ts +++ b/packages/cli/src/core/initializer.test.ts @@ -44,11 +44,15 @@ describe('initializer', () => { getToolRegistry: ReturnType; getIdeMode: ReturnType; getGeminiMdFileCount: ReturnType; + getProfileManager: ReturnType; }; let mockSettings: LoadedSettings; let mockIdeClient: { connect: ReturnType; }; + let mockProfileManager: { + load: ReturnType; + }; beforeEach(() => { vi.clearAllMocks(); @@ -56,7 +60,12 @@ describe('initializer', () => { getToolRegistry: vi.fn(), getIdeMode: vi.fn().mockReturnValue(false), getGeminiMdFileCount: vi.fn().mockReturnValue(5), + getProfileManager: vi.fn(), }; + mockProfileManager = { + load: vi.fn().mockResolvedValue(undefined), + }; + mockConfig.getProfileManager.mockReturnValue(mockProfileManager); mockSettings = { merged: { security: { diff --git a/packages/cli/src/core/initializer.ts b/packages/cli/src/core/initializer.ts index f27e9a9511..26b8f0f288 100644 --- a/packages/cli/src/core/initializer.ts +++ b/packages/cli/src/core/initializer.ts @@ -60,6 +60,11 @@ export async function initializeApp( logIdeConnection(config, new IdeConnectionEvent(IdeConnectionType.START)); } + // Load profiles + const profilesHandle = startupProfiler.start('load_profiles'); + await config.getProfileManager().load(); + profilesHandle?.end(); + return { authError, accountSuspensionInfo, diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 66806f5ef1..0befda353a 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -48,6 +48,7 @@ import { planCommand } from '../ui/commands/planCommand.js'; import { policiesCommand } from '../ui/commands/policiesCommand.js'; import { privacyCommand } from '../ui/commands/privacyCommand.js'; import { profileCommand } from '../ui/commands/profileCommand.js'; +import { profilesCommand } from '../ui/commands/profilesCommand.js'; import { quitCommand } from '../ui/commands/quitCommand.js'; import { restoreCommand } from '../ui/commands/restoreCommand.js'; import { resumeCommand } from '../ui/commands/resumeCommand.js'; @@ -188,6 +189,7 @@ export class BuiltinCommandLoader implements ICommandLoader { policiesCommand, privacyCommand, ...(isDevelopment ? [profileCommand] : []), + profilesCommand, quitCommand, restoreCommand(this.config), { diff --git a/packages/cli/src/ui/commands/profilesCommand.ts b/packages/cli/src/ui/commands/profilesCommand.ts new file mode 100644 index 0000000000..0b76cd887f --- /dev/null +++ b/packages/cli/src/ui/commands/profilesCommand.ts @@ -0,0 +1,147 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + type CommandContext, + type SlashCommand, + type SlashCommandActionReturn, + CommandKind, +} from './types.js'; +import { type HistoryItemProfilesList, MessageType } from '../types.js'; +import { getErrorMessage } from '../../utils/errors.js'; +import { + listProfiles, + switchProfile, + getActiveProfile, +} from '@google/gemini-cli-core'; + +async function listAction( + context: CommandContext, + args: string, +): Promise { + const subArgs = args.trim().split(/\s+/); + let useShowDescriptions = true; + + for (const arg of subArgs) { + if (arg === 'nodesc' || arg === '--nodesc') { + useShowDescriptions = false; + } + } + + const config = context.services.config; + if (!config) { + context.ui.addItem({ + type: MessageType.ERROR, + text: 'Could not retrieve configuration.', + }); + return; + } + + const profiles = listProfiles(config); + const activeProfile = getActiveProfile(config); + + const profilesListItem: HistoryItemProfilesList = { + type: MessageType.PROFILES_LIST, + profiles, + activeProfileName: activeProfile?.name, + showDescriptions: useShowDescriptions, + }; + + context.ui.addItem(profilesListItem); +} + +async function switchAction( + context: CommandContext, + args: string, +): Promise { + const profileName = args.trim(); + if (!profileName) { + context.ui.addItem({ + type: MessageType.ERROR, + text: 'Please provide a profile name to switch to.', + }); + return; + } + + const config = context.services.config; + if (!config) { + context.ui.addItem({ + type: MessageType.ERROR, + text: 'Could not retrieve configuration.', + }); + return; + } + + try { + await switchProfile(config, profileName); + context.ui.addItem({ + type: MessageType.INFO, + text: `Switched to profile: ${profileName}`, + }); + } catch (error) { + context.ui.addItem({ + type: MessageType.ERROR, + text: `Failed to switch profile: ${getErrorMessage(error)}`, + }); + } +} + +async function reloadAction( + context: CommandContext, +): Promise { + const config = context.services.config; + if (!config) { + context.ui.addItem({ + type: MessageType.ERROR, + text: 'Could not retrieve configuration.', + }); + return; + } + + try { + await config.getProfileManager().load(); + context.ui.addItem({ + type: MessageType.INFO, + text: 'Profiles reloaded successfully.', + }); + } catch (error) { + context.ui.addItem({ + type: MessageType.ERROR, + text: `Failed to reload profiles: ${getErrorMessage(error)}`, + }); + } +} + +export const profilesCommand: SlashCommand = { + name: 'profiles', + description: + 'List, switch, or reload Gemini CLI profiles. Usage: /profiles [list | switch | reload]', + kind: CommandKind.BUILT_IN, + autoExecute: false, + subCommands: [ + { + name: 'list', + description: 'List available profiles. Usage: /profiles list [nodesc]', + kind: CommandKind.BUILT_IN, + action: listAction, + }, + { + name: 'switch', + description: + 'Switch to a profile by name. Usage: /profiles switch ', + kind: CommandKind.BUILT_IN, + action: switchAction, + }, + { + name: 'reload', + description: + 'Reload the list of discovered profiles. Usage: /profiles reload', + kind: CommandKind.BUILT_IN, + action: reloadAction, + }, + ], + action: listAction, +}; diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index 9c8d90cd19..40df02b27d 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -35,6 +35,7 @@ import { ChatList } from './views/ChatList.js'; import { ModelMessage } from './messages/ModelMessage.js'; import { ThinkingMessage } from './messages/ThinkingMessage.js'; import { HintMessage } from './messages/HintMessage.js'; +import { ProfilesList } from './views/ProfilesList.js'; import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js'; import { useSettings } from '../contexts/SettingsContext.js'; @@ -233,6 +234,13 @@ export const HistoryItemDisplay: React.FC = ({ {itemForDisplay.type === 'chat_list' && ( )} + {itemForDisplay.type === 'profiles_list' && ( + + )} ); }; diff --git a/packages/cli/src/ui/components/views/ProfilesList.tsx b/packages/cli/src/ui/components/views/ProfilesList.tsx new file mode 100644 index 0000000000..7fb9f6c718 --- /dev/null +++ b/packages/cli/src/ui/components/views/ProfilesList.tsx @@ -0,0 +1,67 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../../semantic-colors.js'; +import { type ProfileDefinition } from '../../types.js'; + +interface ProfilesListProps { + profiles: readonly ProfileDefinition[]; + activeProfileName?: string; + showDescriptions: boolean; +} + +export const ProfilesList: React.FC = ({ + profiles, + activeProfileName, + showDescriptions, +}) => { + const sortedProfiles = profiles + .slice() + .sort((a, b) => a.name.localeCompare(b.name)); + + const renderProfile = (profile: ProfileDefinition) => { + const isActive = profile.name === activeProfileName; + + return ( + + {' '}- + + + + {profile.name} + + {isActive && ( + {' [Active]'} + )} + + {showDescriptions && profile.description && ( + + {profile.description} + + )} + + + ); + }; + + return ( + + {sortedProfiles.length > 0 ? ( + + + Available Profiles: + + + {sortedProfiles.map(renderProfile)} + + ) : ( + No profiles available + )} + + ); +}; diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index 3898461fb0..260a5ac145 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -13,6 +13,7 @@ import { type ToolResultDisplay, type RetrieveUserQuotaResponse, type SkillDefinition, + type ProfileDefinition, type AgentDefinition, type ApprovalMode, type Kind, @@ -22,7 +23,7 @@ import { import type { PartListUnion } from '@google/genai'; import { type ReactNode } from 'react'; -export type { ThoughtSummary, SkillDefinition }; +export type { ThoughtSummary, SkillDefinition, ProfileDefinition }; export enum AuthState { // Attempting to authenticate or re-authenticate @@ -290,6 +291,13 @@ export type HistoryItemSkillsList = HistoryItemBase & { showDescriptions: boolean; }; +export type HistoryItemProfilesList = HistoryItemBase & { + type: 'profiles_list'; + profiles: ProfileDefinition[]; + activeProfileName?: string; + showDescriptions: boolean; +}; + export type AgentDefinitionJson = Pick< AgentDefinition, 'name' | 'displayName' | 'description' | 'kind' @@ -376,6 +384,7 @@ export type HistoryItemWithoutId = | HistoryItemExtensionsList | HistoryItemToolsList | HistoryItemSkillsList + | HistoryItemProfilesList | HistoryItemAgentsList | HistoryItemMcpStatus | HistoryItemChatList @@ -401,6 +410,7 @@ export enum MessageType { EXTENSIONS_LIST = 'extensions_list', TOOLS_LIST = 'tools_list', SKILLS_LIST = 'skills_list', + PROFILES_LIST = 'profiles_list', AGENTS_LIST = 'agents_list', MCP_STATUS = 'mcp_status', CHAT_LIST = 'chat_list', diff --git a/packages/core/src/commands/profiles.ts b/packages/core/src/commands/profiles.ts new file mode 100644 index 0000000000..d9ccbf7541 --- /dev/null +++ b/packages/core/src/commands/profiles.ts @@ -0,0 +1,26 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Config } from '../config/config.js'; +import type { ProfileDefinition } from '../profiles/profileLoader.js'; + +export function listProfiles(config: Config): ProfileDefinition[] { + const profiles = config.getProfileManager().getAllProfiles(); + return profiles; +} + +export async function switchProfile( + config: Config, + name: string, +): Promise { + await config.applyProfile(name); +} + +export function getActiveProfile( + config: Config, +): ProfileDefinition | undefined { + return config.getProfileManager().getActiveProfile(); +} diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 752ad25c4f..fab1858057 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -143,6 +143,7 @@ import { SubagentTool } from '../agents/subagent-tool.js'; import { ExperimentFlags } from '../code_assist/experiments/flagNames.js'; import { debugLogger } from '../utils/debugLogger.js'; import { SkillManager, type SkillDefinition } from '../skills/skillManager.js'; +import { ProfileManager } from '../profiles/profileManager.js'; import { startupProfiler } from '../telemetry/startupProfiler.js'; import type { AgentDefinition } from '../agents/types.js'; import { fetchAdminControls } from '../code_assist/admin/admin_controls.js'; @@ -583,6 +584,9 @@ export interface ConfigParameters { skillsSupport?: boolean; disabledSkills?: string[]; adminSkillsEnabled?: boolean; + profilesEnabled?: boolean; + activeProfile?: string; + profilesDir?: string; experimentalJitContext?: boolean; toolOutputMasking?: Partial; disableLLMCorrection?: boolean; @@ -618,6 +622,7 @@ export class Config implements McpContext, AgentLoopContext { private agentRegistry!: AgentRegistry; private readonly acknowledgedAgentsService: AcknowledgedAgentsService; private skillManager!: SkillManager; + private profileManager!: ProfileManager; private _sessionId: string; private clientVersion: string; private fileSystemService: FileSystemService; @@ -686,7 +691,7 @@ export class Config implements McpContext, AgentLoopContext { private readonly deleteSession: string | undefined; private readonly listExtensions: boolean; private readonly _extensionLoader: ExtensionLoader; - private readonly _enabledExtensions: string[]; + private _enabledExtensions: string[]; private readonly enableExtensionReloading: boolean; fallbackModelHandler?: FallbackModelHandler; validationHandler?: ValidationHandler; @@ -1009,6 +1014,12 @@ export class Config implements McpContext, AgentLoopContext { this._messageBus = new MessageBus(this.policyEngine, this.debugMode); this.acknowledgedAgentsService = new AcknowledgedAgentsService(); this.skillManager = new SkillManager(); + this.profileManager = new ProfileManager( + params.profilesDir ?? path.join(this.targetDir, 'profiles'), + ); + if (params.activeProfile) { + this.profileManager.setActiveProfile(params.activeProfile); + } this.outputSettings = { format: params.output?.format ?? OutputFormat.TEXT, }; @@ -1651,6 +1662,30 @@ export class Config implements McpContext, AgentLoopContext { return this.skillManager; } + getProfileManager(): ProfileManager { + return this.profileManager; + } + + async applyProfile(name: string): Promise { + const profile = this.profileManager.getProfile(name); + if (!profile) { + throw new Error(`Profile "${name}" not found.`); + } + + this.profileManager.setActiveProfile(name); + + if (profile.default_model) { + this.model = profile.default_model; + } + + if (profile.extensions) { + this._enabledExtensions = profile.extensions; + } + + // Emit event or perform other side effects if necessary + debugLogger.log(`Applied profile: ${name}`); + } + getResourceRegistry(): ResourceRegistry { return this.resourceRegistry; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 47af5f76e1..725c6ee2ab 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -28,6 +28,7 @@ export * from './commands/extensions.js'; export * from './commands/restore.js'; export * from './commands/init.js'; export * from './commands/memory.js'; +export * from './commands/profiles.js'; export * from './commands/types.js'; // Export Core Logic @@ -130,6 +131,8 @@ export * from './services/keychainService.js'; export * from './services/keychainTypes.js'; export * from './skills/skillManager.js'; export * from './skills/skillLoader.js'; +export * from './profiles/profileLoader.js'; +export * from './profiles/profileManager.js'; // Export IDE specific logic export * from './ide/ide-client.js'; diff --git a/packages/core/src/profiles/profileLoader.test.ts b/packages/core/src/profiles/profileLoader.test.ts new file mode 100644 index 0000000000..16902b4199 --- /dev/null +++ b/packages/core/src/profiles/profileLoader.test.ts @@ -0,0 +1,96 @@ +/** + * @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/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { loadProfilesFromDir, loadProfileFromFile } from './profileLoader.js'; +import { coreEvents } from '../utils/events.js'; +import { debugLogger } from '../utils/debugLogger.js'; + +describe('profileLoader', () => { + let testRootDir: string; + + beforeEach(async () => { + testRootDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'profile-loader-test-'), + ); + vi.spyOn(coreEvents, 'emitFeedback'); + vi.spyOn(debugLogger, 'debug').mockImplementation(() => {}); + }); + + afterEach(async () => { + await fs.rm(testRootDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + it('should load profiles from a directory with valid .md files', async () => { + const profileFile = path.join(testRootDir, 'my-profile.md'); + await fs.writeFile( + profileFile, + `--- +name: my-profile +description: A test profile +default_model: gemini-1.5-pro +extensions: + - ext1 + - ext2 +--- +# Instructions +You are a helpful assistant. +`, + ); + + const profiles = await loadProfilesFromDir(testRootDir); + + expect(profiles).toHaveLength(1); + expect(profiles[0].name).toBe('my-profile'); + expect(profiles[0].description).toBe('A test profile'); + expect(profiles[0].default_model).toBe('gemini-1.5-pro'); + expect(profiles[0].extensions).toEqual(['ext1', 'ext2']); + expect(profiles[0].location).toBe(profileFile); + expect(profiles[0].body).toBe( + '# Instructions\nYou are a helpful assistant.', + ); + }); + + it('should return null for file without frontmatter', async () => { + const filePath = path.join(testRootDir, 'no-frontmatter.md'); + await fs.writeFile(filePath, '# No frontmatter here'); + + const profile = await loadProfileFromFile(filePath); + expect(profile).toBeNull(); + }); + + it('should return null for file with invalid frontmatter', async () => { + const filePath = path.join(testRootDir, 'invalid-frontmatter.md'); + await fs.writeFile(filePath, '---\nname:\n---'); + + const profile = await loadProfileFromFile(filePath); + expect(profile).toBeNull(); + }); + + it('should load multiple profiles from directory', async () => { + await fs.writeFile( + path.join(testRootDir, 'p1.md'), + '---\nname: p1\n---\nBody 1', + ); + await fs.writeFile( + path.join(testRootDir, 'p2.md'), + '---\nname: p2\n---\nBody 2', + ); + + const profiles = await loadProfilesFromDir(testRootDir); + expect(profiles).toHaveLength(2); + expect(profiles.map((p) => p.name).sort()).toEqual(['p1', 'p2']); + }); + + it('should return empty array for non-existent directory', async () => { + const profiles = await loadProfilesFromDir('/non/existent/path'); + expect(profiles).toEqual([]); + }); +}); diff --git a/packages/core/src/profiles/profileLoader.ts b/packages/core/src/profiles/profileLoader.ts new file mode 100644 index 0000000000..3eeff253ef --- /dev/null +++ b/packages/core/src/profiles/profileLoader.ts @@ -0,0 +1,131 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { glob } from 'glob'; +import { load } from 'js-yaml'; +import { debugLogger } from '../utils/debugLogger.js'; +import { coreEvents } from '../utils/events.js'; + +/** + * Represents the definition of a Gemini Profile. + */ +export interface ProfileDefinition { + /** The unique name of the profile (slug). */ + name: string; + /** A concise description of the profile's purpose. */ + description?: string; + /** The model ID to use for this profile. */ + default_model?: string; + /** List of extension IDs allowed for this profile. */ + extensions?: string[]; + /** The absolute path to the profile file. */ + location: string; + /** The system instructions / persona body. */ + body: string; +} + +const FRONTMATTER_REGEX = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n([\s\S]*))?/; + +/** + * Parses profile frontmatter. + */ +function parseProfileFrontmatter( + content: string, +): Partial | null { + try { + const parsed = load(content); + if (parsed && typeof parsed === 'object') { + return parsed as Partial; + } + } catch (error) { + debugLogger.debug('YAML profile frontmatter parsing failed:', error); + } + return null; +} + +/** + * Loads a single profile from an .md file. + */ +export async function loadProfileFromFile( + filePath: string, +): Promise { + try { + const content = await fs.readFile(filePath, 'utf-8'); + const match = content.match(FRONTMATTER_REGEX); + if (!match) { + debugLogger.debug(`Profile ${filePath} is missing frontmatter.`); + return null; + } + + const frontmatter = parseProfileFrontmatter(match[1]); + if (!frontmatter || !frontmatter.name) { + debugLogger.debug( + `Profile ${filePath} has invalid or missing name in frontmatter.`, + ); + return null; + } + + // Enforce name matches file slug (optional but good practice/consistency) + const expectedName = path.basename(filePath, '.md'); + if (frontmatter.name !== expectedName) { + debugLogger.debug( + `Profile name in frontmatter (${frontmatter.name}) should match filename (${expectedName}).`, + ); + } + + return { + name: frontmatter.name, + description: frontmatter.description, + default_model: frontmatter.default_model, + extensions: frontmatter.extensions, + location: filePath, + body: match[2]?.trim() ?? '', + }; + } catch (error) { + debugLogger.log(`Error parsing profile file ${filePath}:`, error); + return null; + } +} + +/** + * Discovers and loads all profiles in a directory. + */ +export async function loadProfilesFromDir( + dir: string, +): Promise { + const discoveredProfiles: ProfileDefinition[] = []; + + try { + const absoluteSearchPath = path.resolve(dir); + const stats = await fs.stat(absoluteSearchPath).catch(() => null); + if (!stats || !stats.isDirectory()) { + return []; + } + + const profileFiles = await glob('*.md', { + cwd: absoluteSearchPath, + absolute: true, + nodir: true, + }); + + for (const file of profileFiles) { + const profile = await loadProfileFromFile(file); + if (profile) { + discoveredProfiles.push(profile); + } + } + } catch (error) { + coreEvents.emitFeedback( + 'warning', + `Error discovering profiles in ${dir}:`, + error, + ); + } + + return discoveredProfiles; +} diff --git a/packages/core/src/profiles/profileManager.test.ts b/packages/core/src/profiles/profileManager.test.ts new file mode 100644 index 0000000000..29ade4f566 --- /dev/null +++ b/packages/core/src/profiles/profileManager.test.ts @@ -0,0 +1,98 @@ +/** + * @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/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { ProfileManager } from './profileManager.js'; +import { coreEvents } from '../utils/events.js'; + +describe('ProfileManager', () => { + let testProfilesDir: string; + let profileManager: ProfileManager; + + beforeEach(async () => { + testProfilesDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'profile-manager-test-'), + ); + profileManager = new ProfileManager(testProfilesDir); + vi.spyOn(coreEvents, 'emitFeedback'); + }); + + afterEach(async () => { + await fs.rm(testProfilesDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + it('should load profiles from directory', async () => { + await fs.writeFile( + path.join(testProfilesDir, 'test.md'), + '---\nname: test\n---\nBody', + ); + await profileManager.load(); + const profiles = profileManager.getAllProfiles(); + expect(profiles).toHaveLength(1); + expect(profiles[0].name).toBe('test'); + }); + + it('should manage active profile', async () => { + await fs.writeFile( + path.join(testProfilesDir, 'my-profile.md'), + '---\nname: my-profile\n---\nBody', + ); + await profileManager.load(); + profileManager.setActiveProfile('my-profile'); + expect(profileManager.getActiveProfileName()).toBe('my-profile'); + }); + + it('should install profile by copying', async () => { + const sourceDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'source-profile-'), + ); + const sourceFile = path.join(sourceDir, 'new-profile.md'); + await fs.writeFile(sourceFile, '---\nname: new-profile\n---\nNew Body'); + + const installed = await profileManager.installProfile(sourceFile); + expect(installed).toBeDefined(); + expect(installed.name).toBe('new-profile'); + expect(installed.location).toBe( + path.join(testProfilesDir, 'new-profile.md'), + ); + + const profiles = profileManager.getAllProfiles(); + expect(profiles.find((p) => p.name === 'new-profile')).toBeDefined(); + }); + + it('should link profile using symlink', async () => { + const sourceDir = await fs.mkdtemp(path.join(os.tmpdir(), 'source-link-')); + const sourceFile = path.join(sourceDir, 'linked-profile.md'); + await fs.writeFile( + sourceFile, + '---\nname: linked-profile\n---\nLinked Body', + ); + + const linked = await profileManager.linkProfile(sourceFile); + expect(linked).toBeDefined(); + expect(linked.name).toBe('linked-profile'); + + const targetPath = path.join(testProfilesDir, 'linked-profile.md'); + const stats = await fs.lstat(targetPath); + expect(stats.isSymbolicLink()).toBe(true); + }); + + it('should reload profiles', async () => { + await profileManager.load(); + expect(profileManager.getAllProfiles()).toHaveLength(0); + + await fs.writeFile( + path.join(testProfilesDir, 'test.md'), + '---\nname: test\n---\nBody', + ); + await profileManager.load(); + expect(profileManager.getAllProfiles()).toHaveLength(1); + }); +}); diff --git a/packages/core/src/profiles/profileManager.ts b/packages/core/src/profiles/profileManager.ts new file mode 100644 index 0000000000..f96cd0d332 --- /dev/null +++ b/packages/core/src/profiles/profileManager.ts @@ -0,0 +1,167 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + type ProfileDefinition, + loadProfilesFromDir, + loadProfileFromFile, +} from './profileLoader.js'; +import { debugLogger } from '../utils/debugLogger.js'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; + +export class ProfileManager { + private profiles: Map = new Map(); + private activeProfileName: string | undefined; + + constructor(private readonly profilesDir: string) {} + + /** + * Discovers and loads all profiles from the configured directory. + */ + async load(): Promise { + const loaded = await loadProfilesFromDir(this.profilesDir); + this.profiles.clear(); + for (const profile of loaded) { + this.profiles.set(profile.name, profile); + } + debugLogger.log( + `Loaded ${this.profiles.size} profiles from ${this.profilesDir}`, + ); + } + + /** + * Returns all discovered profiles. + */ + getProfiles(): ProfileDefinition[] { + return Array.from(this.profiles.values()); + } + + /** + * Returns a specific profile by name. + */ + getProfile(name: string): ProfileDefinition | undefined { + return this.profiles.get(name); + } + + /** + * Sets the active profile for the session. + */ + setActiveProfile(name: string | undefined): void { + if (name && !this.profiles.has(name)) { + debugLogger.warn(`Attempted to activate non-existent profile: ${name}`); + return; + } + this.activeProfileName = name; + } + + /** + * Returns the currently active profile definition. + */ + getActiveProfile(): ProfileDefinition | undefined { + return this.activeProfileName + ? this.profiles.get(this.activeProfileName) + : undefined; + } + + /** + * Returns the name of the active profile. + */ + getActiveProfileName(): string | undefined { + return this.activeProfileName; + } + + /** + * Returns all profiles. + */ + getAllProfiles(): ProfileDefinition[] { + return Array.from(this.profiles.values()); + } + + /** + * Links a profile from a local path. + */ + async linkProfile(sourcePath: string): Promise { + const profile = await loadProfileFromFile(sourcePath); + if (!profile) { + throw new Error(`Failed to load profile from ${sourcePath}`); + } + const targetPath = path.join(this.profilesDir, `${profile.name}.md`); + + // Create symlink + try { + await fs.mkdir(this.profilesDir, { recursive: true }); + await fs.symlink(path.resolve(sourcePath), targetPath); + const linkedProfile = { ...profile, location: targetPath }; + this.profiles.set(profile.name, linkedProfile); + debugLogger.log( + `Linked profile ${profile.name} from ${sourcePath} to ${targetPath}`, + ); + return linkedProfile; + } catch (error) { + debugLogger.error(`Failed to link profile: ${error}`); + throw error; + } + } + + /** + * Installs a profile from a local path (copies it). + */ + async installProfile(sourcePath: string): Promise { + const profile = await loadProfileFromFile(sourcePath); + if (!profile) { + throw new Error(`Failed to load profile from ${sourcePath}`); + } + const targetPath = path.join(this.profilesDir, `${profile.name}.md`); + + try { + await fs.mkdir(this.profilesDir, { recursive: true }); + await fs.copyFile(sourcePath, targetPath); + const installedProfile = { ...profile, location: targetPath }; + this.profiles.set(profile.name, installedProfile); + debugLogger.log(`Installed profile ${profile.name} to ${targetPath}`); + return installedProfile; + } catch (error) { + debugLogger.error(`Failed to install profile: ${error}`); + throw error; + } + } + + /** + * Uninstalls a profile by deleting its file. + * @param name Name of the profile to uninstall + */ + async uninstallProfile(name: string): Promise { + const profile = this.profiles.get(name); + if (!profile) { + throw new Error(`Profile "${name}" not found.`); + } + + try { + if (fs.unlink) { + // We are using fs/promises, so unlink is available + const location = + profile.location || path.join(this.profilesDir, `${profile.name}.md`); + await fs.unlink(location); + } + this.profiles.delete(name); + if (this.activeProfileName === name) { + this.activeProfileName = undefined; + } + } catch (error) { + throw new Error( + `Failed to uninstall profile "${name}": ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + /** + * Returns a list of all loaded profile names. + */ + getProfileNames(): string[] { + return Array.from(this.profiles.keys()); + } +} diff --git a/packages/core/src/prompts/promptProvider.ts b/packages/core/src/prompts/promptProvider.ts index 01dbd8d4d4..e33d97ddca 100644 --- a/packages/core/src/prompts/promptProvider.ts +++ b/packages/core/src/prompts/promptProvider.ts @@ -117,6 +117,7 @@ export class PromptProvider { preamble: this.withSection('preamble', () => ({ interactive: interactiveMode, })), + profileContext: config.getProfileManager().getActiveProfile()?.body, coreMandates: this.withSection('coreMandates', () => ({ interactive: interactiveMode, hasSkills: skills.length > 0, diff --git a/packages/core/src/prompts/snippets.legacy.ts b/packages/core/src/prompts/snippets.legacy.ts index 227b06be45..a3111c7d9b 100644 --- a/packages/core/src/prompts/snippets.legacy.ts +++ b/packages/core/src/prompts/snippets.legacy.ts @@ -34,6 +34,7 @@ export interface SystemPromptOptions { operationalGuidelines?: OperationalGuidelinesOptions; sandbox?: SandboxMode; interactiveYoloMode?: boolean; + profileContext?: string; gitRepo?: GitRepoOptions; finalReminder?: FinalReminderOptions; } @@ -101,6 +102,8 @@ export function getCoreSystemPrompt(options: SystemPromptOptions): string { return ` ${renderPreamble(options.preamble)} +${renderProfileContext(options.profileContext)} + ${renderCoreMandates(options.coreMandates)} ${renderSubAgents(options.subAgents)} @@ -142,6 +145,14 @@ ${renderUserMemory(userMemory)} // --- Subsection Renderers --- +export function renderProfileContext(profileContext?: string): string { + if (!profileContext) return ''; + return ` +# Profile Persona +${profileContext} +`.trim(); +} + export function renderPreamble(options?: PreambleOptions): string { if (!options) return ''; return options.interactive diff --git a/packages/core/src/prompts/snippets.ts b/packages/core/src/prompts/snippets.ts index 3b3334f96b..fa1b28c1c5 100644 --- a/packages/core/src/prompts/snippets.ts +++ b/packages/core/src/prompts/snippets.ts @@ -46,6 +46,7 @@ export interface SystemPromptOptions { planningWorkflow?: PlanningWorkflowOptions; taskTracker?: boolean; operationalGuidelines?: OperationalGuidelinesOptions; + profileContext?: string; sandbox?: SandboxMode; interactiveYoloMode?: boolean; gitRepo?: GitRepoOptions; @@ -112,6 +113,8 @@ export function getCoreSystemPrompt(options: SystemPromptOptions): string { return ` ${renderPreamble(options.preamble)} +${renderProfileContext(options.profileContext)} + ${renderCoreMandates(options.coreMandates)} ${renderSubAgents(options.subAgents)} @@ -155,6 +158,14 @@ ${renderUserMemory(userMemory, contextFilenames)} // --- Subsection Renderers --- +export function renderProfileContext(profileContext?: string): string { + if (!profileContext) return ''; + return ` +# Profile Persona +${profileContext} +`.trim(); +} + export function renderPreamble(options?: PreambleOptions): string { if (!options) return ''; return options.interactive