mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-21 18:44:30 -07:00
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:
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user