diff options
| author | Jack Wotherspoon <[email protected]> | 2025-08-06 11:52:29 -0400 |
|---|---|---|
| committer | GitHub <[email protected]> | 2025-08-06 15:52:29 +0000 |
| commit | ca4c745e3b620e3ac4eca24b610cc7b936c0a50d (patch) | |
| tree | eb1b9cfb46bd4fa2e7f9631828b0704df1050eb7 /packages/cli/src | |
| parent | b38f377c9a2672e88dd15119b38d908c0f00b54a (diff) | |
feat(mcp): add `gemini mcp` commands for `add`, `remove` and `list` (#5481)
Diffstat (limited to 'packages/cli/src')
| -rw-r--r-- | packages/cli/src/commands/mcp.test.ts | 55 | ||||
| -rw-r--r-- | packages/cli/src/commands/mcp.ts | 27 | ||||
| -rw-r--r-- | packages/cli/src/commands/mcp/add.test.ts | 88 | ||||
| -rw-r--r-- | packages/cli/src/commands/mcp/add.ts | 211 | ||||
| -rw-r--r-- | packages/cli/src/commands/mcp/list.test.ts | 154 | ||||
| -rw-r--r-- | packages/cli/src/commands/mcp/list.ts | 139 | ||||
| -rw-r--r-- | packages/cli/src/commands/mcp/remove.test.ts | 69 | ||||
| -rw-r--r-- | packages/cli/src/commands/mcp/remove.ts | 60 | ||||
| -rw-r--r-- | packages/cli/src/config/config.ts | 323 |
9 files changed, 971 insertions, 155 deletions
diff --git a/packages/cli/src/commands/mcp.test.ts b/packages/cli/src/commands/mcp.test.ts new file mode 100644 index 00000000..b4e9980c --- /dev/null +++ b/packages/cli/src/commands/mcp.test.ts @@ -0,0 +1,55 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } from 'vitest'; +import { mcpCommand } from './mcp.js'; +import { type Argv } from 'yargs'; +import yargs from 'yargs'; + +describe('mcp command', () => { + it('should have correct command definition', () => { + expect(mcpCommand.command).toBe('mcp'); + expect(mcpCommand.describe).toBe('Manage MCP servers'); + expect(typeof mcpCommand.builder).toBe('function'); + expect(typeof mcpCommand.handler).toBe('function'); + }); + + it('should have exactly one option (help flag)', () => { + // Test to ensure that the global 'gemini' flags are not added to the mcp command + const yargsInstance = yargs(); + const builtYargs = mcpCommand.builder(yargsInstance); + const options = builtYargs.getOptions(); + + // Should have exactly 1 option (help flag) + expect(Object.keys(options.key).length).toBe(1); + expect(options.key).toHaveProperty('help'); + }); + + it('should register add, remove, and list subcommands', () => { + const mockYargs = { + command: vi.fn().mockReturnThis(), + demandCommand: vi.fn().mockReturnThis(), + version: vi.fn().mockReturnThis(), + }; + + mcpCommand.builder(mockYargs as unknown as Argv); + + expect(mockYargs.command).toHaveBeenCalledTimes(3); + + // Verify that the specific subcommands are registered + const commandCalls = mockYargs.command.mock.calls; + const commandNames = commandCalls.map((call) => call[0].command); + + expect(commandNames).toContain('add <name> <commandOrUrl> [args...]'); + expect(commandNames).toContain('remove <name>'); + expect(commandNames).toContain('list'); + + expect(mockYargs.demandCommand).toHaveBeenCalledWith( + 1, + 'You need at least one command before continuing.', + ); + }); +}); diff --git a/packages/cli/src/commands/mcp.ts b/packages/cli/src/commands/mcp.ts new file mode 100644 index 00000000..5e55286c --- /dev/null +++ b/packages/cli/src/commands/mcp.ts @@ -0,0 +1,27 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// File for 'gemini mcp' command +import type { CommandModule, Argv } from 'yargs'; +import { addCommand } from './mcp/add.js'; +import { removeCommand } from './mcp/remove.js'; +import { listCommand } from './mcp/list.js'; + +export const mcpCommand: CommandModule = { + command: 'mcp', + describe: 'Manage MCP servers', + builder: (yargs: Argv) => + yargs + .command(addCommand) + .command(removeCommand) + .command(listCommand) + .demandCommand(1, 'You need at least one command before continuing.') + .version(false), + handler: () => { + // yargs will automatically show help if no subcommand is provided + // thanks to demandCommand(1) in the builder. + }, +}; diff --git a/packages/cli/src/commands/mcp/add.test.ts b/packages/cli/src/commands/mcp/add.test.ts new file mode 100644 index 00000000..1d431c48 --- /dev/null +++ b/packages/cli/src/commands/mcp/add.test.ts @@ -0,0 +1,88 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import yargs from 'yargs'; +import { addCommand } from './add.js'; +import { loadSettings, SettingScope } from '../../config/settings.js'; + +vi.mock('fs/promises', () => ({ + readFile: vi.fn(), + writeFile: vi.fn(), +})); + +vi.mock('../../config/settings.js', async () => { + const actual = await vi.importActual('../../config/settings.js'); + return { + ...actual, + loadSettings: vi.fn(), + }; +}); + +const mockedLoadSettings = loadSettings as vi.Mock; + +describe('mcp add command', () => { + let parser: yargs.Argv; + let mockSetValue: vi.Mock; + + beforeEach(() => { + vi.resetAllMocks(); + const yargsInstance = yargs([]).command(addCommand); + parser = yargsInstance; + mockSetValue = vi.fn(); + mockedLoadSettings.mockReturnValue({ + forScope: () => ({ settings: {} }), + setValue: mockSetValue, + }); + }); + + it('should add a stdio server to project settings', async () => { + await parser.parseAsync( + 'add my-server /path/to/server arg1 arg2 -e FOO=bar', + ); + + expect(mockSetValue).toHaveBeenCalledWith( + SettingScope.Workspace, + 'mcpServers', + { + 'my-server': { + command: '/path/to/server', + args: ['arg1', 'arg2'], + env: { FOO: 'bar' }, + }, + }, + ); + }); + + it('should add an sse server to user settings', async () => { + await parser.parseAsync( + 'add --transport sse sse-server https://example.com/sse-endpoint --scope user -H "X-API-Key: your-key"', + ); + + expect(mockSetValue).toHaveBeenCalledWith(SettingScope.User, 'mcpServers', { + 'sse-server': { + url: 'https://example.com/sse-endpoint', + headers: { 'X-API-Key': 'your-key' }, + }, + }); + }); + + it('should add an http server to project settings', async () => { + await parser.parseAsync( + 'add --transport http http-server https://example.com/mcp -H "Authorization: Bearer your-token"', + ); + + expect(mockSetValue).toHaveBeenCalledWith( + SettingScope.Workspace, + 'mcpServers', + { + 'http-server': { + httpUrl: 'https://example.com/mcp', + headers: { Authorization: 'Bearer your-token' }, + }, + }, + ); + }); +}); diff --git a/packages/cli/src/commands/mcp/add.ts b/packages/cli/src/commands/mcp/add.ts new file mode 100644 index 00000000..9537e131 --- /dev/null +++ b/packages/cli/src/commands/mcp/add.ts @@ -0,0 +1,211 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// File for 'gemini mcp add' command +import type { CommandModule } from 'yargs'; +import { loadSettings, SettingScope } from '../../config/settings.js'; +import { MCPServerConfig } from '@google/gemini-cli-core'; + +async function addMcpServer( + name: string, + commandOrUrl: string, + args: Array<string | number> | undefined, + options: { + scope: string; + transport: string; + env: string[] | undefined; + header: string[] | undefined; + timeout?: number; + trust?: boolean; + description?: string; + includeTools?: string[]; + excludeTools?: string[]; + }, +) { + const { + scope, + transport, + env, + header, + timeout, + trust, + description, + includeTools, + excludeTools, + } = options; + const settingsScope = + scope === 'user' ? SettingScope.User : SettingScope.Workspace; + const settings = loadSettings(process.cwd()); + + let newServer: Partial<MCPServerConfig> = {}; + + const headers = header?.reduce( + (acc, curr) => { + const [key, ...valueParts] = curr.split(':'); + const value = valueParts.join(':').trim(); + if (key.trim() && value) { + acc[key.trim()] = value; + } + return acc; + }, + {} as Record<string, string>, + ); + + switch (transport) { + case 'sse': + newServer = { + url: commandOrUrl, + headers, + timeout, + trust, + description, + includeTools, + excludeTools, + }; + break; + case 'http': + newServer = { + httpUrl: commandOrUrl, + headers, + timeout, + trust, + description, + includeTools, + excludeTools, + }; + break; + case 'stdio': + default: + newServer = { + command: commandOrUrl, + args: args?.map(String), + env: env?.reduce( + (acc, curr) => { + const [key, value] = curr.split('='); + if (key && value) { + acc[key] = value; + } + return acc; + }, + {} as Record<string, string>, + ), + timeout, + trust, + description, + includeTools, + excludeTools, + }; + break; + } + + const existingSettings = settings.forScope(settingsScope).settings; + const mcpServers = existingSettings.mcpServers || {}; + + const isExistingServer = !!mcpServers[name]; + if (isExistingServer) { + console.log( + `MCP server "${name}" is already configured within ${scope} settings.`, + ); + } + + mcpServers[name] = newServer as MCPServerConfig; + + settings.setValue(settingsScope, 'mcpServers', mcpServers); + + if (isExistingServer) { + console.log(`MCP server "${name}" updated in ${scope} settings.`); + } else { + console.log( + `MCP server "${name}" added to ${scope} settings. (${transport})`, + ); + } +} + +export const addCommand: CommandModule = { + command: 'add <name> <commandOrUrl> [args...]', + describe: 'Add a server', + builder: (yargs) => + yargs + .usage('Usage: gemini mcp add [options] <name> <commandOrUrl> [args...]') + .positional('name', { + describe: 'Name of the server', + type: 'string', + demandOption: true, + }) + .positional('commandOrUrl', { + describe: 'Command (stdio) or URL (sse, http)', + type: 'string', + demandOption: true, + }) + .option('scope', { + alias: 's', + describe: 'Configuration scope (user or project)', + type: 'string', + default: 'project', + choices: ['user', 'project'], + }) + .option('transport', { + alias: 't', + describe: 'Transport type (stdio, sse, http)', + type: 'string', + default: 'stdio', + choices: ['stdio', 'sse', 'http'], + }) + .option('env', { + alias: 'e', + describe: 'Set environment variables (e.g. -e KEY=value)', + type: 'array', + string: true, + }) + .option('header', { + alias: 'H', + describe: + 'Set HTTP headers for SSE and HTTP transports (e.g. -H "X-Api-Key: abc123" -H "Authorization: Bearer abc123")', + type: 'array', + string: true, + }) + .option('timeout', { + describe: 'Set connection timeout in milliseconds', + type: 'number', + }) + .option('trust', { + describe: + 'Trust the server (bypass all tool call confirmation prompts)', + type: 'boolean', + }) + .option('description', { + describe: 'Set the description for the server', + type: 'string', + }) + .option('include-tools', { + describe: 'A comma-separated list of tools to include', + type: 'array', + string: true, + }) + .option('exclude-tools', { + describe: 'A comma-separated list of tools to exclude', + type: 'array', + string: true, + }), + handler: async (argv) => { + await addMcpServer( + argv.name as string, + argv.commandOrUrl as string, + argv.args as Array<string | number>, + { + scope: argv.scope as string, + transport: argv.transport as string, + env: argv.env as string[], + header: argv.header as string[], + timeout: argv.timeout as number | undefined, + trust: argv.trust as boolean | undefined, + description: argv.description as string | undefined, + includeTools: argv.includeTools as string[] | undefined, + excludeTools: argv.excludeTools as string[] | undefined, + }, + ); + }, +}; diff --git a/packages/cli/src/commands/mcp/list.test.ts b/packages/cli/src/commands/mcp/list.test.ts new file mode 100644 index 00000000..daf2e3d7 --- /dev/null +++ b/packages/cli/src/commands/mcp/list.test.ts @@ -0,0 +1,154 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { listMcpServers } from './list.js'; +import { loadSettings } from '../../config/settings.js'; +import { loadExtensions } from '../../config/extension.js'; +import { createTransport } from '@google/gemini-cli-core'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; + +vi.mock('../../config/settings.js'); +vi.mock('../../config/extension.js'); +vi.mock('@google/gemini-cli-core'); +vi.mock('@modelcontextprotocol/sdk/client/index.js'); + +const mockedLoadSettings = loadSettings as vi.Mock; +const mockedLoadExtensions = loadExtensions as vi.Mock; +const mockedCreateTransport = createTransport as vi.Mock; +const MockedClient = Client as vi.Mock; + +interface MockClient { + connect: vi.Mock; + ping: vi.Mock; + close: vi.Mock; +} + +interface MockTransport { + close: vi.Mock; +} + +describe('mcp list command', () => { + let consoleSpy: vi.SpyInstance; + let mockClient: MockClient; + let mockTransport: MockTransport; + + beforeEach(() => { + vi.resetAllMocks(); + + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + mockTransport = { close: vi.fn() }; + mockClient = { + connect: vi.fn(), + ping: vi.fn(), + close: vi.fn(), + }; + + MockedClient.mockImplementation(() => mockClient); + mockedCreateTransport.mockResolvedValue(mockTransport); + mockedLoadExtensions.mockReturnValue([]); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + }); + + it('should display message when no servers configured', async () => { + mockedLoadSettings.mockReturnValue({ merged: { mcpServers: {} } }); + + await listMcpServers(); + + expect(consoleSpy).toHaveBeenCalledWith('No MCP servers configured.'); + }); + + it('should display different server types with connected status', async () => { + mockedLoadSettings.mockReturnValue({ + merged: { + mcpServers: { + 'stdio-server': { command: '/path/to/server', args: ['arg1'] }, + 'sse-server': { url: 'https://example.com/sse' }, + 'http-server': { httpUrl: 'https://example.com/http' }, + }, + }, + }); + + mockClient.connect.mockResolvedValue(undefined); + mockClient.ping.mockResolvedValue(undefined); + + await listMcpServers(); + + expect(consoleSpy).toHaveBeenCalledWith('Configured MCP servers:\n'); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'stdio-server: /path/to/server arg1 (stdio) - Connected', + ), + ); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'sse-server: https://example.com/sse (sse) - Connected', + ), + ); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'http-server: https://example.com/http (http) - Connected', + ), + ); + }); + + it('should display disconnected status when connection fails', async () => { + mockedLoadSettings.mockReturnValue({ + merged: { + mcpServers: { + 'test-server': { command: '/test/server' }, + }, + }, + }); + + mockClient.connect.mockRejectedValue(new Error('Connection failed')); + + await listMcpServers(); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'test-server: /test/server (stdio) - Disconnected', + ), + ); + }); + + it('should merge extension servers with config servers', async () => { + mockedLoadSettings.mockReturnValue({ + merged: { + mcpServers: { 'config-server': { command: '/config/server' } }, + }, + }); + + mockedLoadExtensions.mockReturnValue([ + { + config: { + name: 'test-extension', + mcpServers: { 'extension-server': { command: '/ext/server' } }, + }, + }, + ]); + + mockClient.connect.mockResolvedValue(undefined); + mockClient.ping.mockResolvedValue(undefined); + + await listMcpServers(); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'config-server: /config/server (stdio) - Connected', + ), + ); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'extension-server: /ext/server (stdio) - Connected', + ), + ); + }); +}); diff --git a/packages/cli/src/commands/mcp/list.ts b/packages/cli/src/commands/mcp/list.ts new file mode 100644 index 00000000..48ea912e --- /dev/null +++ b/packages/cli/src/commands/mcp/list.ts @@ -0,0 +1,139 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// File for 'gemini mcp list' command +import type { CommandModule } from 'yargs'; +import { loadSettings } from '../../config/settings.js'; +import { + MCPServerConfig, + MCPServerStatus, + createTransport, +} from '@google/gemini-cli-core'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { loadExtensions } from '../../config/extension.js'; + +const COLOR_GREEN = '\u001b[32m'; +const COLOR_YELLOW = '\u001b[33m'; +const COLOR_RED = '\u001b[31m'; +const RESET_COLOR = '\u001b[0m'; + +async function getMcpServersFromConfig(): Promise< + Record<string, MCPServerConfig> +> { + const settings = loadSettings(process.cwd()); + const extensions = loadExtensions(process.cwd()); + const mcpServers = { ...(settings.merged.mcpServers || {}) }; + for (const extension of extensions) { + Object.entries(extension.config.mcpServers || {}).forEach( + ([key, server]) => { + if (mcpServers[key]) { + return; + } + mcpServers[key] = { + ...server, + extensionName: extension.config.name, + }; + }, + ); + } + return mcpServers; +} + +async function testMCPConnection( + serverName: string, + config: MCPServerConfig, +): Promise<MCPServerStatus> { + const client = new Client({ + name: 'mcp-test-client', + version: '0.0.1', + }); + + let transport; + try { + // Use the same transport creation logic as core + transport = await createTransport(serverName, config, false); + } catch (_error) { + await client.close(); + return MCPServerStatus.DISCONNECTED; + } + + try { + // Attempt actual MCP connection with short timeout + await client.connect(transport, { timeout: 5000 }); // 5s timeout + + // Test basic MCP protocol by pinging the server + await client.ping(); + + await client.close(); + return MCPServerStatus.CONNECTED; + } catch (_error) { + await transport.close(); + return MCPServerStatus.DISCONNECTED; + } +} + +async function getServerStatus( + serverName: string, + server: MCPServerConfig, +): Promise<MCPServerStatus> { + // Test all server types by attempting actual connection + return await testMCPConnection(serverName, server); +} + +export async function listMcpServers(): Promise<void> { + const mcpServers = await getMcpServersFromConfig(); + const serverNames = Object.keys(mcpServers); + + if (serverNames.length === 0) { + console.log('No MCP servers configured.'); + return; + } + + console.log('Configured MCP servers:\n'); + + for (const serverName of serverNames) { + const server = mcpServers[serverName]; + + const status = await getServerStatus(serverName, server); + + let statusIndicator = ''; + let statusText = ''; + switch (status) { + case MCPServerStatus.CONNECTED: + statusIndicator = COLOR_GREEN + '✓' + RESET_COLOR; + statusText = 'Connected'; + break; + case MCPServerStatus.CONNECTING: + statusIndicator = COLOR_YELLOW + '…' + RESET_COLOR; + statusText = 'Connecting'; + break; + case MCPServerStatus.DISCONNECTED: + default: + statusIndicator = COLOR_RED + '✗' + RESET_COLOR; + statusText = 'Disconnected'; + break; + } + + let serverInfo = `${serverName}: `; + if (server.httpUrl) { + serverInfo += `${server.httpUrl} (http)`; + } else if (server.url) { + serverInfo += `${server.url} (sse)`; + } else if (server.command) { + serverInfo += `${server.command} ${server.args?.join(' ') || ''} (stdio)`; + } + + console.log(`${statusIndicator} ${serverInfo} - ${statusText}`); + } +} + +export const listCommand: CommandModule = { + command: 'list', + describe: 'List all configured MCP servers', + handler: async () => { + await listMcpServers(); + }, +}; diff --git a/packages/cli/src/commands/mcp/remove.test.ts b/packages/cli/src/commands/mcp/remove.test.ts new file mode 100644 index 00000000..eb7dedce --- /dev/null +++ b/packages/cli/src/commands/mcp/remove.test.ts @@ -0,0 +1,69 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import yargs from 'yargs'; +import { loadSettings, SettingScope } from '../../config/settings.js'; +import { removeCommand } from './remove.js'; + +vi.mock('fs/promises', () => ({ + readFile: vi.fn(), + writeFile: vi.fn(), +})); + +vi.mock('../../config/settings.js', async () => { + const actual = await vi.importActual('../../config/settings.js'); + return { + ...actual, + loadSettings: vi.fn(), + }; +}); + +const mockedLoadSettings = loadSettings as vi.Mock; + +describe('mcp remove command', () => { + let parser: yargs.Argv; + let mockSetValue: vi.Mock; + let mockSettings: Record<string, unknown>; + + beforeEach(() => { + vi.resetAllMocks(); + const yargsInstance = yargs([]).command(removeCommand); + parser = yargsInstance; + mockSetValue = vi.fn(); + mockSettings = { + mcpServers: { + 'test-server': { + command: 'echo "hello"', + }, + }, + }; + mockedLoadSettings.mockReturnValue({ + forScope: () => ({ settings: mockSettings }), + setValue: mockSetValue, + }); + }); + + it('should remove a server from project settings', async () => { + await parser.parseAsync('remove test-server'); + + expect(mockSetValue).toHaveBeenCalledWith( + SettingScope.Workspace, + 'mcpServers', + {}, + ); + }); + + it('should show a message if server not found', async () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + await parser.parseAsync('remove non-existent-server'); + + expect(mockSetValue).not.toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalledWith( + 'Server "non-existent-server" not found in project settings.', + ); + }); +}); diff --git a/packages/cli/src/commands/mcp/remove.ts b/packages/cli/src/commands/mcp/remove.ts new file mode 100644 index 00000000..80d66234 --- /dev/null +++ b/packages/cli/src/commands/mcp/remove.ts @@ -0,0 +1,60 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// File for 'gemini mcp remove' command +import type { CommandModule } from 'yargs'; +import { loadSettings, SettingScope } from '../../config/settings.js'; + +async function removeMcpServer( + name: string, + options: { + scope: string; + }, +) { + const { scope } = options; + const settingsScope = + scope === 'user' ? SettingScope.User : SettingScope.Workspace; + const settings = loadSettings(process.cwd()); + + const existingSettings = settings.forScope(settingsScope).settings; + const mcpServers = existingSettings.mcpServers || {}; + + if (!mcpServers[name]) { + console.log(`Server "${name}" not found in ${scope} settings.`); + return; + } + + delete mcpServers[name]; + + settings.setValue(settingsScope, 'mcpServers', mcpServers); + + console.log(`Server "${name}" removed from ${scope} settings.`); +} + +export const removeCommand: CommandModule = { + command: 'remove <name>', + describe: 'Remove a server', + builder: (yargs) => + yargs + .usage('Usage: gemini mcp remove [options] <name>') + .positional('name', { + describe: 'Name of the server', + type: 'string', + demandOption: true, + }) + .option('scope', { + alias: 's', + describe: 'Configuration scope (user or project)', + type: 'string', + default: 'project', + choices: ['user', 'project'], + }), + handler: async (argv) => { + await removeMcpServer(argv.name as string, { + scope: argv.scope as string, + }); + }, +}; diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index beba9602..7175c033 100644 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -10,6 +10,7 @@ import { homedir } from 'node:os'; import yargs from 'yargs/yargs'; import { hideBin } from 'yargs/helpers'; import process from 'node:process'; +import { mcpCommand } from '../commands/mcp.js'; import { Config, loadServerHierarchicalMemory, @@ -72,173 +73,185 @@ export async function parseArguments(): Promise<CliArgs> { const yargsInstance = yargs(hideBin(process.argv)) .scriptName('gemini') .usage( - '$0 [options]', - 'Gemini CLI - Launch an interactive CLI, use -p/--prompt for non-interactive mode', + 'Usage: gemini [options] [command]\n\nGemini CLI - Launch an interactive CLI, use -p/--prompt for non-interactive mode', ) - .option('model', { - alias: 'm', - type: 'string', - description: `Model`, - default: process.env.GEMINI_MODEL, - }) - .option('prompt', { - alias: 'p', - type: 'string', - description: 'Prompt. Appended to input on stdin (if any).', - }) - .option('prompt-interactive', { - alias: 'i', - type: 'string', - description: - 'Execute the provided prompt and continue in interactive mode', - }) - .option('sandbox', { - alias: 's', - type: 'boolean', - description: 'Run in sandbox?', - }) - .option('sandbox-image', { - type: 'string', - description: 'Sandbox image URI.', - }) - .option('debug', { - alias: 'd', - type: 'boolean', - description: 'Run in debug mode?', - default: false, - }) - .option('all-files', { - alias: ['a'], - type: 'boolean', - description: 'Include ALL files in context?', - default: false, - }) - .option('all_files', { - type: 'boolean', - description: 'Include ALL files in context?', - default: false, - }) - .deprecateOption( - 'all_files', - 'Use --all-files instead. We will be removing --all_files in the coming weeks.', - ) - .option('show-memory-usage', { - type: 'boolean', - description: 'Show memory usage in status bar', - default: false, - }) - .option('show_memory_usage', { - type: 'boolean', - description: 'Show memory usage in status bar', - default: false, - }) - .deprecateOption( - 'show_memory_usage', - 'Use --show-memory-usage instead. We will be removing --show_memory_usage in the coming weeks.', + .command('$0', 'Launch Gemini CLI', (yargsInstance) => + yargsInstance + .option('model', { + alias: 'm', + type: 'string', + description: `Model`, + default: process.env.GEMINI_MODEL, + }) + .option('prompt', { + alias: 'p', + type: 'string', + description: 'Prompt. Appended to input on stdin (if any).', + }) + .option('prompt-interactive', { + alias: 'i', + type: 'string', + description: + 'Execute the provided prompt and continue in interactive mode', + }) + .option('sandbox', { + alias: 's', + type: 'boolean', + description: 'Run in sandbox?', + }) + .option('sandbox-image', { + type: 'string', + description: 'Sandbox image URI.', + }) + .option('debug', { + alias: 'd', + type: 'boolean', + description: 'Run in debug mode?', + default: false, + }) + .option('all-files', { + alias: ['a'], + type: 'boolean', + description: 'Include ALL files in context?', + default: false, + }) + .option('all_files', { + type: 'boolean', + description: 'Include ALL files in context?', + default: false, + }) + .deprecateOption( + 'all_files', + 'Use --all-files instead. We will be removing --all_files in the coming weeks.', + ) + .option('show-memory-usage', { + type: 'boolean', + description: 'Show memory usage in status bar', + default: false, + }) + .option('show_memory_usage', { + type: 'boolean', + description: 'Show memory usage in status bar', + default: false, + }) + .deprecateOption( + 'show_memory_usage', + 'Use --show-memory-usage instead. We will be removing --show_memory_usage in the coming weeks.', + ) + .option('yolo', { + alias: 'y', + type: 'boolean', + description: + 'Automatically accept all actions (aka YOLO mode, see https://www.youtube.com/watch?v=xvFZjo5PgG0 for more details)?', + default: false, + }) + .option('telemetry', { + type: 'boolean', + description: + 'Enable telemetry? This flag specifically controls if telemetry is sent. Other --telemetry-* flags set specific values but do not enable telemetry on their own.', + }) + .option('telemetry-target', { + type: 'string', + choices: ['local', 'gcp'], + description: + 'Set the telemetry target (local or gcp). Overrides settings files.', + }) + .option('telemetry-otlp-endpoint', { + type: 'string', + description: + 'Set the OTLP endpoint for telemetry. Overrides environment variables and settings files.', + }) + .option('telemetry-log-prompts', { + type: 'boolean', + description: + 'Enable or disable logging of user prompts for telemetry. Overrides settings files.', + }) + .option('telemetry-outfile', { + type: 'string', + description: 'Redirect all telemetry output to the specified file.', + }) + .option('checkpointing', { + alias: 'c', + type: 'boolean', + description: 'Enables checkpointing of file edits', + default: false, + }) + .option('experimental-acp', { + type: 'boolean', + description: 'Starts the agent in ACP mode', + }) + .option('allowed-mcp-server-names', { + type: 'array', + string: true, + description: 'Allowed MCP server names', + }) + .option('extensions', { + alias: 'e', + type: 'array', + string: true, + description: + 'A list of extensions to use. If not provided, all extensions are used.', + }) + .option('list-extensions', { + alias: 'l', + type: 'boolean', + description: 'List all available extensions and exit.', + }) + .option('ide-mode-feature', { + type: 'boolean', + description: 'Run in IDE mode?', + }) + .option('proxy', { + type: 'string', + description: + 'Proxy for gemini client, like schema://user:password@host:port', + }) + .option('include-directories', { + type: 'array', + string: true, + description: + 'Additional directories to include in the workspace (comma-separated or multiple --include-directories)', + coerce: (dirs: string[]) => + // Handle comma-separated values + dirs.flatMap((dir) => dir.split(',').map((d) => d.trim())), + }) + .option('load-memory-from-include-directories', { + type: 'boolean', + description: + 'If true, when refreshing memory, GEMINI.md files should be loaded from all directories that are added. If false, GEMINI.md files should only be loaded from the primary working directory.', + default: false, + }) + .check((argv) => { + if (argv.prompt && argv.promptInteractive) { + throw new Error( + 'Cannot use both --prompt (-p) and --prompt-interactive (-i) together', + ); + } + return true; + }), ) - .option('yolo', { - alias: 'y', - type: 'boolean', - description: - 'Automatically accept all actions (aka YOLO mode, see https://www.youtube.com/watch?v=xvFZjo5PgG0 for more details)?', - default: false, - }) - .option('telemetry', { - type: 'boolean', - description: - 'Enable telemetry? This flag specifically controls if telemetry is sent. Other --telemetry-* flags set specific values but do not enable telemetry on their own.', - }) - .option('telemetry-target', { - type: 'string', - choices: ['local', 'gcp'], - description: - 'Set the telemetry target (local or gcp). Overrides settings files.', - }) - .option('telemetry-otlp-endpoint', { - type: 'string', - description: - 'Set the OTLP endpoint for telemetry. Overrides environment variables and settings files.', - }) - .option('telemetry-log-prompts', { - type: 'boolean', - description: - 'Enable or disable logging of user prompts for telemetry. Overrides settings files.', - }) - .option('telemetry-outfile', { - type: 'string', - description: 'Redirect all telemetry output to the specified file.', - }) - .option('checkpointing', { - alias: 'c', - type: 'boolean', - description: 'Enables checkpointing of file edits', - default: false, - }) - .option('experimental-acp', { - type: 'boolean', - description: 'Starts the agent in ACP mode', - }) - .option('allowed-mcp-server-names', { - type: 'array', - string: true, - description: 'Allowed MCP server names', - }) - .option('extensions', { - alias: 'e', - type: 'array', - string: true, - description: - 'A list of extensions to use. If not provided, all extensions are used.', - }) - .option('list-extensions', { - alias: 'l', - type: 'boolean', - description: 'List all available extensions and exit.', - }) - .option('ide-mode-feature', { - type: 'boolean', - description: 'Run in IDE mode?', - }) - .option('proxy', { - type: 'string', - description: - 'Proxy for gemini client, like schema://user:password@host:port', - }) - .option('include-directories', { - type: 'array', - string: true, - description: - 'Additional directories to include in the workspace (comma-separated or multiple --include-directories)', - coerce: (dirs: string[]) => - // Handle comma-separated values - dirs.flatMap((dir) => dir.split(',').map((d) => d.trim())), - }) - .option('load-memory-from-include-directories', { - type: 'boolean', - description: - 'If true, when refreshing memory, GEMINI.md files should be loaded from all directories that are added. If false, GEMINI.md files should only be loaded from the primary working directory.', - default: false, - }) + // Register MCP subcommands + .command(mcpCommand) .version(await getCliVersion()) // This will enable the --version flag based on package.json .alias('v', 'version') .help() .alias('h', 'help') .strict() - .check((argv) => { - if (argv.prompt && argv.promptInteractive) { - throw new Error( - 'Cannot use both --prompt (-p) and --prompt-interactive (-i) together', - ); - } - return true; - }); + .demandCommand(0, 0); // Allow base command to run with no subcommands yargsInstance.wrap(yargsInstance.terminalWidth()); - const result = yargsInstance.parseSync(); + const result = await yargsInstance.parse(); + + // Handle case where MCP subcommands are executed - they should exit the process + // and not return to main CLI logic + if (result._.length > 0 && result._[0] === 'mcp') { + // MCP commands handle their own execution and process exit + process.exit(0); + } // The import format is now only controlled by settings.memoryImportFormat // We no longer accept it as a CLI argument - return result as CliArgs; + return result as unknown as CliArgs; } // This function is now a thin wrapper around the server's implementation. |
