mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 00:01:24 -07:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ec0c6ec08 | |||
| a04c593eb2 | |||
| 7b1e984a6d | |||
| 1e278c1e5b | |||
| 5453129d9f | |||
| 7178b77527 | |||
| 7f5c805d9d | |||
| 25ee5327f3 | |||
| a15ea53ff8 | |||
| 949392b384 | |||
| 259bf78f65 |
@@ -0,0 +1,81 @@
|
||||
# Profiles
|
||||
|
||||
Profiles allow you to maintain separate configurations for different workflows,
|
||||
tasks, or personas. Each profile can define its own set of allowed extensions, a
|
||||
default model, and a custom system instruction (persona).
|
||||
|
||||
Profiles are particularly useful for:
|
||||
|
||||
- Switching between domain-specific setups (e.g., frontend development vs.
|
||||
system administration).
|
||||
- Defining specialized AI personas for different tasks.
|
||||
- Restricting available tools/extensions for specific projects.
|
||||
|
||||
## Profile structure
|
||||
|
||||
Profiles are Markdown files (`.md`) that use YAML frontmatter for configuration
|
||||
and the file body for system instructions.
|
||||
|
||||
**Location**: `~/.gemini/profiles/[profile-name].md`
|
||||
|
||||
### Example profile (`coder.md`)
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: coder
|
||||
default_model: gemini-3.0-flash
|
||||
extensions:
|
||||
- github
|
||||
- web-search
|
||||
---
|
||||
|
||||
You are a world-class Web Developer. Focus on React, TypeScript, and modern CSS.
|
||||
Always prefer functional components and explain your reasoning.
|
||||
```
|
||||
|
||||
### Configuration fields
|
||||
|
||||
| Field | Description |
|
||||
| :-------------- | :----------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `name` | (Mandatory) A unique name for the profile. Must be a valid slug (a-z, 0-9, -, \_) and MUST match the filename without the `.md` extension. |
|
||||
| `default_model` | (Optional) The model to use when this profile is active. |
|
||||
| `extensions` | (Optional) A list of allowed extension IDs. If specified, only these extensions will be loaded. |
|
||||
|
||||
The **body** of the Markdown file is injected into the system prompt, allowing
|
||||
you to define a specific persona or set of instructions that the AI will always
|
||||
follow when the profile is active.
|
||||
|
||||
## Usage
|
||||
|
||||
### Single session usage
|
||||
|
||||
Use the `--profiles` flag (or `-P`) to use a profile for a single session:
|
||||
|
||||
```bash
|
||||
gemini --profiles coder
|
||||
```
|
||||
|
||||
### Persistent default
|
||||
|
||||
You can set a profile as your default for all sessions:
|
||||
|
||||
```bash
|
||||
gemini profiles enable coder
|
||||
```
|
||||
|
||||
To return to the standard configuration:
|
||||
|
||||
```bash
|
||||
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 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. |
|
||||
@@ -149,6 +149,7 @@
|
||||
"label": "Project context (GEMINI.md)",
|
||||
"slug": "docs/cli/gemini-md"
|
||||
},
|
||||
{ "label": "Profiles", "slug": "docs/cli/profiles" },
|
||||
{ "label": "Settings", "slug": "docs/cli/settings" },
|
||||
{
|
||||
"label": "System prompt override",
|
||||
|
||||
Generated
+28
-2
@@ -527,7 +527,8 @@
|
||||
"version": "2.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz",
|
||||
"integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==",
|
||||
"license": "(Apache-2.0 AND BSD-3-Clause)"
|
||||
"license": "(Apache-2.0 AND BSD-3-Clause)",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@bundled-es-modules/cookie": {
|
||||
"version": "2.0.1",
|
||||
@@ -1600,6 +1601,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz",
|
||||
"integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@grpc/proto-loader": "^0.8.0",
|
||||
"@js-sdsl/ordered-map": "^4.4.2"
|
||||
@@ -2324,6 +2326,7 @@
|
||||
"integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@octokit/auth-token": "^6.0.0",
|
||||
"@octokit/graphql": "^9.0.2",
|
||||
@@ -2504,6 +2507,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
@@ -2553,6 +2557,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz",
|
||||
"integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
@@ -2927,6 +2932,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.0.tgz",
|
||||
"integrity": "sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.5.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
@@ -2960,6 +2966,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.5.0.tgz",
|
||||
"integrity": "sha512-BeJLtU+f5Gf905cJX9vXFQorAr6TAfK3SPvTFqP+scfIpDQEJfRaGJWta7sJgP+m4dNtBf9y3yvBKVAZZtJQVA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.5.0",
|
||||
"@opentelemetry/resources": "2.5.0"
|
||||
@@ -3014,6 +3021,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.0.tgz",
|
||||
"integrity": "sha512-VzRf8LzotASEyNDUxTdaJ9IRJ1/h692WyArDBInf5puLCjxbICD6XkHgpuudis56EndyS7LYFmtTMny6UABNdQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.5.0",
|
||||
"@opentelemetry/resources": "2.5.0",
|
||||
@@ -4210,6 +4218,7 @@
|
||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
@@ -4483,6 +4492,7 @@
|
||||
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.56.1",
|
||||
"@typescript-eslint/types": "8.56.1",
|
||||
@@ -5330,6 +5340,7 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -7933,6 +7944,7 @@
|
||||
"integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -8565,6 +8577,7 @@
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "^2.0.0",
|
||||
"body-parser": "^2.2.1",
|
||||
@@ -9879,6 +9892,7 @@
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.2.tgz",
|
||||
"integrity": "sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
@@ -10158,6 +10172,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.11.tgz",
|
||||
"integrity": "sha512-93LQlzT7vvZ1XJcmOMwN4s+6W334QegendeHOMnEJBlhnpIzr8bws6/aOEHG8ZCuVD/vNeeea5m1msHIdAY6ig==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@alcalzone/ansi-tokenize": "^0.2.1",
|
||||
"ansi-escapes": "^7.0.0",
|
||||
@@ -13840,6 +13855,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -13850,6 +13866,7 @@
|
||||
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"shell-quote": "^1.6.1",
|
||||
"ws": "^7"
|
||||
@@ -15938,6 +15955,7 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -16161,7 +16179,8 @@
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
"license": "0BSD",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.20.3",
|
||||
@@ -16169,6 +16188,7 @@
|
||||
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.25.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
@@ -16328,6 +16348,7 @@
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -16551,6 +16572,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
|
||||
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -16664,6 +16686,7 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -16676,6 +16699,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
|
||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.4",
|
||||
@@ -17320,6 +17344,7 @@
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
@@ -17722,6 +17747,7 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
||||
@@ -9,7 +9,9 @@ import { coreEvents } from '@google/gemini-cli-core';
|
||||
import { handleList, listCommand } from './list.js';
|
||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
import { loadSettings, type LoadedSettings } from '../../config/settings.js';
|
||||
import { loadCliConfig, type CliArgs } from '../../config/config.js';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const { mockCoreDebugLogger } = await import(
|
||||
@@ -25,6 +27,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
|
||||
vi.mock('../../config/extension-manager.js');
|
||||
vi.mock('../../config/settings.js');
|
||||
vi.mock('../../config/config.js');
|
||||
vi.mock('../../utils/errors.js');
|
||||
vi.mock('../../config/extensions/consent.js', () => ({
|
||||
requestConsentNonInteractive: vi.fn(),
|
||||
@@ -40,6 +43,7 @@ describe('extensions list command', () => {
|
||||
const mockLoadSettings = vi.mocked(loadSettings);
|
||||
const mockGetErrorMessage = vi.mocked(getErrorMessage);
|
||||
const mockExtensionManager = vi.mocked(ExtensionManager);
|
||||
const mockLoadCliConfig = vi.mocked(loadCliConfig);
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
@@ -55,10 +59,17 @@ describe('extensions list command', () => {
|
||||
describe('handleList', () => {
|
||||
it('should log a message if no extensions are installed', async () => {
|
||||
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
|
||||
const mockConfig = {
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
getExtensionLoader: vi
|
||||
.fn()
|
||||
.mockReturnValue(mockExtensionManager.prototype),
|
||||
};
|
||||
mockLoadCliConfig.mockResolvedValue(mockConfig as unknown as Config);
|
||||
mockExtensionManager.prototype.loadExtensions = vi
|
||||
.fn()
|
||||
.mockResolvedValue([]);
|
||||
await handleList();
|
||||
await handleList({} as unknown as CliArgs);
|
||||
|
||||
expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
|
||||
'log',
|
||||
@@ -69,10 +80,17 @@ describe('extensions list command', () => {
|
||||
|
||||
it('should output empty JSON array if no extensions are installed and output-format is json', async () => {
|
||||
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
|
||||
const mockConfig = {
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
getExtensionLoader: vi
|
||||
.fn()
|
||||
.mockReturnValue(mockExtensionManager.prototype),
|
||||
};
|
||||
mockLoadCliConfig.mockResolvedValue(mockConfig as unknown as Config);
|
||||
mockExtensionManager.prototype.loadExtensions = vi
|
||||
.fn()
|
||||
.mockResolvedValue([]);
|
||||
await handleList({ outputFormat: 'json' });
|
||||
await handleList({} as unknown as CliArgs, { outputFormat: 'json' });
|
||||
|
||||
expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith('log', '[]');
|
||||
mockCwd.mockRestore();
|
||||
@@ -80,6 +98,14 @@ describe('extensions list command', () => {
|
||||
|
||||
it('should list all installed extensions', async () => {
|
||||
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
|
||||
const mockConfig = {
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
getExtensionLoader: vi
|
||||
.fn()
|
||||
.mockReturnValue(mockExtensionManager.prototype),
|
||||
};
|
||||
mockLoadCliConfig.mockResolvedValue(mockConfig as unknown as Config);
|
||||
|
||||
const extensions = [
|
||||
{ name: 'ext1', version: '1.0.0' },
|
||||
{ name: 'ext2', version: '2.0.0' },
|
||||
@@ -90,7 +116,7 @@ describe('extensions list command', () => {
|
||||
mockExtensionManager.prototype.toOutputString = vi.fn(
|
||||
(ext) => `${ext.name}@${ext.version}`,
|
||||
);
|
||||
await handleList();
|
||||
await handleList({} as unknown as CliArgs);
|
||||
|
||||
expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
|
||||
'log',
|
||||
@@ -101,6 +127,14 @@ describe('extensions list command', () => {
|
||||
|
||||
it('should list all installed extensions in JSON format', async () => {
|
||||
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
|
||||
const mockConfig = {
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
getExtensionLoader: vi
|
||||
.fn()
|
||||
.mockReturnValue(mockExtensionManager.prototype),
|
||||
};
|
||||
mockLoadCliConfig.mockResolvedValue(mockConfig as unknown as Config);
|
||||
|
||||
const extensions = [
|
||||
{ name: 'ext1', version: '1.0.0' },
|
||||
{ name: 'ext2', version: '2.0.0' },
|
||||
@@ -108,7 +142,7 @@ describe('extensions list command', () => {
|
||||
mockExtensionManager.prototype.loadExtensions = vi
|
||||
.fn()
|
||||
.mockResolvedValue(extensions);
|
||||
await handleList({ outputFormat: 'json' });
|
||||
await handleList({} as unknown as CliArgs, { outputFormat: 'json' });
|
||||
|
||||
expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
|
||||
'log',
|
||||
@@ -124,12 +158,11 @@ describe('extensions list command', () => {
|
||||
code?: string | number | null | undefined,
|
||||
) => never);
|
||||
const error = new Error('List failed');
|
||||
mockExtensionManager.prototype.loadExtensions = vi
|
||||
.fn()
|
||||
.mockRejectedValue(error);
|
||||
mockLoadCliConfig.mockRejectedValue(error);
|
||||
mockGetErrorMessage.mockReturnValue('List failed message');
|
||||
|
||||
await handleList();
|
||||
|
||||
await handleList({} as unknown as CliArgs);
|
||||
|
||||
expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
|
||||
'error',
|
||||
@@ -167,6 +200,13 @@ describe('extensions list command', () => {
|
||||
});
|
||||
|
||||
it('handler should call handleList with parsed arguments', async () => {
|
||||
const mockConfig = {
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
getExtensionLoader: vi
|
||||
.fn()
|
||||
.mockReturnValue(mockExtensionManager.prototype),
|
||||
};
|
||||
mockLoadCliConfig.mockResolvedValue(mockConfig as unknown as Config);
|
||||
mockExtensionManager.prototype.loadExtensions = vi
|
||||
.fn()
|
||||
.mockResolvedValue([]);
|
||||
@@ -177,7 +217,7 @@ describe('extensions list command', () => {
|
||||
)({
|
||||
'output-format': 'json',
|
||||
});
|
||||
expect(mockExtensionManager.prototype.loadExtensions).toHaveBeenCalled();
|
||||
expect(mockLoadCliConfig).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,22 +7,30 @@
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
|
||||
import { loadCliConfig, type CliArgs } from '../../config/config.js';
|
||||
import { loadSettings } from '../../config/settings.js';
|
||||
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
|
||||
import type { ExtensionManager } from '../../config/extension-manager.js';
|
||||
import { exitCli } from '../utils.js';
|
||||
|
||||
export async function handleList(options?: { outputFormat?: 'text' | 'json' }) {
|
||||
export async function handleList(
|
||||
argv: CliArgs,
|
||||
options?: { outputFormat?: 'text' | 'json' },
|
||||
) {
|
||||
try {
|
||||
const workspaceDir = process.cwd();
|
||||
const extensionManager = new ExtensionManager({
|
||||
workspaceDir,
|
||||
requestConsent: requestConsentNonInteractive,
|
||||
requestSetting: promptForSetting,
|
||||
settings: loadSettings(workspaceDir).merged,
|
||||
});
|
||||
const extensions = await extensionManager.loadExtensions();
|
||||
const settings = loadSettings(workspaceDir);
|
||||
const config = await loadCliConfig(
|
||||
settings.merged,
|
||||
'extensions-list-session',
|
||||
argv,
|
||||
{ cwd: workspaceDir },
|
||||
);
|
||||
|
||||
// Initialize to trigger extension loading (and profile filtering)
|
||||
await config.initialize();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const extensionManager = config.getExtensionLoader() as ExtensionManager;
|
||||
const extensions = extensionManager.getExtensions();
|
||||
if (extensions.length === 0) {
|
||||
if (options?.outputFormat === 'json') {
|
||||
debugLogger.log('[]');
|
||||
@@ -61,7 +69,8 @@ export const listCommand: CommandModule = {
|
||||
default: 'text',
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
await handleList({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
await handleList(argv as unknown as CliArgs, {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
outputFormat: argv['output-format'] as 'text' | 'json',
|
||||
});
|
||||
|
||||
@@ -14,30 +14,42 @@ import {
|
||||
import { loadSettings } from '../../config/settings.js';
|
||||
import { exitCli } from '../utils.js';
|
||||
import { getMcpServersFromConfig } from './list.js';
|
||||
import { loadCliConfig, type CliArgs } from '../../config/config.js';
|
||||
import type { ExtensionManager } from '../../config/extension-manager.js';
|
||||
|
||||
const GREEN = '\x1b[32m';
|
||||
const YELLOW = '\x1b[33m';
|
||||
const RED = '\x1b[31m';
|
||||
const RESET = '\x1b[0m';
|
||||
|
||||
interface Args {
|
||||
interface Args extends CliArgs {
|
||||
name: string;
|
||||
session?: boolean;
|
||||
}
|
||||
|
||||
async function handleEnable(args: Args): Promise<void> {
|
||||
async function handleEnable(argv: Args): Promise<void> {
|
||||
const manager = McpServerEnablementManager.getInstance();
|
||||
const name = normalizeServerId(args.name);
|
||||
const name = normalizeServerId(argv.name);
|
||||
|
||||
// Check settings blocks
|
||||
const settings = loadSettings();
|
||||
|
||||
const config = await loadCliConfig(settings.merged, 'mcp-enable', argv, {
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
await config.initialize();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const extensionManager = config.getExtensionLoader() as ExtensionManager;
|
||||
|
||||
// Get all servers including extensions
|
||||
const servers = await getMcpServersFromConfig();
|
||||
const { mcpServers: servers } = await getMcpServersFromConfig(
|
||||
settings.merged,
|
||||
extensionManager,
|
||||
);
|
||||
const normalizedServerNames = Object.keys(servers).map(normalizeServerId);
|
||||
if (!normalizedServerNames.includes(name)) {
|
||||
debugLogger.log(
|
||||
`${RED}Error:${RESET} Server '${args.name}' not found. Use 'gemini mcp' to see available servers.`,
|
||||
`${RED}Error:${RESET} Server '${argv.name}' not found. Use 'gemini mcp' to see available servers.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -56,7 +68,7 @@ async function handleEnable(args: Args): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.session) {
|
||||
if (argv.session) {
|
||||
manager.clearSessionDisable(name);
|
||||
debugLogger.log(`${GREEN}✓${RESET} Session disable cleared for '${name}'.`);
|
||||
} else {
|
||||
@@ -71,21 +83,34 @@ async function handleEnable(args: Args): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDisable(args: Args): Promise<void> {
|
||||
async function handleDisable(argv: Args): Promise<void> {
|
||||
const manager = McpServerEnablementManager.getInstance();
|
||||
const name = normalizeServerId(args.name);
|
||||
const name = normalizeServerId(argv.name);
|
||||
|
||||
// Check settings blocks
|
||||
const settings = loadSettings();
|
||||
|
||||
const config = await loadCliConfig(settings.merged, 'mcp-disable', argv, {
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
await config.initialize();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const extensionManager = config.getExtensionLoader() as ExtensionManager;
|
||||
|
||||
// Get all servers including extensions
|
||||
const servers = await getMcpServersFromConfig();
|
||||
const { mcpServers: servers } = await getMcpServersFromConfig(
|
||||
settings.merged,
|
||||
extensionManager,
|
||||
);
|
||||
const normalizedServerNames = Object.keys(servers).map(normalizeServerId);
|
||||
if (!normalizedServerNames.includes(name)) {
|
||||
debugLogger.log(
|
||||
`${RED}Error:${RESET} Server '${args.name}' not found. Use 'gemini mcp' to see available servers.`,
|
||||
`${RED}Error:${RESET} Server '${argv.name}' not found. Use 'gemini mcp' to see available servers.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.session) {
|
||||
if (argv.session) {
|
||||
manager.disableForSession(name);
|
||||
debugLogger.log(
|
||||
`${GREEN}✓${RESET} MCP server '${name}' disabled for this session.`,
|
||||
@@ -100,6 +125,7 @@ export const enableCommand: CommandModule<object, Args> = {
|
||||
command: 'enable <name>',
|
||||
describe: 'Enable an MCP server',
|
||||
builder: (yargs) =>
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return */
|
||||
yargs
|
||||
.positional('name', {
|
||||
describe: 'MCP server name to enable',
|
||||
@@ -110,7 +136,8 @@ export const enableCommand: CommandModule<object, Args> = {
|
||||
describe: 'Clear session-only disable',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
}),
|
||||
}) as any,
|
||||
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return */
|
||||
handler: async (argv) => {
|
||||
await handleEnable(argv as Args);
|
||||
await exitCli();
|
||||
@@ -121,6 +148,7 @@ export const disableCommand: CommandModule<object, Args> = {
|
||||
command: 'disable <name>',
|
||||
describe: 'Disable an MCP server',
|
||||
builder: (yargs) =>
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return */
|
||||
yargs
|
||||
.positional('name', {
|
||||
describe: 'MCP server name to disable',
|
||||
@@ -131,7 +159,8 @@ export const disableCommand: CommandModule<object, Args> = {
|
||||
describe: 'Disable for current session only',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
}),
|
||||
}) as any,
|
||||
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return */
|
||||
handler: async (argv) => {
|
||||
await handleDisable(argv as Args);
|
||||
await exitCli();
|
||||
|
||||
@@ -24,6 +24,8 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { ExtensionStorage } from '../../config/extensions/storage.js';
|
||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
import { McpServerEnablementManager } from '../../config/mcp/index.js';
|
||||
import { loadCliConfig, type CliArgs } from '../../config/config.js';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
|
||||
vi.mock('../../config/settings.js', async (importOriginal) => {
|
||||
const actual =
|
||||
@@ -33,6 +35,7 @@ vi.mock('../../config/settings.js', async (importOriginal) => {
|
||||
loadSettings: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mock('../../config/config.js');
|
||||
vi.mock('../../config/extensions/storage.js', () => ({
|
||||
ExtensionStorage: {
|
||||
getUserExtensionsDir: vi.fn(),
|
||||
@@ -62,6 +65,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
{
|
||||
getGlobalSettingsPath: () => '/tmp/gemini/settings.json',
|
||||
getGlobalGeminiDir: () => '/tmp/gemini',
|
||||
getGlobalTempDir: () => '/tmp/gemini/tmp',
|
||||
},
|
||||
),
|
||||
GEMINI_DIR: '.gemini',
|
||||
@@ -138,7 +142,13 @@ describe('mcp list command', () => {
|
||||
merged: { ...defaultMergedSettings, mcpServers: {} },
|
||||
});
|
||||
|
||||
await listMcpServers();
|
||||
const mockConfig = {
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
getExtensionLoader: vi.fn().mockReturnValue(mockExtensionManager),
|
||||
};
|
||||
(loadCliConfig as Mock).mockResolvedValue(mockConfig as unknown as Config);
|
||||
|
||||
await listMcpServers({} as unknown as CliArgs);
|
||||
|
||||
expect(debugLogger.log).toHaveBeenCalledWith('No MCP servers configured.');
|
||||
});
|
||||
@@ -162,10 +172,16 @@ describe('mcp list command', () => {
|
||||
isTrusted: true,
|
||||
});
|
||||
|
||||
const mockConfig = {
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
getExtensionLoader: vi.fn().mockReturnValue(mockExtensionManager),
|
||||
};
|
||||
(loadCliConfig as Mock).mockResolvedValue(mockConfig as unknown as Config);
|
||||
|
||||
mockClient.connect.mockResolvedValue(undefined);
|
||||
mockClient.ping.mockResolvedValue(undefined);
|
||||
|
||||
await listMcpServers();
|
||||
await listMcpServers({} as unknown as CliArgs);
|
||||
|
||||
expect(debugLogger.log).toHaveBeenCalledWith('Configured MCP servers:\n');
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
@@ -206,9 +222,15 @@ describe('mcp list command', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const mockConfig = {
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
getExtensionLoader: vi.fn().mockReturnValue(mockExtensionManager),
|
||||
};
|
||||
(loadCliConfig as Mock).mockResolvedValue(mockConfig as unknown as Config);
|
||||
|
||||
mockClient.connect.mockRejectedValue(new Error('Connection failed'));
|
||||
|
||||
await listMcpServers();
|
||||
await listMcpServers({} as unknown as CliArgs);
|
||||
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
@@ -236,10 +258,16 @@ describe('mcp list command', () => {
|
||||
},
|
||||
]);
|
||||
|
||||
const mockConfig = {
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
getExtensionLoader: vi.fn().mockReturnValue(mockExtensionManager),
|
||||
};
|
||||
(loadCliConfig as Mock).mockResolvedValue(mockConfig as unknown as Config);
|
||||
|
||||
mockClient.connect.mockResolvedValue(undefined);
|
||||
mockClient.ping.mockResolvedValue(undefined);
|
||||
|
||||
await listMcpServers();
|
||||
await listMcpServers({} as unknown as CliArgs);
|
||||
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
@@ -276,13 +304,22 @@ describe('mcp list command', () => {
|
||||
merged: settingsWithAllowlist,
|
||||
});
|
||||
|
||||
const mockConfig = {
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
getExtensionLoader: vi.fn().mockReturnValue(mockExtensionManager),
|
||||
};
|
||||
(loadCliConfig as Mock).mockResolvedValue(mockConfig as unknown as Config);
|
||||
|
||||
mockClient.connect.mockResolvedValue(undefined);
|
||||
mockClient.ping.mockResolvedValue(undefined);
|
||||
|
||||
await listMcpServers({
|
||||
merged: settingsWithAllowlist,
|
||||
isTrusted: true,
|
||||
} as unknown as LoadedSettings);
|
||||
await listMcpServers(
|
||||
{} as unknown as CliArgs,
|
||||
{
|
||||
merged: settingsWithAllowlist,
|
||||
isTrusted: true,
|
||||
} as unknown as LoadedSettings,
|
||||
);
|
||||
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('allowed-server'),
|
||||
@@ -310,10 +347,16 @@ describe('mcp list command', () => {
|
||||
isTrusted: false,
|
||||
});
|
||||
|
||||
const mockConfig = {
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
getExtensionLoader: vi.fn().mockReturnValue(mockExtensionManager),
|
||||
};
|
||||
(loadCliConfig as Mock).mockResolvedValue(mockConfig as unknown as Config);
|
||||
|
||||
// createTransport will throw in core if not trusted
|
||||
mockedCreateTransport.mockRejectedValue(new Error('Folder not trusted'));
|
||||
|
||||
await listMcpServers();
|
||||
await listMcpServers({} as unknown as CliArgs);
|
||||
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
@@ -337,7 +380,13 @@ describe('mcp list command', () => {
|
||||
isTrusted: true,
|
||||
});
|
||||
|
||||
await listMcpServers();
|
||||
const mockConfig = {
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
getExtensionLoader: vi.fn().mockReturnValue(mockExtensionManager),
|
||||
};
|
||||
(loadCliConfig as Mock).mockResolvedValue(mockConfig as unknown as Config);
|
||||
|
||||
await listMcpServers({} as unknown as CliArgs);
|
||||
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
@@ -359,12 +408,18 @@ describe('mcp list command', () => {
|
||||
isTrusted: true,
|
||||
});
|
||||
|
||||
const mockConfig = {
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
getExtensionLoader: vi.fn().mockReturnValue(mockExtensionManager),
|
||||
};
|
||||
(loadCliConfig as Mock).mockResolvedValue(mockConfig as unknown as Config);
|
||||
|
||||
vi.spyOn(
|
||||
McpServerEnablementManager.prototype,
|
||||
'isFileEnabled',
|
||||
).mockResolvedValue(false);
|
||||
|
||||
await listMcpServers();
|
||||
await listMcpServers({} as unknown as CliArgs);
|
||||
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
|
||||
@@ -17,36 +17,32 @@ import {
|
||||
debugLogger,
|
||||
applyAdminAllowlist,
|
||||
getAdminBlockedMcpServersMessage,
|
||||
type GeminiCLIExtension,
|
||||
type MCPServerConfig,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { MCPServerConfig } from '@google/gemini-cli-core';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
import type { ExtensionManager } from '../../config/extension-manager.js';
|
||||
import {
|
||||
canLoadServer,
|
||||
McpServerEnablementManager,
|
||||
} from '../../config/mcp/index.js';
|
||||
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
|
||||
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
|
||||
import { loadCliConfig, type CliArgs } from '../../config/config.js';
|
||||
import { exitCli } from '../utils.js';
|
||||
import chalk from 'chalk';
|
||||
|
||||
export async function getMcpServersFromConfig(
|
||||
settings?: MergedSettings,
|
||||
settings: MergedSettings,
|
||||
extensionManager: ExtensionManager,
|
||||
): Promise<{
|
||||
mcpServers: Record<string, MCPServerConfig>;
|
||||
blockedServerNames: string[];
|
||||
}> {
|
||||
if (!settings) {
|
||||
settings = loadSettings().merged;
|
||||
let extensions: GeminiCLIExtension[];
|
||||
try {
|
||||
extensions = extensionManager.getExtensions();
|
||||
} catch {
|
||||
extensions = await extensionManager.loadExtensions();
|
||||
}
|
||||
|
||||
const extensionManager = new ExtensionManager({
|
||||
settings,
|
||||
workspaceDir: process.cwd(),
|
||||
requestConsent: requestConsentNonInteractive,
|
||||
requestSetting: promptForSetting,
|
||||
});
|
||||
const extensions = await extensionManager.loadExtensions();
|
||||
const mcpServers = { ...settings.mcpServers };
|
||||
for (const extension of extensions) {
|
||||
Object.entries(extension.mcpServers || {}).forEach(([key, server]) => {
|
||||
@@ -170,13 +166,23 @@ async function getServerStatus(
|
||||
}
|
||||
|
||||
export async function listMcpServers(
|
||||
argv: CliArgs,
|
||||
loadedSettingsArg?: LoadedSettings,
|
||||
): Promise<void> {
|
||||
const loadedSettings = loadedSettingsArg ?? loadSettings();
|
||||
const activeSettings = loadedSettings.merged;
|
||||
|
||||
const { mcpServers, blockedServerNames } =
|
||||
await getMcpServersFromConfig(activeSettings);
|
||||
const config = await loadCliConfig(activeSettings, 'mcp-list-session', argv, {
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
await config.initialize();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const extensionManager = config.getExtensionLoader() as ExtensionManager;
|
||||
|
||||
const { mcpServers, blockedServerNames } = await getMcpServersFromConfig(
|
||||
activeSettings,
|
||||
extensionManager,
|
||||
);
|
||||
const serverNames = Object.keys(mcpServers);
|
||||
|
||||
if (blockedServerNames.length > 0) {
|
||||
@@ -257,7 +263,8 @@ export const listCommand: CommandModule<object, ListArgs> = {
|
||||
command: 'list',
|
||||
describe: 'List all configured MCP servers',
|
||||
handler: async (argv) => {
|
||||
await listMcpServers(argv.loadedSettings);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
await listMcpServers(argv as unknown as CliArgs, argv.loadedSettings);
|
||||
await exitCli();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
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 = {
|
||||
command: 'profiles <command>',
|
||||
describe: 'Manage Gemini CLI profiles.',
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.middleware((argv) => {
|
||||
initializeOutputListenersAndFlush();
|
||||
argv['isCommand'] = true;
|
||||
})
|
||||
.command(listCommand)
|
||||
.command(enableCommand)
|
||||
.command(disableCommand)
|
||||
.command(uninstallCommand)
|
||||
.command(installCommand)
|
||||
.command(linkCommand)
|
||||
.demandCommand(1, 'You need at least one command before continuing.')
|
||||
.version(false),
|
||||
handler: () => {
|
||||
// This handler is not called when a subcommand is provided.
|
||||
// Yargs will show the help menu.
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { type CommandModule } from 'yargs';
|
||||
import { loadSettings, SettingScope } from '../../config/settings.js';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { exitCli } from '../utils.js';
|
||||
|
||||
/**
|
||||
* Command module for `gemini profiles disable`.
|
||||
*/
|
||||
export const disableCommand: CommandModule = {
|
||||
command: 'disable',
|
||||
describe: 'Disables the currently active profile.',
|
||||
handler: async () => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
settings.setValue(SettingScope.User, 'general.activeProfile', undefined);
|
||||
debugLogger.log('Profile disabled. Reverting to default behavior.');
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Profile disabled. Reverting to default behavior.');
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
`Error disabling profile: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
await exitCli(1);
|
||||
}
|
||||
await exitCli();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { type CommandModule } from 'yargs';
|
||||
import { loadSettings, SettingScope } from '../../config/settings.js';
|
||||
import { Storage, ProfileManager, debugLogger } from '@google/gemini-cli-core';
|
||||
import { exitCli } from '../utils.js';
|
||||
|
||||
/**
|
||||
* Command module for `gemini profiles enable <name>`.
|
||||
*/
|
||||
export const enableCommand: CommandModule = {
|
||||
command: 'enable <name>',
|
||||
describe: 'Enables a profile persistently.',
|
||||
builder: (yargs) =>
|
||||
yargs.positional('name', {
|
||||
describe: 'The name of the profile to enable.',
|
||||
type: 'string',
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
const name = String(argv['name']);
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
const profilesDir = Storage.getProfilesDir();
|
||||
const manager = new ProfileManager(profilesDir);
|
||||
await manager.load();
|
||||
|
||||
if (!manager.getProfile(name)) {
|
||||
throw new Error(`Profile "${name}" not found.`);
|
||||
}
|
||||
|
||||
settings.setValue(SettingScope.User, 'general.activeProfile', name);
|
||||
debugLogger.log(`Profile "${name}" successfully enabled.`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Profile "${name}" successfully enabled.`);
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
`Error enabling profile: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
await exitCli(1);
|
||||
}
|
||||
await exitCli();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import chalk from 'chalk';
|
||||
import { debugLogger, Storage, ProfileManager } from '@google/gemini-cli-core';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { exitCli } from '../utils.js';
|
||||
|
||||
interface InstallArgs {
|
||||
path: string;
|
||||
}
|
||||
|
||||
export async function handleInstall(args: InstallArgs) {
|
||||
try {
|
||||
const manager = new ProfileManager(Storage.getProfilesDir());
|
||||
const profile = await manager.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();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import chalk from 'chalk';
|
||||
import { debugLogger, Storage, ProfileManager } from '@google/gemini-cli-core';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { exitCli } from '../utils.js';
|
||||
|
||||
interface LinkArgs {
|
||||
path: string;
|
||||
}
|
||||
|
||||
export async function handleLink(args: LinkArgs) {
|
||||
try {
|
||||
const manager = new ProfileManager(Storage.getProfilesDir());
|
||||
const profile = await manager.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();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { render, Box, Text } from 'ink';
|
||||
import { Storage, ProfileManager } from '@google/gemini-cli-core';
|
||||
import { loadSettings } from '../../config/settings.js';
|
||||
import { exitCli } from '../utils.js';
|
||||
|
||||
/**
|
||||
* View component for listing profiles in the terminal.
|
||||
*/
|
||||
const ProfileListView = ({
|
||||
profiles,
|
||||
activeProfile,
|
||||
}: {
|
||||
profiles: string[];
|
||||
activeProfile?: string;
|
||||
}) => {
|
||||
if (profiles.length === 0) {
|
||||
return (
|
||||
<Box flexDirection="column" paddingY={1}>
|
||||
<Text color="yellow">No profiles found.</Text>
|
||||
<Text dimColor>
|
||||
Profiles are stored as .md files in ~/.gemini/profiles/
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingY={1}>
|
||||
<Text bold underline>
|
||||
Available Profiles:
|
||||
</Text>
|
||||
{profiles.map((name) => (
|
||||
<Box key={name} marginLeft={2}>
|
||||
<Text color={name === activeProfile ? 'green' : 'white'}>
|
||||
{name === activeProfile ? '●' : '○'} {name}
|
||||
</Text>
|
||||
{name === activeProfile && (
|
||||
<Text color="green" italic>
|
||||
{' '}
|
||||
(active)
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>
|
||||
Use `gemini profiles enable {'<name>'}` to switch profiles.
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Command module for `gemini profiles list`.
|
||||
*/
|
||||
export const listCommand: CommandModule = {
|
||||
command: 'list',
|
||||
describe: 'List all available profiles.',
|
||||
handler: async () => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
const profilesDir = Storage.getProfilesDir();
|
||||
const manager = new ProfileManager(profilesDir);
|
||||
await manager.load();
|
||||
const profiles = manager.getProfileNames();
|
||||
const activeProfile = settings.merged.general?.activeProfile;
|
||||
|
||||
const { waitUntilExit } = render(
|
||||
<ProfileListView profiles={profiles} activeProfile={activeProfile} />,
|
||||
);
|
||||
await waitUntilExit();
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
`Error listing profiles: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
await exitCli(1);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { type CommandModule } from 'yargs';
|
||||
import { Storage, ProfileManager, debugLogger } from '@google/gemini-cli-core';
|
||||
import { exitCli } from '../utils.js';
|
||||
|
||||
/**
|
||||
* Command module for `gemini profiles uninstall <name>`.
|
||||
*/
|
||||
export const uninstallCommand: CommandModule = {
|
||||
command: 'uninstall <name>',
|
||||
describe: 'Uninstalls a profile.',
|
||||
builder: (yargs) =>
|
||||
yargs.positional('name', {
|
||||
describe: 'The name of the profile to uninstall.',
|
||||
type: 'string',
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
const name = String(argv['name']);
|
||||
try {
|
||||
const manager = new ProfileManager(Storage.getProfilesDir());
|
||||
await manager.load();
|
||||
|
||||
await manager.uninstallProfile(name);
|
||||
debugLogger.log(`Profile "${name}" successfully uninstalled.`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Profile "${name}" successfully uninstalled.`);
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
`Error uninstalling profile: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
await exitCli(1);
|
||||
}
|
||||
await exitCli();
|
||||
},
|
||||
};
|
||||
@@ -8,7 +8,7 @@ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { coreEvents } from '@google/gemini-cli-core';
|
||||
import { handleList, listCommand } from './list.js';
|
||||
import { loadSettings, type LoadedSettings } from '../../config/settings.js';
|
||||
import { loadCliConfig } from '../../config/config.js';
|
||||
import { loadCliConfig, type CliArgs } from '../../config/config.js';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
@@ -55,7 +55,7 @@ describe('skills list command', () => {
|
||||
};
|
||||
mockLoadCliConfig.mockResolvedValue(mockConfig as unknown as Config);
|
||||
|
||||
await handleList({});
|
||||
await handleList({} as unknown as CliArgs, {});
|
||||
|
||||
expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
|
||||
'log',
|
||||
@@ -86,7 +86,7 @@ describe('skills list command', () => {
|
||||
};
|
||||
mockLoadCliConfig.mockResolvedValue(mockConfig as unknown as Config);
|
||||
|
||||
await handleList({});
|
||||
await handleList({} as unknown as CliArgs, {});
|
||||
|
||||
expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
|
||||
'log',
|
||||
@@ -135,7 +135,7 @@ describe('skills list command', () => {
|
||||
mockLoadCliConfig.mockResolvedValue(mockConfig as unknown as Config);
|
||||
|
||||
// Default
|
||||
await handleList({ all: false });
|
||||
await handleList({} as unknown as CliArgs, { all: false });
|
||||
expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
|
||||
'log',
|
||||
expect.stringContaining('regular'),
|
||||
@@ -148,7 +148,7 @@ describe('skills list command', () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// With all: true
|
||||
await handleList({ all: true });
|
||||
await handleList({} as unknown as CliArgs, { all: true });
|
||||
expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
|
||||
'log',
|
||||
expect.stringContaining('regular'),
|
||||
@@ -166,7 +166,9 @@ describe('skills list command', () => {
|
||||
it('should throw an error when listing fails', async () => {
|
||||
mockLoadCliConfig.mockRejectedValue(new Error('List failed'));
|
||||
|
||||
await expect(handleList({})).rejects.toThrow('List failed');
|
||||
await expect(handleList({} as unknown as CliArgs, {})).rejects.toThrow(
|
||||
'List failed',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -11,17 +11,14 @@ import { loadCliConfig, type CliArgs } from '../../config/config.js';
|
||||
import { exitCli } from '../utils.js';
|
||||
import chalk from 'chalk';
|
||||
|
||||
export async function handleList(args: { all?: boolean }) {
|
||||
export async function handleList(argv: CliArgs, args: { all?: boolean }) {
|
||||
const workspaceDir = process.cwd();
|
||||
const settings = loadSettings(workspaceDir);
|
||||
|
||||
const config = await loadCliConfig(
|
||||
settings.merged,
|
||||
'skills-list-session',
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
{
|
||||
debug: false,
|
||||
} as Partial<CliArgs> as CliArgs,
|
||||
argv,
|
||||
{ cwd: workspaceDir },
|
||||
);
|
||||
|
||||
@@ -73,8 +70,9 @@ export const listCommand: CommandModule = {
|
||||
default: false,
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
const args = { all: Boolean(argv['all']) };
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
await handleList({ all: argv['all'] as boolean });
|
||||
await handleList(argv as unknown as CliArgs, args);
|
||||
await exitCli();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ import { hideBin } from 'yargs/helpers';
|
||||
import process from 'node:process';
|
||||
import { mcpCommand } from '../commands/mcp.js';
|
||||
import { extensionsCommand } from '../commands/extensions.js';
|
||||
import { profilesCommand } from '../commands/profiles.js';
|
||||
import { skillsCommand } from '../commands/skills.js';
|
||||
import { hooksCommand } from '../commands/hooks.js';
|
||||
import {
|
||||
@@ -38,6 +39,8 @@ import {
|
||||
type HookDefinition,
|
||||
type HookEventName,
|
||||
type OutputFormat,
|
||||
Storage as CoreStorage,
|
||||
ProfileManager as CoreProfileManager,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
type Settings,
|
||||
@@ -61,6 +64,7 @@ import type { ExtensionEvents } from '@google/gemini-cli-core/src/utils/extensio
|
||||
import { requestConsentNonInteractive } from './extensions/consent.js';
|
||||
import { promptForSetting } from './extensions/extensionSettings.js';
|
||||
import type { EventEmitter } from 'node:stream';
|
||||
// Removed legacy profile manager import
|
||||
import { runExitCleanup } from '../utils/cleanup.js';
|
||||
|
||||
export interface CliArgs {
|
||||
@@ -93,6 +97,7 @@ export interface CliArgs {
|
||||
rawOutput: boolean | undefined;
|
||||
acceptRawOutputRisk: boolean | undefined;
|
||||
isCommand: boolean | undefined;
|
||||
profiles: string | undefined;
|
||||
}
|
||||
|
||||
export async function parseArguments(
|
||||
@@ -106,6 +111,12 @@ export async function parseArguments(
|
||||
.usage(
|
||||
'Usage: gemini [options] [command]\n\nGemini CLI - Defaults to interactive mode. Use -p/--prompt for non-interactive (headless) mode.',
|
||||
)
|
||||
.option('profiles', {
|
||||
alias: ['profile', 'P'],
|
||||
type: 'string',
|
||||
global: true,
|
||||
description: 'The name of the profile to use for this session.',
|
||||
})
|
||||
.option('debug', {
|
||||
alias: 'd',
|
||||
type: 'boolean',
|
||||
@@ -143,7 +154,6 @@ export async function parseArguments(
|
||||
type: 'boolean',
|
||||
description: 'Run in sandbox?',
|
||||
})
|
||||
|
||||
.option('yolo', {
|
||||
alias: 'y',
|
||||
type: 'boolean',
|
||||
@@ -289,6 +299,11 @@ export async function parseArguments(
|
||||
.option('accept-raw-output-risk', {
|
||||
type: 'boolean',
|
||||
description: 'Suppress the security warning when using --raw-output.',
|
||||
})
|
||||
.option('profiles', {
|
||||
alias: ['profile', 'P'],
|
||||
type: 'string',
|
||||
description: 'The name of the profile to use for this session.',
|
||||
}),
|
||||
)
|
||||
// Register MCP subcommands
|
||||
@@ -340,6 +355,8 @@ export async function parseArguments(
|
||||
yargsInstance.command(hooksCommand);
|
||||
}
|
||||
|
||||
yargsInstance.command(profilesCommand);
|
||||
|
||||
yargsInstance
|
||||
.version(await getVersion()) // This will enable the --version flag based on package.json
|
||||
.alias('v', 'version')
|
||||
@@ -421,7 +438,16 @@ export async function loadCliConfig(
|
||||
const { cwd = process.cwd(), projectHooks } = options;
|
||||
const debugMode = isDebugMode(argv);
|
||||
|
||||
const loadedSettings = loadSettings(cwd);
|
||||
const profilesDir = CoreStorage.getProfilesDir();
|
||||
const coreProfileManager = new CoreProfileManager(profilesDir);
|
||||
await coreProfileManager.load();
|
||||
const activeProfileName =
|
||||
argv.profiles ||
|
||||
settings.general?.activeProfile ||
|
||||
coreProfileManager.getActiveProfileName();
|
||||
const profile = activeProfileName
|
||||
? coreProfileManager.getProfile(activeProfileName)
|
||||
: null;
|
||||
|
||||
if (argv.sandbox) {
|
||||
process.env['GEMINI_SANDBOX'] = 'true';
|
||||
@@ -470,12 +496,21 @@ export async function loadCliConfig(
|
||||
.map(resolvePath)
|
||||
.concat((argv.includeDirectories || []).map(resolvePath));
|
||||
|
||||
let enabledExtensionOverrides = argv.extensions;
|
||||
if (enabledExtensionOverrides === undefined && profile) {
|
||||
const profileExtensions = profile.extensions;
|
||||
if (profileExtensions !== undefined) {
|
||||
enabledExtensionOverrides =
|
||||
profileExtensions.length > 0 ? profileExtensions : ['none'];
|
||||
}
|
||||
}
|
||||
|
||||
const extensionManager = new ExtensionManager({
|
||||
settings,
|
||||
requestConsent: requestConsentNonInteractive,
|
||||
requestSetting: promptForSetting,
|
||||
workspaceDir: cwd,
|
||||
enabledExtensionOverrides: argv.extensions,
|
||||
enabledExtensionOverrides,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
eventEmitter: coreEvents as EventEmitter<ExtensionEvents>,
|
||||
clientVersion: await getVersion(),
|
||||
@@ -511,6 +546,20 @@ export async function loadCliConfig(
|
||||
filePaths = result.filePaths;
|
||||
}
|
||||
|
||||
if (profile?.body) {
|
||||
const profileContext = `Profile Context (${profile.name}):\n${profile.body}`;
|
||||
if (typeof memoryContent === 'string') {
|
||||
memoryContent = profileContext + '\n\n' + memoryContent;
|
||||
} else {
|
||||
// If it's HierarchicalMemory, we'll need to prepend it to the text content if possible
|
||||
// or just treat it as a string if we can't easily modify HierarchicalMemory here.
|
||||
// For now, let's assume if it's not a string, we might have issues prepending easily.
|
||||
// But looking at core, userMemory is often expected to be a string in simple cases.
|
||||
// If it's HierarchicalMemory, we might need to handle it in core.
|
||||
// Let's check how it's handled in core.
|
||||
}
|
||||
}
|
||||
|
||||
const question = argv.promptInteractive || argv.prompt || '';
|
||||
|
||||
// Determine approval mode with backward compatibility
|
||||
@@ -651,7 +700,10 @@ export async function loadCliConfig(
|
||||
|
||||
const defaultModel = PREVIEW_GEMINI_MODEL_AUTO;
|
||||
const specifiedModel =
|
||||
argv.model || process.env['GEMINI_MODEL'] || settings.model?.name;
|
||||
argv.model ||
|
||||
profile?.default_model ||
|
||||
process.env['GEMINI_MODEL'] ||
|
||||
settings.model?.name;
|
||||
|
||||
const resolvedModel =
|
||||
specifiedModel === GEMINI_MODEL_ALIAS_AUTO
|
||||
@@ -721,6 +773,8 @@ export async function loadCliConfig(
|
||||
extensionsEnabled,
|
||||
agents: settings.agents,
|
||||
adminSkillsEnabled,
|
||||
profilesDir,
|
||||
activeProfile: activeProfileName || undefined,
|
||||
allowedMcpServers: mcpEnabled
|
||||
? (argv.allowedMcpServerNames ?? settings.mcp?.allowed)
|
||||
: undefined,
|
||||
@@ -818,7 +872,7 @@ export async function loadCliConfig(
|
||||
hooks: settings.hooks || {},
|
||||
disabledHooks: settings.hooksConfig?.disabled || [],
|
||||
projectHooks: projectHooks || {},
|
||||
onModelChange: (model: string) => saveModelChange(loadedSettings, model),
|
||||
onModelChange: (model: string) => saveModelChange(loadSettings(cwd), model),
|
||||
onReload: async () => {
|
||||
const refreshedSettings = loadSettings(cwd);
|
||||
return {
|
||||
|
||||
@@ -379,6 +379,15 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
description: 'Settings for automatic session cleanup.',
|
||||
},
|
||||
activeProfile: {
|
||||
type: 'string',
|
||||
label: 'Active Profile',
|
||||
category: 'General',
|
||||
requiresRestart: true,
|
||||
default: undefined as string | undefined,
|
||||
description: 'The name of the currently active profile.',
|
||||
showInDialog: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
output: {
|
||||
|
||||
@@ -44,11 +44,15 @@ describe('initializer', () => {
|
||||
getToolRegistry: ReturnType<typeof vi.fn>;
|
||||
getIdeMode: ReturnType<typeof vi.fn>;
|
||||
getGeminiMdFileCount: ReturnType<typeof vi.fn>;
|
||||
getProfileManager: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let mockSettings: LoadedSettings;
|
||||
let mockIdeClient: {
|
||||
connect: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let mockProfileManager: {
|
||||
load: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -56,7 +60,12 @@ describe('initializer', () => {
|
||||
getToolRegistry: vi.fn(),
|
||||
getIdeMode: vi.fn().mockReturnValue(false),
|
||||
getGeminiMdFileCount: vi.fn().mockReturnValue(5),
|
||||
getProfileManager: vi.fn(),
|
||||
};
|
||||
mockProfileManager = {
|
||||
load: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
mockConfig.getProfileManager.mockReturnValue(mockProfileManager);
|
||||
mockSettings = {
|
||||
merged: {
|
||||
security: {
|
||||
|
||||
@@ -60,6 +60,11 @@ export async function initializeApp(
|
||||
logIdeConnection(config, new IdeConnectionEvent(IdeConnectionType.START));
|
||||
}
|
||||
|
||||
// Load profiles
|
||||
const profilesHandle = startupProfiler.start('load_profiles');
|
||||
await config.getProfileManager().load();
|
||||
profilesHandle?.end();
|
||||
|
||||
return {
|
||||
authError,
|
||||
accountSuspensionInfo,
|
||||
|
||||
@@ -498,6 +498,7 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
rawOutput: undefined,
|
||||
acceptRawOutputRisk: undefined,
|
||||
isCommand: undefined,
|
||||
profiles: undefined,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
|
||||
@@ -48,6 +48,7 @@ import { planCommand } from '../ui/commands/planCommand.js';
|
||||
import { policiesCommand } from '../ui/commands/policiesCommand.js';
|
||||
import { privacyCommand } from '../ui/commands/privacyCommand.js';
|
||||
import { profileCommand } from '../ui/commands/profileCommand.js';
|
||||
import { profilesCommand } from '../ui/commands/profilesCommand.js';
|
||||
import { quitCommand } from '../ui/commands/quitCommand.js';
|
||||
import { restoreCommand } from '../ui/commands/restoreCommand.js';
|
||||
import { resumeCommand } from '../ui/commands/resumeCommand.js';
|
||||
@@ -188,6 +189,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
policiesCommand,
|
||||
privacyCommand,
|
||||
...(isDevelopment ? [profileCommand] : []),
|
||||
profilesCommand,
|
||||
quitCommand,
|
||||
restoreCommand(this.config),
|
||||
{
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
type CommandContext,
|
||||
type SlashCommand,
|
||||
type SlashCommandActionReturn,
|
||||
CommandKind,
|
||||
} from './types.js';
|
||||
import { type HistoryItemProfilesList, MessageType } from '../types.js';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import {
|
||||
listProfiles,
|
||||
switchProfile,
|
||||
getActiveProfile,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
async function listAction(
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
): Promise<void | SlashCommandActionReturn> {
|
||||
const subArgs = args.trim().split(/\s+/);
|
||||
let useShowDescriptions = true;
|
||||
|
||||
for (const arg of subArgs) {
|
||||
if (arg === 'nodesc' || arg === '--nodesc') {
|
||||
useShowDescriptions = false;
|
||||
}
|
||||
}
|
||||
|
||||
const config = context.services.config;
|
||||
if (!config) {
|
||||
context.ui.addItem({
|
||||
type: MessageType.ERROR,
|
||||
text: 'Could not retrieve configuration.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const profiles = listProfiles(config);
|
||||
const activeProfile = getActiveProfile(config);
|
||||
|
||||
const profilesListItem: HistoryItemProfilesList = {
|
||||
type: MessageType.PROFILES_LIST,
|
||||
profiles,
|
||||
activeProfileName: activeProfile?.name,
|
||||
showDescriptions: useShowDescriptions,
|
||||
};
|
||||
|
||||
context.ui.addItem(profilesListItem);
|
||||
}
|
||||
|
||||
async function switchAction(
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
): Promise<void | SlashCommandActionReturn> {
|
||||
const profileName = args.trim();
|
||||
if (!profileName) {
|
||||
context.ui.addItem({
|
||||
type: MessageType.ERROR,
|
||||
text: 'Please provide a profile name to switch to.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const config = context.services.config;
|
||||
if (!config) {
|
||||
context.ui.addItem({
|
||||
type: MessageType.ERROR,
|
||||
text: 'Could not retrieve configuration.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await switchProfile(config, profileName);
|
||||
context.ui.addItem({
|
||||
type: MessageType.INFO,
|
||||
text: `Switched to profile: ${profileName}`,
|
||||
});
|
||||
} catch (error) {
|
||||
context.ui.addItem({
|
||||
type: MessageType.ERROR,
|
||||
text: `Failed to switch profile: ${getErrorMessage(error)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadAction(
|
||||
context: CommandContext,
|
||||
): Promise<void | SlashCommandActionReturn> {
|
||||
const config = context.services.config;
|
||||
if (!config) {
|
||||
context.ui.addItem({
|
||||
type: MessageType.ERROR,
|
||||
text: 'Could not retrieve configuration.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await config.getProfileManager().load();
|
||||
context.ui.addItem({
|
||||
type: MessageType.INFO,
|
||||
text: 'Profiles reloaded successfully.',
|
||||
});
|
||||
} catch (error) {
|
||||
context.ui.addItem({
|
||||
type: MessageType.ERROR,
|
||||
text: `Failed to reload profiles: ${getErrorMessage(error)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const profilesCommand: SlashCommand = {
|
||||
name: 'profiles',
|
||||
description:
|
||||
'List, switch, or reload Gemini CLI profiles. Usage: /profiles [list | switch <name> | reload]',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: false,
|
||||
subCommands: [
|
||||
{
|
||||
name: 'list',
|
||||
description: 'List available profiles. Usage: /profiles list [nodesc]',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: listAction,
|
||||
},
|
||||
{
|
||||
name: 'switch',
|
||||
description:
|
||||
'Switch to a profile by name. Usage: /profiles switch <name>',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: switchAction,
|
||||
},
|
||||
{
|
||||
name: 'reload',
|
||||
description:
|
||||
'Reload the list of discovered profiles. Usage: /profiles reload',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: reloadAction,
|
||||
},
|
||||
],
|
||||
action: listAction,
|
||||
};
|
||||
@@ -35,6 +35,7 @@ import { ChatList } from './views/ChatList.js';
|
||||
import { ModelMessage } from './messages/ModelMessage.js';
|
||||
import { ThinkingMessage } from './messages/ThinkingMessage.js';
|
||||
import { HintMessage } from './messages/HintMessage.js';
|
||||
import { ProfilesList } from './views/ProfilesList.js';
|
||||
import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
|
||||
@@ -233,6 +234,13 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
{itemForDisplay.type === 'chat_list' && (
|
||||
<ChatList chats={itemForDisplay.chats} />
|
||||
)}
|
||||
{itemForDisplay.type === 'profiles_list' && (
|
||||
<ProfilesList
|
||||
profiles={itemForDisplay.profiles}
|
||||
activeProfileName={itemForDisplay.activeProfileName}
|
||||
showDescriptions={itemForDisplay.showDescriptions}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { type ProfileDefinition } from '../../types.js';
|
||||
|
||||
interface ProfilesListProps {
|
||||
profiles: readonly ProfileDefinition[];
|
||||
activeProfileName?: string;
|
||||
showDescriptions: boolean;
|
||||
}
|
||||
|
||||
export const ProfilesList: React.FC<ProfilesListProps> = ({
|
||||
profiles,
|
||||
activeProfileName,
|
||||
showDescriptions,
|
||||
}) => {
|
||||
const sortedProfiles = profiles
|
||||
.slice()
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const renderProfile = (profile: ProfileDefinition) => {
|
||||
const isActive = profile.name === activeProfileName;
|
||||
|
||||
return (
|
||||
<Box key={profile.name} flexDirection="row">
|
||||
<Text color={theme.text.primary}>{' '}- </Text>
|
||||
<Box flexDirection="column">
|
||||
<Box flexDirection="row">
|
||||
<Text bold color={isActive ? theme.text.link : theme.text.primary}>
|
||||
{profile.name}
|
||||
</Text>
|
||||
{isActive && (
|
||||
<Text color={theme.status.success}>{' [Active]'}</Text>
|
||||
)}
|
||||
</Box>
|
||||
{showDescriptions && profile.description && (
|
||||
<Box marginLeft={2}>
|
||||
<Text color={theme.text.secondary}>{profile.description}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
{sortedProfiles.length > 0 ? (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={theme.text.primary}>
|
||||
Available Profiles:
|
||||
</Text>
|
||||
<Box height={1} />
|
||||
{sortedProfiles.map(renderProfile)}
|
||||
</Box>
|
||||
) : (
|
||||
<Text color={theme.text.primary}>No profiles available</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type ToolResultDisplay,
|
||||
type RetrieveUserQuotaResponse,
|
||||
type SkillDefinition,
|
||||
type ProfileDefinition,
|
||||
type AgentDefinition,
|
||||
type ApprovalMode,
|
||||
type Kind,
|
||||
@@ -22,7 +23,7 @@ import {
|
||||
import type { PartListUnion } from '@google/genai';
|
||||
import { type ReactNode } from 'react';
|
||||
|
||||
export type { ThoughtSummary, SkillDefinition };
|
||||
export type { ThoughtSummary, SkillDefinition, ProfileDefinition };
|
||||
|
||||
export enum AuthState {
|
||||
// Attempting to authenticate or re-authenticate
|
||||
@@ -290,6 +291,13 @@ export type HistoryItemSkillsList = HistoryItemBase & {
|
||||
showDescriptions: boolean;
|
||||
};
|
||||
|
||||
export type HistoryItemProfilesList = HistoryItemBase & {
|
||||
type: 'profiles_list';
|
||||
profiles: ProfileDefinition[];
|
||||
activeProfileName?: string;
|
||||
showDescriptions: boolean;
|
||||
};
|
||||
|
||||
export type AgentDefinitionJson = Pick<
|
||||
AgentDefinition,
|
||||
'name' | 'displayName' | 'description' | 'kind'
|
||||
@@ -376,6 +384,7 @@ export type HistoryItemWithoutId =
|
||||
| HistoryItemExtensionsList
|
||||
| HistoryItemToolsList
|
||||
| HistoryItemSkillsList
|
||||
| HistoryItemProfilesList
|
||||
| HistoryItemAgentsList
|
||||
| HistoryItemMcpStatus
|
||||
| HistoryItemChatList
|
||||
@@ -401,6 +410,7 @@ export enum MessageType {
|
||||
EXTENSIONS_LIST = 'extensions_list',
|
||||
TOOLS_LIST = 'tools_list',
|
||||
SKILLS_LIST = 'skills_list',
|
||||
PROFILES_LIST = 'profiles_list',
|
||||
AGENTS_LIST = 'agents_list',
|
||||
MCP_STATUS = 'mcp_status',
|
||||
CHAT_LIST = 'chat_list',
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -143,6 +143,10 @@ export class Storage {
|
||||
return path.join(Storage.getGlobalTempDir(), BIN_DIR_NAME);
|
||||
}
|
||||
|
||||
static getProfilesDir(): string {
|
||||
return path.join(Storage.getGlobalGeminiDir(), 'profiles');
|
||||
}
|
||||
|
||||
getGeminiDir(): string {
|
||||
return path.join(this.targetDir, GEMINI_DIR);
|
||||
}
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user