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
+26
View File
@@ -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<void> {
await config.applyProfile(name);
}
export function getActiveProfile(
config: Config,
): ProfileDefinition | undefined {
return config.getProfileManager().getActiveProfile();
}
+36 -1
View File
@@ -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<ToolOutputMaskingConfig>;
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<void> {
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;
}
+3
View File
@@ -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';
@@ -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([]);
});
});
+131
View File
@@ -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<ProfileDefinition> | null {
try {
const parsed = load(content);
if (parsed && typeof parsed === 'object') {
return parsed as Partial<ProfileDefinition>;
}
} 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<ProfileDefinition | null> {
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<ProfileDefinition[]> {
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;
}
@@ -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);
});
});
@@ -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<string, ProfileDefinition> = new Map();
private activeProfileName: string | undefined;
constructor(private readonly profilesDir: string) {}
/**
* Discovers and loads all profiles from the configured directory.
*/
async load(): Promise<void> {
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<ProfileDefinition> {
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<ProfileDefinition> {
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<void> {
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());
}
}
@@ -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,
@@ -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
+11
View File
@@ -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