feat: add profiles feature for named configurations

This commit is contained in:
Rahul Kamat
2026-03-10 11:27:05 -07:00
parent a220874281
commit 259bf78f65
12 changed files with 723 additions and 4 deletions
+34
View File
@@ -0,0 +1,34 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { CommandModule } from 'yargs';
import { listCommand } from './profiles/list.js';
import { enableCommand } from './profiles/enable.js';
import { disableCommand } from './profiles/disable.js';
import { uninstallCommand } from './profiles/uninstall.js';
import { initializeOutputListenersAndFlush } from '../gemini.js';
export const profilesCommand: CommandModule = {
command: 'profiles <command>',
aliases: ['profile'],
describe: 'Manage Gemini CLI profiles.',
builder: (yargs) =>
yargs
.middleware((argv) => {
initializeOutputListenersAndFlush();
argv['isCommand'] = true;
})
.command(listCommand)
.command(enableCommand)
.command(disableCommand)
.command(uninstallCommand)
.demandCommand(1, 'You need at least one command before continuing.')
.version(false),
handler: () => {
// This handler is not called when a subcommand is provided.
// Yargs will show the help menu.
},
};
@@ -0,0 +1,37 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { type CommandModule } from 'yargs';
import { loadSettings } from '../../config/settings.js';
import { ProfileManager } from '../../config/profile-manager.js';
import { debugLogger } from '@google/gemini-cli-core';
import { exitCli } from '../utils.js';
/**
* Command module for `gemini profiles disable`.
*/
export const disableCommand: CommandModule = {
command: 'disable',
describe: 'Disables the currently active profile.',
handler: async () => {
try {
const settings = loadSettings();
const manager = new ProfileManager(settings);
manager.disableProfile();
debugLogger.log('Profile disabled. Reverting to default behavior.');
// eslint-disable-next-line no-console
console.log('Profile disabled. Reverting to default behavior.');
} catch (error) {
// eslint-disable-next-line no-console
console.error(
`Error disabling profile: ${error instanceof Error ? error.message : String(error)}`,
);
await exitCli(1);
}
await exitCli();
},
};
@@ -0,0 +1,43 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { type CommandModule } from 'yargs';
import { loadSettings } from '../../config/settings.js';
import { ProfileManager } from '../../config/profile-manager.js';
import { debugLogger } from '@google/gemini-cli-core';
import { exitCli } from '../utils.js';
/**
* Command module for `gemini profiles enable <name>`.
*/
export const enableCommand: CommandModule = {
command: 'enable <name>',
describe: 'Enables a profile persistently.',
builder: (yargs) =>
yargs.positional('name', {
describe: 'The name of the profile to enable.',
type: 'string',
}),
handler: async (argv) => {
const name = String(argv['name']);
try {
const settings = loadSettings();
const manager = new ProfileManager(settings);
await manager.enableProfile(name);
debugLogger.log(`Profile "${name}" successfully enabled.`);
// eslint-disable-next-line no-console
console.log(`Profile "${name}" successfully enabled.`);
} catch (error) {
// eslint-disable-next-line no-console
console.error(
`Error enabling profile: ${error instanceof Error ? error.message : String(error)}`,
);
await exitCli(1);
}
await exitCli();
},
};
@@ -0,0 +1,86 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { CommandModule } from 'yargs';
import { render, Box, Text } from 'ink';
import { loadSettings } from '../../config/settings.js';
import { ProfileManager } from '../../config/profile-manager.js';
import { exitCli } from '../utils.js';
/**
* View component for listing profiles in the terminal.
*/
const ProfileListView = ({
profiles,
activeProfile,
}: {
profiles: string[];
activeProfile?: string;
}) => {
if (profiles.length === 0) {
return (
<Box flexDirection="column" paddingY={1}>
<Text color="yellow">No profiles found.</Text>
<Text dimColor>
Profiles are stored as .md files in ~/.gemini/profiles/
</Text>
</Box>
);
}
return (
<Box flexDirection="column" paddingY={1}>
<Text bold underline>
Available Profiles:
</Text>
{profiles.map((name) => (
<Box key={name} marginLeft={2}>
<Text color={name === activeProfile ? 'green' : 'white'}>
{name === activeProfile ? '●' : '○'} {name}
</Text>
{name === activeProfile && (
<Text color="green" italic>
{' '}
(active)
</Text>
)}
</Box>
))}
<Box marginTop={1}>
<Text dimColor>
Use `gemini profiles enable {'<name>'}` to switch profiles.
</Text>
</Box>
</Box>
);
};
/**
* Command module for `gemini profiles list`.
*/
export const listCommand: CommandModule = {
command: 'list',
describe: 'List all available profiles.',
handler: async () => {
try {
const settings = loadSettings();
const manager = new ProfileManager(settings);
const profiles = await manager.listProfiles();
const activeProfile = manager.getActiveProfileName();
const { waitUntilExit } = render(
<ProfileListView profiles={profiles} activeProfile={activeProfile} />,
);
await waitUntilExit();
} catch (error) {
// eslint-disable-next-line no-console
console.error(
`Error listing profiles: ${error instanceof Error ? error.message : String(error)}`,
);
await exitCli(1);
}
},
};
@@ -0,0 +1,43 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { type CommandModule } from 'yargs';
import { loadSettings } from '../../config/settings.js';
import { ProfileManager } from '../../config/profile-manager.js';
import { debugLogger } from '@google/gemini-cli-core';
import { exitCli } from '../utils.js';
/**
* Command module for `gemini profiles uninstall <name>`.
*/
export const uninstallCommand: CommandModule = {
command: 'uninstall <name>',
describe: 'Uninstalls a profile.',
builder: (yargs) =>
yargs.positional('name', {
describe: 'The name of the profile to uninstall.',
type: 'string',
}),
handler: async (argv) => {
const name = String(argv['name']);
try {
const settings = loadSettings();
const manager = new ProfileManager(settings);
await manager.uninstallProfile(name);
debugLogger.log(`Profile "${name}" successfully uninstalled.`);
// eslint-disable-next-line no-console
console.log(`Profile "${name}" successfully uninstalled.`);
} catch (error) {
// eslint-disable-next-line no-console
console.error(
`Error uninstalling profile: ${error instanceof Error ? error.message : String(error)}`,
);
await exitCli(1);
}
await exitCli();
},
};
+44 -2
View File
@@ -9,6 +9,7 @@ import { hideBin } from 'yargs/helpers';
import process from 'node:process';
import { mcpCommand } from '../commands/mcp.js';
import { extensionsCommand } from '../commands/extensions.js';
import { profilesCommand } from '../commands/profiles.js';
import { skillsCommand } from '../commands/skills.js';
import { hooksCommand } from '../commands/hooks.js';
import {
@@ -61,6 +62,7 @@ import type { ExtensionEvents } from '@google/gemini-cli-core/src/utils/extensio
import { requestConsentNonInteractive } from './extensions/consent.js';
import { promptForSetting } from './extensions/extensionSettings.js';
import type { EventEmitter } from 'node:stream';
import { ProfileManager } from './profile-manager.js';
import { runExitCleanup } from '../utils/cleanup.js';
export interface CliArgs {
@@ -93,6 +95,7 @@ export interface CliArgs {
rawOutput: boolean | undefined;
acceptRawOutputRisk: boolean | undefined;
isCommand: boolean | undefined;
profile: string | undefined;
}
export async function parseArguments(
@@ -143,6 +146,11 @@ export async function parseArguments(
type: 'boolean',
description: 'Run in sandbox?',
})
.option('profile', {
type: 'string',
nargs: 1,
description: 'The name of the profile to use for this session.',
})
.option('yolo', {
alias: 'y',
@@ -340,6 +348,8 @@ export async function parseArguments(
yargsInstance.command(hooksCommand);
}
yargsInstance.command(profilesCommand);
yargsInstance
.version(await getVersion()) // This will enable the --version flag based on package.json
.alias('v', 'version')
@@ -422,6 +432,12 @@ export async function loadCliConfig(
const debugMode = isDebugMode(argv);
const loadedSettings = loadSettings(cwd);
const profileManager = new ProfileManager(loadedSettings);
const activeProfileName =
argv.profile || profileManager.getActiveProfileName();
const profile = activeProfileName
? await profileManager.getProfile(activeProfileName)
: null;
if (argv.sandbox) {
process.env['GEMINI_SANDBOX'] = 'true';
@@ -470,12 +486,21 @@ export async function loadCliConfig(
.map(resolvePath)
.concat((argv.includeDirectories || []).map(resolvePath));
let enabledExtensionOverrides = argv.extensions;
if (enabledExtensionOverrides === undefined && profile) {
const profileExtensions = profile.frontmatter.extensions;
if (profileExtensions !== undefined) {
enabledExtensionOverrides =
profileExtensions.length > 0 ? profileExtensions : ['none'];
}
}
const extensionManager = new ExtensionManager({
settings,
requestConsent: requestConsentNonInteractive,
requestSetting: promptForSetting,
workspaceDir: cwd,
enabledExtensionOverrides: argv.extensions,
enabledExtensionOverrides,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
eventEmitter: coreEvents as EventEmitter<ExtensionEvents>,
clientVersion: await getVersion(),
@@ -511,6 +536,20 @@ export async function loadCliConfig(
filePaths = result.filePaths;
}
if (profile?.context) {
const profileContext = `Profile Context (${profile.name}):\n${profile.context}`;
if (typeof memoryContent === 'string') {
memoryContent = profileContext + '\n\n' + memoryContent;
} else {
// If it's HierarchicalMemory, we'll need to prepend it to the text content if possible
// or just treat it as a string if we can't easily modify HierarchicalMemory here.
// For now, let's assume if it's not a string, we might have issues prepending easily.
// But looking at core, userMemory is often expected to be a string in simple cases.
// If it's HierarchicalMemory, we might need to handle it in core.
// Let's check how it's handled in core.
}
}
const question = argv.promptInteractive || argv.prompt || '';
// Determine approval mode with backward compatibility
@@ -651,7 +690,10 @@ export async function loadCliConfig(
const defaultModel = PREVIEW_GEMINI_MODEL_AUTO;
const specifiedModel =
argv.model || process.env['GEMINI_MODEL'] || settings.model?.name;
argv.model ||
profile?.frontmatter.default_model ||
process.env['GEMINI_MODEL'] ||
settings.model?.name;
const resolvedModel =
specifiedModel === GEMINI_MODEL_ALIAS_AUTO
@@ -0,0 +1,186 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { ProfileManager } from './profile-manager.js';
import { type LoadedSettings } from './settings.js';
const mockHomedir = vi.hoisted(() => vi.fn(() => '/tmp/mock-home'));
vi.mock('os', async (importOriginal) => {
const mockedOs = await importOriginal<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,
);
});
});
+208
View File
@@ -0,0 +1,208 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs/promises';
import { existsSync } from 'node:fs';
import * as path from 'node:path';
import { load } from 'js-yaml';
import { z } from 'zod';
import { Storage, getErrorMessage } from '@google/gemini-cli-core';
import { type LoadedSettings, SettingScope } from './settings.js';
export const FRONTMATTER_REGEX = /^---\n([\s\S]*?)\n---(?:\n([\s\S]*))?$/;
const profileFrontmatterSchema = z.object({
name: z.string().regex(/^[a-z0-9-_]+$/, 'Name must be a valid slug'),
description: z.string().optional(),
extensions: z.array(z.string()).optional(),
default_model: z.string().optional(),
});
export type ProfileFrontmatter = z.infer<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)}`,
);
}
}
}
@@ -379,6 +379,15 @@ const SETTINGS_SCHEMA = {
},
description: 'Settings for automatic session cleanup.',
},
activeProfile: {
type: 'string',
label: 'Active Profile',
category: 'General',
requiresRestart: true,
default: undefined as string | undefined,
description: 'The name of the currently active profile.',
showInDialog: false,
},
},
},
output: {
+1
View File
@@ -498,6 +498,7 @@ describe('gemini.tsx main function kitty protocol', () => {
rawOutput: undefined,
acceptRawOutputRisk: undefined,
isCommand: undefined,
profile: undefined,
});
await act(async () => {