mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-15 08:31:14 -07:00
feat(cli): add install and link commands for profiles
This commit is contained in:
@@ -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. |
|
||||
|
||||
72
packages/cli/src/commands/profiles.test.tsx
Normal file
72
packages/cli/src/commands/profiles.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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: () => {
|
||||
|
||||
52
packages/cli/src/commands/profiles/install.ts
Normal file
52
packages/cli/src/commands/profiles/install.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
53
packages/cli/src/commands/profiles/link.ts
Normal file
53
packages/cli/src/commands/profiles/link.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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))!;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user