From 1e278c1e5b8f0c87094732332d174ea37a740cb6 Mon Sep 17 00:00:00 2001 From: Rahul Kamat Date: Wed, 11 Mar 2026 13:26:18 -0700 Subject: [PATCH] feat(cli): add install and link commands for profiles --- docs/cli/profiles.md | 14 +- packages/cli/src/commands/profiles.test.tsx | 72 ++++++++++ packages/cli/src/commands/profiles.tsx | 4 + packages/cli/src/commands/profiles/install.ts | 52 ++++++++ packages/cli/src/commands/profiles/link.ts | 53 ++++++++ .../cli/src/config/profile-manager.test.ts | 124 ++++++++++++++++++ packages/cli/src/config/profile-manager.ts | 84 ++++++++++++ 7 files changed, 397 insertions(+), 6 deletions(-) create mode 100644 packages/cli/src/commands/profiles.test.tsx create mode 100644 packages/cli/src/commands/profiles/install.ts create mode 100644 packages/cli/src/commands/profiles/link.ts diff --git a/docs/cli/profiles.md b/docs/cli/profiles.md index b8d6310655..139285e695 100644 --- a/docs/cli/profiles.md +++ b/docs/cli/profiles.md @@ -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 ` | Set a profile as the persistent default. | -| `gemini profiles disable` | Clear the persistent default profile. | -| `gemini profiles uninstall ` | 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 ` | Set a profile as the persistent default. | +| `gemini profiles disable` | Clear the persistent default profile. | +| `gemini profiles install ` | Install a new profile from a local file (Markdown). | +| `gemini profiles link ` | Create a symlink to an existing profile file on your system. | +| `gemini profiles uninstall ` | Delete a profile from your local storage. | diff --git a/packages/cli/src/commands/profiles.test.tsx b/packages/cli/src/commands/profiles.test.tsx new file mode 100644 index 0000000000..08cbd266e6 --- /dev/null +++ b/packages/cli/src/commands/profiles.test.tsx @@ -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 '); + 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); + }); +}); diff --git a/packages/cli/src/commands/profiles.tsx b/packages/cli/src/commands/profiles.tsx index 3ff45c2b61..b037c2757b 100644 --- a/packages/cli/src/commands/profiles.tsx +++ b/packages/cli/src/commands/profiles.tsx @@ -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: () => { diff --git a/packages/cli/src/commands/profiles/install.ts b/packages/cli/src/commands/profiles/install.ts new file mode 100644 index 0000000000..182113dece --- /dev/null +++ b/packages/cli/src/commands/profiles/install.ts @@ -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 ', + 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(); + }, +}; diff --git a/packages/cli/src/commands/profiles/link.ts b/packages/cli/src/commands/profiles/link.ts new file mode 100644 index 0000000000..5ade430563 --- /dev/null +++ b/packages/cli/src/commands/profiles/link.ts @@ -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 ', + 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(); + }, +}; diff --git a/packages/cli/src/config/profile-manager.test.ts b/packages/cli/src/config/profile-manager.test.ts index 119344d40a..c225ab762b 100644 --- a/packages/cli/src/config/profile-manager.test.ts +++ b/packages/cli/src/config/profile-manager.test.ts @@ -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/, + ); + }); +}); diff --git a/packages/cli/src/config/profile-manager.ts b/packages/cli/src/config/profile-manager.ts index 974d48b2df..8bb6c9843e 100644 --- a/packages/cli/src/config/profile-manager.ts +++ b/packages/cli/src/config/profile-manager.ts @@ -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 { + 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 { + 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))!; + } }