Skip to main content
Glama

AI Code Toolkit

by AgiFlow
GeminiCliService.ts16.8 kB
/** * GeminiCliService * * DESIGN PATTERNS: * - Class-based service pattern for encapsulating business logic * - Interface implementation for dependency injection and testing * - Single Responsibility: Manages Gemini CLI interactions * - Method-based API: Public methods expose service capabilities * * CODING STANDARDS: * - Service class names use PascalCase with 'Service' suffix * - Method names use camelCase with descriptive verbs * - Return types should be explicit (never use implicit any) * - Use async/await for asynchronous operations * - Handle errors with try-catch and throw descriptive Error objects * - Document public methods with JSDoc comments * * AVOID: * - Side effects in constructors (keep them lightweight) * - Mixing concerns (keep services focused on single domain) * - Direct coupling to other services (use dependency injection) * - Exposing internal implementation details */ import { execa } from 'execa'; import * as fs from 'fs-extra'; import * as os from 'node:os'; import * as path from 'node:path'; import type { CodingAgentService, LlmInvocationParams, LlmInvocationResponse, McpSettings, PromptConfig, } from '../types'; /** * Gemini CLI JSON response format (headless mode) * @see https://geminicli.com/docs/cli/headless/#json-output */ interface GeminiJsonResponse { response?: string; stats?: { models?: Record< string, { api?: { totalRequests?: number; totalErrors?: number; totalLatencyMs?: number; }; tokens?: { prompt?: number; candidates?: number; total?: number; cached?: number; thoughts?: number; tool?: number; }; } >; tools?: { totalCalls?: number; totalSuccess?: number; totalFail?: number; totalDurationMs?: number; totalDecisions?: { accept?: number; reject?: number; modify?: number; auto_accept?: number; }; byName?: Record< string, { count?: number; success?: number; fail?: number; durationMs?: number; decisions?: { accept?: number; reject?: number; modify?: number; auto_accept?: number; }; } >; }; files?: { totalLinesAdded?: number; totalLinesRemoved?: number; }; }; error?: { type?: string; message?: string; code?: number; }; } /** * Gemini CLI extension configuration format */ interface GeminiExtensionConfig { name: string; version: string; mcpServers: Record<string, GeminiMcpServerConfig>; contextFileName?: string; excludeTools?: string[]; } /** * Gemini CLI MCP server configuration */ interface GeminiMcpServerConfig { disabled?: boolean; command?: string; args?: string[]; env?: Record<string, string>; cwd?: string; httpUrl?: string; sseUrl?: string; } /** * Gemini CLI settings.json format */ interface GeminiSettingsConfig { mcpServers?: Record<string, GeminiMcpServerConfig>; [key: string]: unknown; } /** * Service for interacting with Gemini CLI as a coding agent * Provides standard LLM interface using Gemini's headless mode with JSON output */ export class GeminiCliService implements CodingAgentService { private mcpSettings: McpSettings = {}; private promptConfig: PromptConfig = {}; private readonly workspaceRoot: string; private readonly geminiPath: string; constructor(options?: { workspaceRoot?: string; geminiPath?: string }) { this.workspaceRoot = options?.workspaceRoot || process.cwd(); this.geminiPath = options?.geminiPath || 'gemini'; } /** * Check if the Gemini CLI service is enabled * Detects Gemini by checking for .gemini file in workspace root (project-level only) */ async isEnabled(): Promise<boolean> { try { const geminiWorkspaceFile = path.join(this.workspaceRoot, '.gemini'); return await fs.pathExists(geminiWorkspaceFile); } catch (_error) { // Return false if unable to check file existence return false; } } /** * Update MCP (Model Context Protocol) settings for Gemini CLI * Supports two modes: * 1. Settings mode: Writes to ~/.gemini/settings.json (default) * 2. Extension mode: Creates extension in {workspaceRoot}/.gemini/extensions/{extensionName}/ * * Extension mode is recommended for: * - Packaging MCP servers with metadata (project-level configuration) * - Including context files and custom commands * - Better distribution and sharing * - Variable substitution support (${extensionPath}, ${workspacePath}, ${/}) * * Settings mode (user-level) takes precedence over extensions (project-level), allowing user overrides */ async updateMcpSettings( settings: McpSettings, options?: { useExtension?: boolean; extensionName?: string; extensionVersion?: string; contextContent?: string; excludeTools?: string[]; }, ): Promise<void> { this.mcpSettings = { ...this.mcpSettings, ...settings }; if (options?.useExtension) { // Extension mode: Create/update Gemini CLI extension await this.createOrUpdateExtension(settings, { extensionName: options.extensionName || 'aicode-toolkit-mcp', extensionVersion: options.extensionVersion || '1.0.0', contextContent: options.contextContent, excludeTools: options.excludeTools, }); } else { // Settings mode: Write to ~/.gemini/settings.json await this.updateSettingsJson(settings); } } /** * Create or update a Gemini CLI extension with MCP server configuration * Extensions are created in workspace root: {workspaceRoot}/.gemini/extensions/{extensionName}/ * * @private */ private async createOrUpdateExtension( settings: McpSettings, options: { extensionName: string; extensionVersion: string; contextContent?: string; excludeTools?: string[]; }, ): Promise<void> { try { const extensionsDir = path.join(this.workspaceRoot, '.gemini', 'extensions'); const extensionDir = path.join(extensionsDir, options.extensionName); const extensionConfigPath = path.join(extensionDir, 'gemini-extension.json'); // Ensure extension directory exists await fs.ensureDir(extensionDir); // Build extension configuration const extensionConfig: GeminiExtensionConfig = { name: options.extensionName, version: options.extensionVersion, mcpServers: {}, }; // Convert standardized MCP server configs to Gemini extension format if (settings.servers) { for (const [serverName, serverConfig] of Object.entries(settings.servers)) { const geminiConfig: GeminiMcpServerConfig = { disabled: serverConfig.disabled ?? false, }; // Add type-specific fields if (serverConfig.type === 'stdio') { geminiConfig.command = serverConfig.command; if (serverConfig.args && serverConfig.args.length > 0) { geminiConfig.args = serverConfig.args; } if (serverConfig.env) { geminiConfig.env = serverConfig.env; } // Support for cwd if needed (extended property not in base type) if ('cwd' in serverConfig) { const cwd = (serverConfig as { cwd?: string }).cwd; if (cwd) { geminiConfig.cwd = cwd; } } } else if (serverConfig.type === 'http') { geminiConfig.httpUrl = serverConfig.url; } else if (serverConfig.type === 'sse') { geminiConfig.sseUrl = serverConfig.url; } extensionConfig.mcpServers[serverName] = geminiConfig; } } // Add optional context file if (options.contextContent) { extensionConfig.contextFileName = 'GEMINI.md'; const contextPath = path.join(extensionDir, 'GEMINI.md'); await fs.writeFile(contextPath, options.contextContent); } // Add optional tool exclusions if (options.excludeTools && options.excludeTools.length > 0) { extensionConfig.excludeTools = options.excludeTools; } // Write extension config with pretty formatting await fs.writeFile(extensionConfigPath, `${JSON.stringify(extensionConfig, null, 2)}\n`); } catch (error) { throw new Error( `Failed to create or update Gemini CLI extension: ${error instanceof Error ? error.message : String(error)}`, ); } } /** * Update MCP settings in ~/.gemini/settings.json * Settings take precedence over extensions * * @private */ private async updateSettingsJson(settings: McpSettings): Promise<void> { try { const configDir = path.join(os.homedir(), '.gemini'); const configPath = path.join(configDir, 'settings.json'); // Ensure config directory exists await fs.ensureDir(configDir); // Read existing config or create new let config: GeminiSettingsConfig = {}; if (await fs.pathExists(configPath)) { const content = await fs.readFile(configPath, 'utf-8'); config = JSON.parse(content); } // Ensure mcpServers key exists if (!config.mcpServers) { config.mcpServers = {}; } // Convert standardized MCP server configs to Gemini CLI format if (settings.servers) { for (const [serverName, serverConfig] of Object.entries(settings.servers)) { const geminiConfig: GeminiMcpServerConfig = { disabled: serverConfig.disabled ?? false, }; // Add type-specific fields if (serverConfig.type === 'stdio') { geminiConfig.command = serverConfig.command; if (serverConfig.args && serverConfig.args.length > 0) { geminiConfig.args = serverConfig.args; } if (serverConfig.env) { geminiConfig.env = serverConfig.env; } } else if (serverConfig.type === 'http') { geminiConfig.httpUrl = serverConfig.url; } else if (serverConfig.type === 'sse') { geminiConfig.sseUrl = serverConfig.url; } config.mcpServers[serverName] = geminiConfig; } } // Write config back with pretty formatting await fs.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`); } catch (error) { throw new Error( `Failed to update Gemini CLI settings.json: ${error instanceof Error ? error.message : String(error)}`, ); } } /** * Update prompt configuration for Gemini CLI * Supports two modes: * 1. Extension mode: Creates/updates extension with contextFileName (recommended) * 2. In-memory mode: Stores system prompt for runtime use * * Extension mode writes context to {workspaceRoot}/.gemini/extensions/{extensionName}/GEMINI.md */ async updatePrompt( config: PromptConfig, options?: { useExtension?: boolean; extensionName?: string; extensionVersion?: string; }, ): Promise<void> { this.promptConfig = { ...this.promptConfig, ...config }; if (options?.useExtension && config.systemPrompt) { try { // Extension mode: Create/update extension with context file const extensionsDir = path.join(this.workspaceRoot, '.gemini', 'extensions'); const extensionDir = path.join( extensionsDir, options.extensionName || 'aicode-toolkit-mcp', ); const extensionConfigPath = path.join(extensionDir, 'gemini-extension.json'); const contextPath = path.join(extensionDir, 'GEMINI.md'); // Ensure extension directory exists await fs.ensureDir(extensionDir); // Read or create extension config let extensionConfig: GeminiExtensionConfig = { name: options.extensionName || 'aicode-toolkit-mcp', version: options.extensionVersion || '1.0.0', mcpServers: {}, }; if (await fs.pathExists(extensionConfigPath)) { const content = await fs.readFile(extensionConfigPath, 'utf-8'); extensionConfig = JSON.parse(content); } // Set context file reference extensionConfig.contextFileName = 'GEMINI.md'; // Write context content await fs.writeFile(contextPath, config.systemPrompt); // Write extension config await fs.writeFile(extensionConfigPath, `${JSON.stringify(extensionConfig, null, 2)}\n`); } catch (error) { throw new Error( `Failed to update Gemini CLI prompt configuration: ${error instanceof Error ? error.message : String(error)}`, ); } } // Otherwise, systemPrompt is stored in memory and used in invokeAsLlm } /** * Invoke Gemini CLI as an LLM * Executes Gemini CLI with headless mode and JSON output format */ async invokeAsLlm(params: LlmInvocationParams): Promise<LlmInvocationResponse> { // Build the prompt with optional system prompt let fullPrompt = params.prompt; const systemPrompt = this.promptConfig.systemPrompt; if (systemPrompt) { fullPrompt = `${systemPrompt}\n\n${params.prompt}`; } // Build command arguments for non-interactive LLM invocation const args = [ '--prompt', fullPrompt, '--output-format', 'json', '--yolo', // Auto-approve tool calls to avoid interactive prompts ]; if (params.model) { args.push('--model', params.model); } // Execute Gemini CLI try { const timeout = (params.timeout as number | undefined) || 120000; // 2 minutes default const { stdout, exitCode } = await execa(this.geminiPath, args, { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe', timeout, maxBuffer: 1024 * 1024 * 100, // 100MB buffer env: process.env, cwd: this.workspaceRoot, }); if (exitCode !== 0) { throw new Error(`Gemini CLI process exited with code ${exitCode}`); } // Parse JSON response const jsonResponse: GeminiJsonResponse = JSON.parse(stdout); // Check for errors in response if (jsonResponse.error) { throw new Error( `Gemini CLI error: ${jsonResponse.error.message || 'Unknown error'} (${jsonResponse.error.type || 'unknown'})`, ); } // Extract response content const responseContent = jsonResponse.response || ''; // Determine which model was used from stats (first model in the list) const usedModel = jsonResponse.stats?.models ? Object.keys(jsonResponse.stats.models)[0] : params.model || 'gemini-2.0-flash-exp'; // Extract token usage from stats.models // Sum up tokens from all models used in the request let totalPromptTokens = 0; let totalCandidateTokens = 0; if (jsonResponse.stats?.models) { for (const modelStats of Object.values(jsonResponse.stats.models)) { totalPromptTokens += modelStats.tokens?.prompt || 0; totalCandidateTokens += modelStats.tokens?.candidates || 0; } } // Return standard LLM response return { content: responseContent.trim(), model: usedModel, usage: { inputTokens: totalPromptTokens, outputTokens: totalCandidateTokens, }, }; } catch (error) { // Provide descriptive error messages based on error type if (error instanceof Error) { if (error.message.includes('ETIMEDOUT') || error.message.includes('timed out')) { throw new Error( `Gemini CLI invocation timed out${params.timeout ? ` after ${params.timeout}ms` : ''}. Consider increasing the timeout parameter.`, ); } if (error.message.includes('ENOENT')) { throw new Error( `Gemini CLI not found at path: ${this.geminiPath}. Ensure Gemini CLI is installed and the path is correct.`, ); } if (error.message.includes('exited with code')) { throw new Error( `Gemini CLI process failed: ${error.message}. Check Gemini CLI logs for details.`, ); } if (error.message.includes('Unexpected token')) { throw new Error( `Failed to parse Gemini CLI JSON response: ${error.message}. The output may not be in JSON format.`, ); } throw new Error(`Failed to invoke Gemini CLI: ${error.message}`); } throw new Error(`Failed to invoke Gemini CLI: ${String(error)}`); } } }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/AgiFlow/aicode-toolkit'

If you have feedback or need assistance with the MCP directory API, please join our Discord server