feat(cli): add install and link commands for profiles

This commit is contained in:
Rahul Kamat
2026-03-11 13:26:18 -07:00
parent 5453129d9f
commit 1e278c1e5b
7 changed files with 397 additions and 6 deletions

View File

@@ -71,9 +71,11 @@ gemini profiles disable
## Commands
| Command | Description |
| :--------------------------------- | :------------------------------------------------------- |
| `gemini profiles list` | List all available profiles and see which one is active. |
| `gemini profiles enable <name>` | Set a profile as the persistent default. |
| `gemini profiles disable` | Clear the persistent default profile. |
| `gemini profiles uninstall <name>` | Delete a profile from your local storage. |
| Command | Description |
| :--------------------------------- | :----------------------------------------------------------- |
| `gemini profiles list` | List all available profiles and see which one is active. |
| `gemini profiles enable <name>` | Set a profile as the persistent default. |
| `gemini profiles disable` | Clear the persistent default profile. |
| `gemini profiles install <path>` | Install a new profile from a local file (Markdown). |
| `gemini profiles link <path>` | Create a symlink to an existing profile file on your system. |
| `gemini profiles uninstall <name>` | Delete a profile from your local storage. |

View File

@@ -0,0 +1,72 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { profilesCommand } from './profiles.js';
// Mock subcommands
vi.mock('./profiles/list.js', () => ({ listCommand: { command: 'list' } }));
vi.mock('./profiles/enable.js', () => ({
enableCommand: { command: 'enable' },
}));
vi.mock('./profiles/disable.js', () => ({
disableCommand: { command: 'disable' },
}));
vi.mock('./profiles/uninstall.js', () => ({
uninstallCommand: { command: 'uninstall' },
}));
vi.mock('./profiles/install.js', () => ({
installCommand: { command: 'install' },
}));
vi.mock('./profiles/link.js', () => ({
linkCommand: { command: 'link' },
}));
// Mock gemini.js
vi.mock('../gemini.js', () => ({
initializeOutputListenersAndFlush: vi.fn(),
}));
describe('profilesCommand', () => {
it('should have correct command and description', () => {
expect(profilesCommand.command).toBe('profiles <command>');
expect(profilesCommand.describe).toBe('Manage Gemini CLI profiles.');
});
it('should register all subcommands in builder', () => {
const mockYargs = {
middleware: vi.fn().mockReturnThis(),
command: vi.fn().mockReturnThis(),
demandCommand: vi.fn().mockReturnThis(),
version: vi.fn().mockReturnThis(),
};
// @ts-expect-error - Mocking yargs
profilesCommand.builder(mockYargs);
expect(mockYargs.middleware).toHaveBeenCalled();
expect(mockYargs.command).toHaveBeenCalledWith(
expect.objectContaining({ command: 'list' }),
);
expect(mockYargs.command).toHaveBeenCalledWith(
expect.objectContaining({ command: 'enable' }),
);
expect(mockYargs.command).toHaveBeenCalledWith(
expect.objectContaining({ command: 'disable' }),
);
expect(mockYargs.command).toHaveBeenCalledWith(
expect.objectContaining({ command: 'uninstall' }),
);
expect(mockYargs.command).toHaveBeenCalledWith(
expect.objectContaining({ command: 'install' }),
);
expect(mockYargs.command).toHaveBeenCalledWith(
expect.objectContaining({ command: 'link' }),
);
expect(mockYargs.demandCommand).toHaveBeenCalledWith(1, expect.any(String));
expect(mockYargs.version).toHaveBeenCalledWith(false);
});
});

View File

@@ -9,6 +9,8 @@ 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 { installCommand } from './profiles/install.js';
import { linkCommand } from './profiles/link.js';
import { initializeOutputListenersAndFlush } from '../gemini.js';
export const profilesCommand: CommandModule = {
@@ -24,6 +26,8 @@ export const profilesCommand: CommandModule = {
.command(enableCommand)
.command(disableCommand)
.command(uninstallCommand)
.command(installCommand)
.command(linkCommand)
.demandCommand(1, 'You need at least one command before continuing.')
.version(false),
handler: () => {

View File

@@ -0,0 +1,52 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { CommandModule } from 'yargs';
import chalk from 'chalk';
import { debugLogger } 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 {
path: string;
}
export async function handleInstall(args: InstallArgs) {
try {
const settings = loadSettings();
const profileManager = new ProfileManager(settings);
const profile = await profileManager.installProfile(args.path);
debugLogger.log(
chalk.green(`Profile "${profile.name}" installed successfully.`),
);
debugLogger.log(
`Use "gemini profiles enable ${profile.name}" to activate it.`,
);
} catch (error) {
debugLogger.error(getErrorMessage(error));
process.exit(1);
}
}
export const installCommand: CommandModule = {
command: 'install <path>',
describe: 'Installs a profile by copying it from a local path.',
builder: (yargs) =>
yargs.positional('path', {
describe: 'The local path of the profile file to install.',
type: 'string',
demandOption: true,
}),
handler: async (argv) => {
await handleInstall({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
path: argv['path'] as string,
});
await exitCli();
},
};

View File

@@ -0,0 +1,53 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { CommandModule } from 'yargs';
import chalk from 'chalk';
import { debugLogger } 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 {
path: string;
}
export async function handleLink(args: LinkArgs) {
try {
const settings = loadSettings();
const profileManager = new ProfileManager(settings);
const profile = await profileManager.linkProfile(args.path);
debugLogger.log(
chalk.green(`Profile "${profile.name}" linked successfully.`),
);
debugLogger.log(
`Use "gemini profiles enable ${profile.name}" to activate it.`,
);
} catch (error) {
debugLogger.error(getErrorMessage(error));
process.exit(1);
}
}
export const linkCommand: CommandModule = {
command: 'link <path>',
describe:
'Links a profile from a local path. Changes to the local file will be reflected in the profile.',
builder: (yargs) =>
yargs.positional('path', {
describe: 'The local path of the profile file to link.',
type: 'string',
demandOption: true,
}),
handler: async (argv) => {
await handleLink({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
path: argv['path'] as string,
});
await exitCli();
},
};

View File

@@ -184,3 +184,127 @@ name: Invalid Name
);
});
});
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

@@ -205,4 +205,88 @@ export class ProfileManager {
);
}
}
/**
* 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))!;
}
}