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.
This commit is contained in:
Rahul Kamat
2026-03-11 17:45:26 -07:00
parent a04c593eb2
commit 0ec0c6ec08
26 changed files with 873 additions and 645 deletions

View File

@@ -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.');

View File

@@ -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.`);

View File

@@ -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.`),
);

View File

@@ -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.`),
);

View File

@@ -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(
<ProfileListView profiles={profiles} activeProfile={activeProfile} />,

View File

@@ -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.`);

View File

@@ -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 {

View File

@@ -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<typeof os>();
return {
...mockedOs,
homedir: mockHomedir,
};
});
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
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/,
);
});
});

View File

@@ -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<typeof profileFrontmatterSchema>;
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<void> {
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<string[]> {
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<Profile | null> {
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<void> {
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<void> {
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<Profile> {
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<Profile> {
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))!;
}
}

View File

@@ -44,11 +44,15 @@ describe('initializer', () => {
getToolRegistry: ReturnType<typeof vi.fn>;
getIdeMode: ReturnType<typeof vi.fn>;
getGeminiMdFileCount: ReturnType<typeof vi.fn>;
getProfileManager: ReturnType<typeof vi.fn>;
};
let mockSettings: LoadedSettings;
let mockIdeClient: {
connect: ReturnType<typeof vi.fn>;
};
let mockProfileManager: {
load: ReturnType<typeof vi.fn>;
};
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: {

View File

@@ -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,

View File

@@ -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),
{

View File

@@ -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<void | SlashCommandActionReturn> {
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<void | SlashCommandActionReturn> {
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<void | SlashCommandActionReturn> {
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 <name> | 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 <name>',
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,
};

View File

@@ -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<HistoryItemDisplayProps> = ({
{itemForDisplay.type === 'chat_list' && (
<ChatList chats={itemForDisplay.chats} />
)}
{itemForDisplay.type === 'profiles_list' && (
<ProfilesList
profiles={itemForDisplay.profiles}
activeProfileName={itemForDisplay.activeProfileName}
showDescriptions={itemForDisplay.showDescriptions}
/>
)}
</Box>
);
};

View File

@@ -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<ProfilesListProps> = ({
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 (
<Box key={profile.name} flexDirection="row">
<Text color={theme.text.primary}>{' '}- </Text>
<Box flexDirection="column">
<Box flexDirection="row">
<Text bold color={isActive ? theme.text.link : theme.text.primary}>
{profile.name}
</Text>
{isActive && (
<Text color={theme.status.success}>{' [Active]'}</Text>
)}
</Box>
{showDescriptions && profile.description && (
<Box marginLeft={2}>
<Text color={theme.text.secondary}>{profile.description}</Text>
</Box>
)}
</Box>
</Box>
);
};
return (
<Box flexDirection="column" marginBottom={1}>
{sortedProfiles.length > 0 ? (
<Box flexDirection="column">
<Text bold color={theme.text.primary}>
Available Profiles:
</Text>
<Box height={1} />
{sortedProfiles.map(renderProfile)}
</Box>
) : (
<Text color={theme.text.primary}>No profiles available</Text>
)}
</Box>
);
};

View File

@@ -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',