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