diff --git a/docs/ide-integration.md b/docs/ide-integration.md index a0bd4976..77cca07b 100644 --- a/docs/ide-integration.md +++ b/docs/ide-integration.md @@ -44,7 +44,10 @@ You can also install the extension directly from a marketplace. - **For Visual Studio Code:** Install from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=google.gemini-cli-vscode-ide-companion). - **For VS Code Forks:** To support forks of VS Code, the extension is also published on the [Open VSX Registry](https://open-vsx.org/extension/google/gemini-cli-vscode-ide-companion). Follow your editor's instructions for installing extensions from this registry. -After any installation method, it's recommended to open a new terminal window to ensure the integration is activated correctly. Once installed, you can use `/ide enable` to connect. +> NOTE: +> The "Gemini CLI Companion" extension may appear towards the bottom of search results. If you don't see it immediately, try scrolling down or sorting by "Newly Published". +> +> After manually installing the extension, you must run `/ide enable` in the CLI to activate the integration. ## Usage @@ -110,7 +113,7 @@ If you encounter issues with IDE integration, here are some common error message ### Connection Errors -- **Message:** `🔴 Disconnected: Failed to connect to IDE companion extension for [IDE Name]. Please ensure the extension is running and try restarting your terminal. To install the extension, run /ide install.` +- **Message:** `🔴 Disconnected: Failed to connect to IDE companion extension in [IDE Name]. Please ensure the extension is running. To install the extension, run /ide install.` - **Cause:** Gemini CLI could not find the necessary environment variables (`GEMINI_CLI_IDE_WORKSPACE_PATH` or `GEMINI_CLI_IDE_SERVER_PORT`) to connect to the IDE. This usually means the IDE companion extension is not running or did not initialize correctly. - **Solution:** 1. Make sure you have installed the **Gemini CLI Companion** extension in your IDE and that it is enabled. @@ -122,13 +125,13 @@ If you encounter issues with IDE integration, here are some common error message ### Configuration Errors -- **Message:** `🔴 Disconnected: Directory mismatch. Gemini CLI is running in a different location than the open workspace in [IDE Name]. Please run the CLI from the same directory as your project's root folder.` - - **Cause:** The CLI's current working directory is outside the folder or workspace you have open in your IDE. +- **Message:** `🔴 Disconnected: Directory mismatch. Gemini CLI is running in a different location than the open workspace in [IDE Name]. Please run the CLI from one of the following directories: [List of directories]` + - **Cause:** The CLI's current working directory is outside the workspace you have open in your IDE. - **Solution:** `cd` into the same directory that is open in your IDE and restart the CLI. -- **Message:** `🔴 Disconnected: To use this feature, please open a single workspace folder in [IDE Name] and try again.` - - **Cause:** You have multiple workspace folders open in your IDE, or no folder is open at all. The IDE integration requires a single root workspace folder to operate correctly. - - **Solution:** Open a single project folder in your IDE and restart the CLI. +- **Message:** `🔴 Disconnected: To use this feature, please open a workspace folder in [IDE Name] and try again.` + - **Cause:** You have no workspace open in your IDE. + - **Solution:** Open a workspace in your IDE and restart the CLI. ### General Errors @@ -136,6 +139,6 @@ If you encounter issues with IDE integration, here are some common error message - **Cause:** You are running Gemini CLI in a terminal or environment that is not a supported IDE. - **Solution:** Run Gemini CLI from the integrated terminal of a supported IDE, like VS Code. -- **Message:** `No installer is available for [IDE Name]. Please install the IDE companion manually from its marketplace.` +- **Message:** `No installer is available for IDE. Please install the Gemini CLI Companion extension manually from the marketplace.` - **Cause:** You ran `/ide install`, but the CLI does not have an automated installer for your specific IDE. - - **Solution:** Open your IDE's extension marketplace, search for "Gemini CLI Companion", and install it manually. + - **Solution:** Open your IDE's extension marketplace, search for "Gemini CLI Companion", and [install it manually](#3-manual-installation-from-a-marketplace). diff --git a/integration-tests/ide-client.test.ts b/integration-tests/ide-client.test.ts index 630589e4..4823990b 100644 --- a/integration-tests/ide-client.test.ts +++ b/integration-tests/ide-client.test.ts @@ -24,7 +24,7 @@ describe.skip('IdeClient', () => { process.env['GEMINI_CLI_IDE_WORKSPACE_PATH'] = process.cwd(); process.env['TERM_PROGRAM'] = 'vscode'; - const ideClient = IdeClient.getInstance(); + const ideClient = await IdeClient.getInstance(); await ideClient.connect(); expect(ideClient.getConnectionStatus()).toEqual({ @@ -67,7 +67,8 @@ describe('IdeClient fallback connection logic', () => { process.env['TERM_PROGRAM'] = 'vscode'; process.env['GEMINI_CLI_IDE_WORKSPACE_PATH'] = process.cwd(); // Reset instance - IdeClient.instance = undefined; + (IdeClient as unknown as { instance: IdeClient | undefined }).instance = + undefined; }); afterEach(async () => { @@ -85,7 +86,7 @@ describe('IdeClient fallback connection logic', () => { fs.unlinkSync(portFile); } - const ideClient = IdeClient.getInstance(); + const ideClient = await IdeClient.getInstance(); await ideClient.connect(); expect(ideClient.getConnectionStatus()).toEqual({ @@ -99,7 +100,7 @@ describe('IdeClient fallback connection logic', () => { // Write port file with a port that is not listening fs.writeFileSync(portFile, JSON.stringify({ port: filePort })); - const ideClient = IdeClient.getInstance(); + const ideClient = await IdeClient.getInstance(); await ideClient.connect(); expect(ideClient.getConnectionStatus()).toEqual({ @@ -110,7 +111,7 @@ describe('IdeClient fallback connection logic', () => { }); describe.skip('getIdeProcessId', () => { - let child: ChildProcess; + let child: child_process.ChildProcess; afterEach(() => { if (child) { @@ -173,11 +174,12 @@ describe('IdeClient with proxy', () => { vi.stubEnv('GEMINI_CLI_IDE_WORKSPACE_PATH', process.cwd()); // Reset instance - IdeClient.instance = undefined; + (IdeClient as unknown as { instance: IdeClient | undefined }).instance = + undefined; }); afterEach(async () => { - IdeClient.getInstance().disconnect(); + (await IdeClient.getInstance()).disconnect(); await mcpServer.stop(); proxyServer.close(); vi.unstubAllEnvs(); @@ -188,7 +190,7 @@ describe('IdeClient with proxy', () => { vi.stubEnv('HTTPS_PROXY', `http://localhost:${proxyServerPort}`); vi.stubEnv('NO_PROXY', 'example.com,127.0.0.1,::1'); - const ideClient = IdeClient.getInstance(); + const ideClient = await IdeClient.getInstance(); await ideClient.connect(); expect(ideClient.getConnectionStatus()).toEqual({ diff --git a/packages/cli/src/ui/IdeIntegrationNudge.tsx b/packages/cli/src/ui/IdeIntegrationNudge.tsx index 861728fa..eae39e8c 100644 --- a/packages/cli/src/ui/IdeIntegrationNudge.tsx +++ b/packages/cli/src/ui/IdeIntegrationNudge.tsx @@ -88,7 +88,7 @@ export function IdeIntegrationNudge({ {'> '} - {`Do you want to connect ${ideName ?? 'your'} editor to Gemini CLI?`} + {`Do you want to connect ${ideName ?? 'your editor'} to Gemini CLI?`} {installText} diff --git a/packages/cli/src/ui/commands/ideCommand.ts b/packages/cli/src/ui/commands/ideCommand.ts index 19a8090e..1310e194 100644 --- a/packages/cli/src/ui/commands/ideCommand.ts +++ b/packages/cli/src/ui/commands/ideCommand.ts @@ -6,10 +6,8 @@ import { Config, - DetectedIde, GEMINI_CLI_COMPANION_EXTENSION_NAME, IDEConnectionStatus, - getIdeInfo, getIdeInstaller, IdeClient, type File, @@ -130,11 +128,7 @@ export const ideCommand = (config: Config | null): SlashCommand | null => { ({ type: 'message', messageType: 'error', - content: `IDE integration is not supported in your current environment. To use this feature, run Gemini CLI in one of these supported IDEs: ${Object.values( - DetectedIde, - ) - .map((ide) => getIdeInfo(ide).displayName) - .join(', ')}`, + content: `IDE integration is not supported in your current environment. To use this feature, run Gemini CLI in one of these supported IDEs: VS Code or VS Code forks.`, }) as const, }; } diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts index 83a60ffd..7a7445d2 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts @@ -86,6 +86,7 @@ import { McpPromptLoader } from '../../services/McpPromptLoader.js'; import { SlashCommandStatus, makeFakeConfig, + type IdeClient, } from '@google/gemini-cli-core/index.js'; function createTestCommand( @@ -109,6 +110,10 @@ describe('useSlashCommandProcessor', () => { const mockSetQuittingMessages = vi.fn(); const mockConfig = makeFakeConfig({}); + vi.spyOn(mockConfig, 'getIdeClient').mockReturnValue({ + addStatusChangeListener: vi.fn(), + removeStatusChangeListener: vi.fn(), + } as unknown as IdeClient); const mockSettings = {} as LoadedSettings; diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 1bca179e..e3d323da 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -254,7 +254,7 @@ export class Config { private readonly folderTrustFeature: boolean; private readonly folderTrust: boolean; private ideMode: boolean; - private ideClient: IdeClient; + private ideClient!: IdeClient; private inFallbackMode = false; private readonly maxSessionTurns: number; private readonly listExtensions: boolean; @@ -342,7 +342,6 @@ export class Config { this.folderTrustFeature = params.folderTrustFeature ?? false; this.folderTrust = params.folderTrust ?? false; this.ideMode = params.ideMode ?? false; - this.ideClient = IdeClient.getInstance(); this.loadMemoryFromIncludeDirectories = params.loadMemoryFromIncludeDirectories ?? false; this.chatCompression = params.chatCompression; @@ -373,6 +372,7 @@ export class Config { throw Error('Config was already initialized'); } this.initialized = true; + this.ideClient = await IdeClient.getInstance(); // Initialize centralized FileDiscoveryService this.getFileService(); if (this.getCheckpointingEnabled()) { diff --git a/packages/core/src/ide/detect-ide.test.ts b/packages/core/src/ide/detect-ide.test.ts index 85249ad6..157a3bf3 100644 --- a/packages/core/src/ide/detect-ide.test.ts +++ b/packages/core/src/ide/detect-ide.test.ts @@ -4,65 +4,133 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, afterEach, vi } from 'vitest'; -import { detectIde, DetectedIde } from './detect-ide.js'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { detectIde, DetectedIde, getIdeInfo } from './detect-ide.js'; describe('detectIde', () => { + const ideProcessInfo = { pid: 123, command: 'some/path/to/code' }; + const ideProcessInfoNoCode = { pid: 123, command: 'some/path/to/fork' }; + afterEach(() => { vi.unstubAllEnvs(); }); - it.each([ - { - env: {}, - expected: DetectedIde.VSCode, - }, - { - env: { __COG_BASHRC_SOURCED: '1' }, - expected: DetectedIde.Devin, - }, - { - env: { REPLIT_USER: 'test' }, - expected: DetectedIde.Replit, - }, - { - env: { CURSOR_TRACE_ID: 'test' }, - expected: DetectedIde.Cursor, - }, - { - env: { CODESPACES: 'true' }, - expected: DetectedIde.Codespaces, - }, - { - env: { EDITOR_IN_CLOUD_SHELL: 'true' }, - expected: DetectedIde.CloudShell, - }, - { - env: { CLOUD_SHELL: 'true' }, - expected: DetectedIde.CloudShell, - }, - { - env: { TERM_PRODUCT: 'Trae' }, - expected: DetectedIde.Trae, - }, - { - env: { FIREBASE_DEPLOY_AGENT: 'true' }, - expected: DetectedIde.FirebaseStudio, - }, - { - env: { MONOSPACE_ENV: 'true' }, - expected: DetectedIde.FirebaseStudio, - }, - ])('detects the IDE for $expected', ({ env, expected }) => { - vi.stubEnv('TERM_PROGRAM', 'vscode'); - for (const [key, value] of Object.entries(env)) { - vi.stubEnv(key, value); - } - expect(detectIde()).toBe(expected); + it('should return undefined if TERM_PROGRAM is not vscode', () => { + vi.stubEnv('TERM_PROGRAM', ''); + expect(detectIde(ideProcessInfo)).toBeUndefined(); }); - it('returns undefined for non-vscode', () => { - vi.stubEnv('TERM_PROGRAM', 'definitely-not-vscode'); - expect(detectIde()).toBeUndefined(); + it('should detect Devin', () => { + vi.stubEnv('TERM_PROGRAM', 'vscode'); + vi.stubEnv('__COG_BASHRC_SOURCED', '1'); + expect(detectIde(ideProcessInfo)).toBe(DetectedIde.Devin); + }); + + it('should detect Replit', () => { + vi.stubEnv('TERM_PROGRAM', 'vscode'); + vi.stubEnv('REPLIT_USER', 'testuser'); + expect(detectIde(ideProcessInfo)).toBe(DetectedIde.Replit); + }); + + it('should detect Cursor', () => { + vi.stubEnv('TERM_PROGRAM', 'vscode'); + vi.stubEnv('CURSOR_TRACE_ID', 'some-id'); + expect(detectIde(ideProcessInfo)).toBe(DetectedIde.Cursor); + }); + + it('should detect Codespaces', () => { + vi.stubEnv('TERM_PROGRAM', 'vscode'); + vi.stubEnv('CODESPACES', 'true'); + expect(detectIde(ideProcessInfo)).toBe(DetectedIde.Codespaces); + }); + + it('should detect Cloud Shell via EDITOR_IN_CLOUD_SHELL', () => { + vi.stubEnv('TERM_PROGRAM', 'vscode'); + vi.stubEnv('EDITOR_IN_CLOUD_SHELL', 'true'); + expect(detectIde(ideProcessInfo)).toBe(DetectedIde.CloudShell); + }); + + it('should detect Cloud Shell via CLOUD_SHELL', () => { + vi.stubEnv('TERM_PROGRAM', 'vscode'); + vi.stubEnv('CLOUD_SHELL', 'true'); + expect(detectIde(ideProcessInfo)).toBe(DetectedIde.CloudShell); + }); + + it('should detect Trae', () => { + vi.stubEnv('TERM_PROGRAM', 'vscode'); + vi.stubEnv('TERM_PRODUCT', 'Trae'); + expect(detectIde(ideProcessInfo)).toBe(DetectedIde.Trae); + }); + + it('should detect Firebase Studio via FIREBASE_DEPLOY_AGENT', () => { + vi.stubEnv('TERM_PROGRAM', 'vscode'); + vi.stubEnv('FIREBASE_DEPLOY_AGENT', 'true'); + expect(detectIde(ideProcessInfo)).toBe(DetectedIde.FirebaseStudio); + }); + + it('should detect Firebase Studio via MONOSPACE_ENV', () => { + vi.stubEnv('TERM_PROGRAM', 'vscode'); + vi.stubEnv('MONOSPACE_ENV', 'true'); + expect(detectIde(ideProcessInfo)).toBe(DetectedIde.FirebaseStudio); + }); + + it('should detect VSCode when no other IDE is detected and command includes "code"', () => { + vi.stubEnv('TERM_PROGRAM', 'vscode'); + expect(detectIde(ideProcessInfo)).toBe(DetectedIde.VSCode); + }); + + it('should detect VSCodeFork when no other IDE is detected and command does not include "code"', () => { + vi.stubEnv('TERM_PROGRAM', 'vscode'); + expect(detectIde(ideProcessInfoNoCode)).toBe(DetectedIde.VSCodeFork); + }); + + it('should prioritize other IDEs over VSCode detection', () => { + vi.stubEnv('TERM_PROGRAM', 'vscode'); + vi.stubEnv('REPLIT_USER', 'testuser'); + expect(detectIde(ideProcessInfo)).toBe(DetectedIde.Replit); + }); +}); + +describe('getIdeInfo', () => { + it('should return correct info for Devin', () => { + expect(getIdeInfo(DetectedIde.Devin)).toEqual({ displayName: 'Devin' }); + }); + + it('should return correct info for Replit', () => { + expect(getIdeInfo(DetectedIde.Replit)).toEqual({ displayName: 'Replit' }); + }); + + it('should return correct info for Cursor', () => { + expect(getIdeInfo(DetectedIde.Cursor)).toEqual({ displayName: 'Cursor' }); + }); + + it('should return correct info for CloudShell', () => { + expect(getIdeInfo(DetectedIde.CloudShell)).toEqual({ + displayName: 'Cloud Shell', + }); + }); + + it('should return correct info for Codespaces', () => { + expect(getIdeInfo(DetectedIde.Codespaces)).toEqual({ + displayName: 'GitHub Codespaces', + }); + }); + + it('should return correct info for FirebaseStudio', () => { + expect(getIdeInfo(DetectedIde.FirebaseStudio)).toEqual({ + displayName: 'Firebase Studio', + }); + }); + + it('should return correct info for Trae', () => { + expect(getIdeInfo(DetectedIde.Trae)).toEqual({ displayName: 'Trae' }); + }); + + it('should return correct info for VSCode', () => { + expect(getIdeInfo(DetectedIde.VSCode)).toEqual({ displayName: 'VS Code' }); + }); + + it('should return correct info for VSCodeFork', () => { + expect(getIdeInfo(DetectedIde.VSCodeFork)).toEqual({ displayName: 'IDE' }); }); }); diff --git a/packages/core/src/ide/detect-ide.ts b/packages/core/src/ide/detect-ide.ts index 5eca5429..52a1244f 100644 --- a/packages/core/src/ide/detect-ide.ts +++ b/packages/core/src/ide/detect-ide.ts @@ -7,12 +7,13 @@ export enum DetectedIde { Devin = 'devin', Replit = 'replit', - VSCode = 'vscode', Cursor = 'cursor', CloudShell = 'cloudshell', Codespaces = 'codespaces', FirebaseStudio = 'firebasestudio', Trae = 'trae', + VSCode = 'vscode', + VSCodeFork = 'vscodefork', } export interface IdeInfo { @@ -29,10 +30,6 @@ export function getIdeInfo(ide: DetectedIde): IdeInfo { return { displayName: 'Replit', }; - case DetectedIde.VSCode: - return { - displayName: 'VS Code', - }; case DetectedIde.Cursor: return { displayName: 'Cursor', @@ -53,6 +50,14 @@ export function getIdeInfo(ide: DetectedIde): IdeInfo { return { displayName: 'Trae', }; + case DetectedIde.VSCode: + return { + displayName: 'VS Code', + }; + case DetectedIde.VSCodeFork: + return { + displayName: 'IDE', + }; default: { // This ensures that if a new IDE is added to the enum, we get a compile-time error. const exhaustiveCheck: never = ide; @@ -61,11 +66,7 @@ export function getIdeInfo(ide: DetectedIde): IdeInfo { } } -export function detectIde(): DetectedIde | undefined { - // Only VSCode-based integrations are currently supported. - if (process.env['TERM_PROGRAM'] !== 'vscode') { - return undefined; - } +export function detectIdeFromEnv(): DetectedIde { if (process.env['__COG_BASHRC_SOURCED']) { return DetectedIde.Devin; } @@ -89,3 +90,32 @@ export function detectIde(): DetectedIde | undefined { } return DetectedIde.VSCode; } + +function verifyVSCode( + ide: DetectedIde, + ideProcessInfo: { + pid: number; + command: string; + }, +): DetectedIde { + if (ide !== DetectedIde.VSCode) { + return ide; + } + if (ideProcessInfo.command.toLowerCase().includes('code')) { + return DetectedIde.VSCode; + } + return DetectedIde.VSCodeFork; +} + +export function detectIde(ideProcessInfo: { + pid: number; + command: string; +}): DetectedIde | undefined { + // Only VSCode-based integrations are currently supported. + if (process.env['TERM_PROGRAM'] !== 'vscode') { + return undefined; + } + + const ide = detectIdeFromEnv(); + return verifyVSCode(ide, ideProcessInfo); +} diff --git a/packages/core/src/ide/ide-client.test.ts b/packages/core/src/ide/ide-client.test.ts index 7ad71ba3..c47753e4 100644 --- a/packages/core/src/ide/ide-client.test.ts +++ b/packages/core/src/ide/ide-client.test.ts @@ -15,7 +15,7 @@ import { } from 'vitest'; import { IdeClient, IDEConnectionStatus } from './ide-client.js'; import * as fs from 'node:fs'; -import { getIdeProcessId } from './process-utils.js'; +import { getIdeProcessInfo } from './process-utils.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; @@ -51,7 +51,7 @@ describe('IdeClient', () => { let mockHttpTransport: Mocked; let mockStdioTransport: Mocked; - beforeEach(() => { + beforeEach(async () => { // Reset singleton instance for test isolation (IdeClient as unknown as { instance: IdeClient | undefined }).instance = undefined; @@ -68,7 +68,10 @@ describe('IdeClient', () => { vi.mocked(getIdeInfo).mockReturnValue({ displayName: 'VS Code', } as IdeInfo); - vi.mocked(getIdeProcessId).mockResolvedValue(12345); + vi.mocked(getIdeProcessInfo).mockResolvedValue({ + pid: 12345, + command: 'test-ide', + }); vi.mocked(os.tmpdir).mockReturnValue('/tmp'); // Mock MCP client and transports @@ -88,6 +91,8 @@ describe('IdeClient', () => { vi.mocked(Client).mockReturnValue(mockClient); vi.mocked(StreamableHTTPClientTransport).mockReturnValue(mockHttpTransport); vi.mocked(StdioClientTransport).mockReturnValue(mockStdioTransport); + + await IdeClient.getInstance(); }); afterEach(() => { @@ -99,7 +104,7 @@ describe('IdeClient', () => { const config = { port: '8080' }; vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); - const ideClient = IdeClient.getInstance(); + const ideClient = await IdeClient.getInstance(); await ideClient.connect(); expect(fs.promises.readFile).toHaveBeenCalledWith( @@ -120,7 +125,7 @@ describe('IdeClient', () => { const config = { stdio: { command: 'test-cmd', args: ['--foo'] } }; vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); - const ideClient = IdeClient.getInstance(); + const ideClient = await IdeClient.getInstance(); await ideClient.connect(); expect(StdioClientTransport).toHaveBeenCalledWith({ @@ -140,7 +145,7 @@ describe('IdeClient', () => { }; vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); - const ideClient = IdeClient.getInstance(); + const ideClient = await IdeClient.getInstance(); await ideClient.connect(); expect(StreamableHTTPClientTransport).toHaveBeenCalled(); @@ -156,7 +161,7 @@ describe('IdeClient', () => { ); process.env['GEMINI_CLI_IDE_SERVER_PORT'] = '9090'; - const ideClient = IdeClient.getInstance(); + const ideClient = await IdeClient.getInstance(); await ideClient.connect(); expect(StreamableHTTPClientTransport).toHaveBeenCalledWith( @@ -176,7 +181,7 @@ describe('IdeClient', () => { process.env['GEMINI_CLI_IDE_SERVER_STDIO_COMMAND'] = 'env-cmd'; process.env['GEMINI_CLI_IDE_SERVER_STDIO_ARGS'] = '["--bar"]'; - const ideClient = IdeClient.getInstance(); + const ideClient = await IdeClient.getInstance(); await ideClient.connect(); expect(StdioClientTransport).toHaveBeenCalledWith({ @@ -194,7 +199,7 @@ describe('IdeClient', () => { vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); process.env['GEMINI_CLI_IDE_SERVER_PORT'] = '9090'; - const ideClient = IdeClient.getInstance(); + const ideClient = await IdeClient.getInstance(); await ideClient.connect(); expect(StreamableHTTPClientTransport).toHaveBeenCalledWith( @@ -211,7 +216,7 @@ describe('IdeClient', () => { new Error('File not found'), ); - const ideClient = IdeClient.getInstance(); + const ideClient = await IdeClient.getInstance(); await ideClient.connect(); expect(StreamableHTTPClientTransport).not.toHaveBeenCalled(); diff --git a/packages/core/src/ide/ide-client.ts b/packages/core/src/ide/ide-client.ts index 0f8536aa..868b9583 100644 --- a/packages/core/src/ide/ide-client.ts +++ b/packages/core/src/ide/ide-client.ts @@ -15,7 +15,7 @@ import { CloseDiffResponseSchema, DiffUpdateResult, } from '../ide/ideContext.js'; -import { getIdeProcessId } from './process-utils.js'; +import { getIdeProcessInfo } from './process-utils.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; @@ -72,21 +72,25 @@ export class IdeClient { details: 'IDE integration is currently disabled. To enable it, run /ide enable.', }; - private readonly currentIde: DetectedIde | undefined; - private readonly currentIdeDisplayName: string | undefined; + private currentIde: DetectedIde | undefined; + private currentIdeDisplayName: string | undefined; + private ideProcessInfo: { pid: number; command: string } | undefined; private diffResponses = new Map void>(); private statusListeners = new Set<(state: IDEConnectionState) => void>(); - private constructor() { - this.currentIde = detectIde(); - if (this.currentIde) { - this.currentIdeDisplayName = getIdeInfo(this.currentIde).displayName; - } - } + private constructor() {} - static getInstance(): IdeClient { + static async getInstance(): Promise { if (!IdeClient.instance) { - IdeClient.instance = new IdeClient(); + const client = new IdeClient(); + client.ideProcessInfo = await getIdeProcessInfo(); + client.currentIde = detectIde(client.ideProcessInfo); + if (client.currentIde) { + client.currentIdeDisplayName = getIdeInfo( + client.currentIde, + ).displayName; + } + IdeClient.instance = client; } return IdeClient.instance; } @@ -103,11 +107,7 @@ export class IdeClient { if (!this.currentIde || !this.currentIdeDisplayName) { this.setState( IDEConnectionStatus.Disconnected, - `IDE integration is not supported in your current environment. To use this feature, run Gemini CLI in one of these supported IDEs: ${Object.values( - DetectedIde, - ) - .map((ide) => getIdeInfo(ide).displayName) - .join(', ')}`, + `IDE integration is not supported in your current environment. To use this feature, run Gemini CLI in one of these supported IDEs: VS Code or VS Code forks`, false, ); return; @@ -168,7 +168,7 @@ export class IdeClient { this.setState( IDEConnectionStatus.Disconnected, - `Failed to connect to IDE companion extension for ${this.currentIdeDisplayName}. Please ensure the extension is running. To install the extension, run /ide install.`, + `Failed to connect to IDE companion extension in ${this.currentIdeDisplayName}. Please ensure the extension is running. To install the extension, run /ide install.`, true, ); } @@ -309,7 +309,7 @@ export class IdeClient { if (ideWorkspacePath === undefined) { return { isValid: false, - error: `Failed to connect to IDE companion extension for ${currentIdeDisplayName}. Please ensure the extension is running. To install the extension, run /ide install.`, + error: `Failed to connect to IDE companion extension in ${currentIdeDisplayName}. Please ensure the extension is running. To install the extension, run /ide install.`, }; } @@ -375,11 +375,13 @@ export class IdeClient { private async getConnectionConfigFromFile(): Promise< (ConnectionConfig & { workspacePath?: string }) | undefined > { + if (!this.ideProcessInfo) { + return {}; + } try { - const ideProcessId = await getIdeProcessId(); const portFile = path.join( os.tmpdir(), - `gemini-ide-server-${ideProcessId}.json`, + `gemini-ide-server-${this.ideProcessInfo.pid}.json`, ); const portFileContents = await fs.promises.readFile(portFile, 'utf8'); return JSON.parse(portFileContents); diff --git a/packages/core/src/ide/process-utils.test.ts b/packages/core/src/ide/process-utils.test.ts new file mode 100644 index 00000000..9ac56424 --- /dev/null +++ b/packages/core/src/ide/process-utils.test.ts @@ -0,0 +1,90 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + describe, + it, + expect, + vi, + afterEach, + beforeEach, + type Mock, +} from 'vitest'; +import { getIdeProcessInfo } from './process-utils.js'; +import os from 'node:os'; + +const mockedExec = vi.hoisted(() => vi.fn()); +vi.mock('node:util', () => ({ + promisify: vi.fn().mockReturnValue(mockedExec), +})); +vi.mock('node:os', () => ({ + default: { + platform: vi.fn(), + }, +})); + +describe('getIdeProcessInfo', () => { + beforeEach(() => { + Object.defineProperty(process, 'pid', { value: 1000, configurable: true }); + mockedExec.mockReset(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('on Unix', () => { + it('should traverse up to find the shell and return grandparent process info', async () => { + (os.platform as Mock).mockReturnValue('linux'); + // process (1000) -> shell (800) -> IDE (700) + mockedExec + .mockResolvedValueOnce({ stdout: '800 /bin/bash' }) // pid 1000 -> ppid 800 (shell) + .mockResolvedValueOnce({ stdout: '700 /usr/lib/vscode/code' }) // pid 800 -> ppid 700 (IDE) + .mockResolvedValueOnce({ stdout: '700 /usr/lib/vscode/code' }); // get command for pid 700 + + const result = await getIdeProcessInfo(); + + expect(result).toEqual({ pid: 700, command: '/usr/lib/vscode/code' }); + }); + + it('should return parent process info if grandparent lookup fails', async () => { + (os.platform as Mock).mockReturnValue('linux'); + mockedExec + .mockResolvedValueOnce({ stdout: '800 /bin/bash' }) // pid 1000 -> ppid 800 (shell) + .mockRejectedValueOnce(new Error('ps failed')) // lookup for ppid of 800 fails + .mockResolvedValueOnce({ stdout: '800 /bin/bash' }); // get command for pid 800 + + const result = await getIdeProcessInfo(); + expect(result).toEqual({ pid: 800, command: '/bin/bash' }); + }); + }); + + describe('on Windows', () => { + it('should traverse up and find the great-grandchild of the root process', async () => { + (os.platform as Mock).mockReturnValue('win32'); + const processInfoMap = new Map([ + [1000, { stdout: 'ParentProcessId=900\r\nCommandLine=node.exe\r\n' }], + [ + 900, + { stdout: 'ParentProcessId=800\r\nCommandLine=powershell.exe\r\n' }, + ], + [800, { stdout: 'ParentProcessId=700\r\nCommandLine=code.exe\r\n' }], + [700, { stdout: 'ParentProcessId=0\r\nCommandLine=wininit.exe\r\n' }], + ]); + mockedExec.mockImplementation((command: string) => { + const pidMatch = command.match(/ProcessId=(\d+)/); + if (pidMatch) { + const pid = parseInt(pidMatch[1], 10); + return Promise.resolve(processInfoMap.get(pid)); + } + return Promise.reject(new Error('Invalid command for mock')); + }); + + const result = await getIdeProcessInfo(); + expect(result).toEqual({ pid: 900, command: 'powershell.exe' }); + }); + }); +}); diff --git a/packages/core/src/ide/process-utils.ts b/packages/core/src/ide/process-utils.ts index a201f45e..c6707442 100644 --- a/packages/core/src/ide/process-utils.ts +++ b/packages/core/src/ide/process-utils.ts @@ -14,24 +14,27 @@ const execAsync = promisify(exec); const MAX_TRAVERSAL_DEPTH = 32; /** - * Fetches the parent process ID and name for a given process ID. + * Fetches the parent process ID, name, and command for a given process ID. * * @param pid The process ID to inspect. - * @returns A promise that resolves to the parent's PID and name. + * @returns A promise that resolves to the parent's PID, name, and command. */ -async function getParentProcessInfo(pid: number): Promise<{ +async function getProcessInfo(pid: number): Promise<{ parentPid: number; name: string; + command: string; }> { const platform = os.platform(); if (platform === 'win32') { - const command = `wmic process where "ProcessId=${pid}" get Name,ParentProcessId /value`; + const command = `wmic process where "ProcessId=${pid}" get Name,ParentProcessId,CommandLine /value`; const { stdout } = await execAsync(command); const nameMatch = stdout.match(/Name=([^\n]*)/); const processName = nameMatch ? nameMatch[1].trim() : ''; const ppidMatch = stdout.match(/ParentProcessId=(\d+)/); const parentPid = ppidMatch ? parseInt(ppidMatch[1], 10) : 0; - return { parentPid, name: processName }; + const commandLineMatch = stdout.match(/CommandLine=([^\n]*)/); + const commandLine = commandLineMatch ? commandLineMatch[1].trim() : ''; + return { parentPid, name: processName, command: commandLine }; } else { const command = `ps -o ppid=,command= -p ${pid}`; const { stdout } = await execAsync(command); @@ -40,42 +43,50 @@ async function getParentProcessInfo(pid: number): Promise<{ const parentPid = parseInt(ppidString, 10); const fullCommand = trimmedStdout.substring(ppidString.length).trim(); const processName = path.basename(fullCommand.split(' ')[0]); - return { parentPid: isNaN(parentPid) ? 1 : parentPid, name: processName }; + return { + parentPid: isNaN(parentPid) ? 1 : parentPid, + name: processName, + command: fullCommand, + }; } } /** - * Traverses the process tree on Unix-like systems to find the IDE process ID. + * Finds the IDE process info on Unix-like systems. * * The strategy is to find the shell process that spawned the CLI, and then * find that shell's parent process (the IDE). To get the true IDE process, * we traverse one level higher to get the grandparent. * - * @returns A promise that resolves to the numeric PID. + * @returns A promise that resolves to the PID and command of the IDE process. */ -async function getIdeProcessIdForUnix(): Promise { +async function getIdeProcessInfoForUnix(): Promise<{ + pid: number; + command: string; +}> { const shells = ['zsh', 'bash', 'sh', 'tcsh', 'csh', 'ksh', 'fish', 'dash']; let currentPid = process.pid; for (let i = 0; i < MAX_TRAVERSAL_DEPTH; i++) { try { - const { parentPid, name } = await getParentProcessInfo(currentPid); + const { parentPid, name } = await getProcessInfo(currentPid); const isShell = shells.some((shell) => name === shell); if (isShell) { // The direct parent of the shell is often a utility process (e.g. VS // Code's `ptyhost` process). To get the true IDE process, we need to // traverse one level higher to get the grandparent. + let idePid = parentPid; try { - const { parentPid: grandParentPid } = - await getParentProcessInfo(parentPid); + const { parentPid: grandParentPid } = await getProcessInfo(parentPid); if (grandParentPid > 1) { - return grandParentPid; + idePid = grandParentPid; } } catch { // Ignore if getting grandparent fails, we'll just use the parent pid. } - return parentPid; + const { command } = await getProcessInfo(idePid); + return { pid: idePid, command }; } if (parentPid <= 1) { @@ -91,30 +102,36 @@ async function getIdeProcessIdForUnix(): Promise { console.error( 'Failed to find shell process in the process tree. Falling back to top-level process, which may be inaccurate. If you see this, please file a bug via /bug.', ); - return currentPid; + const { command } = await getProcessInfo(currentPid); + return { pid: currentPid, command }; } /** - * Traverses the process tree on Windows to find the IDE process ID. + * Finds the IDE process info on Windows. * - * The strategy is to find the grandchild of the root process. + * The strategy is to find the great-grandchild of the root process. * - * @returns A promise that resolves to the numeric PID. + * @returns A promise that resolves to the PID and command of the IDE process. */ -async function getIdeProcessIdForWindows(): Promise { +async function getIdeProcessInfoForWindows(): Promise<{ + pid: number; + command: string; +}> { let currentPid = process.pid; + let previousPid = process.pid; for (let i = 0; i < MAX_TRAVERSAL_DEPTH; i++) { try { - const { parentPid } = await getParentProcessInfo(currentPid); + const { parentPid } = await getProcessInfo(currentPid); if (parentPid > 0) { try { - const { parentPid: grandParentPid } = - await getParentProcessInfo(parentPid); + const { parentPid: grandParentPid } = await getProcessInfo(parentPid); if (grandParentPid === 0) { - // Found grandchild of root - return currentPid; + // We've found the grandchild of the root (`currentPid`). The IDE + // process is its child, which we've stored in `previousPid`. + const { command } = await getProcessInfo(previousPid); + return { pid: previousPid, command }; } } catch { // getting grandparent failed, proceed @@ -124,34 +141,39 @@ async function getIdeProcessIdForWindows(): Promise { if (parentPid <= 0) { break; // Reached the root } + previousPid = currentPid; currentPid = parentPid; } catch { // Process in chain died break; } } - return currentPid; + const { command } = await getProcessInfo(currentPid); + return { pid: currentPid, command }; } /** - * Traverses up the process tree to find the process ID of the IDE. + * Traverses up the process tree to find the process ID and command of the IDE. * * This function uses different strategies depending on the operating system * to identify the main application process (e.g., the main VS Code window * process). * * If the IDE process cannot be reliably identified, it will return the - * top-level ancestor process ID as a fallback. + * top-level ancestor process ID and command as a fallback. * - * @returns A promise that resolves to the numeric PID of the IDE process. + * @returns A promise that resolves to the PID and command of the IDE process. * @throws Will throw an error if the underlying shell commands fail. */ -export async function getIdeProcessId(): Promise { +export async function getIdeProcessInfo(): Promise<{ + pid: number; + command: string; +}> { const platform = os.platform(); if (platform === 'win32') { - return getIdeProcessIdForWindows(); + return getIdeProcessInfoForWindows(); } - return getIdeProcessIdForUnix(); + return getIdeProcessInfoForUnix(); } diff --git a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts index 3cbb9e55..2a66fa0c 100644 --- a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts +++ b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts @@ -28,7 +28,7 @@ import { UserAccountManager } from '../../utils/userAccountManager.js'; import { safeJsonStringify } from '../../utils/safeJsonStringify.js'; import { FixedDeque } from 'mnemonist'; import { GIT_COMMIT_INFO, CLI_VERSION } from '../../generated/git-commit.js'; -import { DetectedIde, detectIde } from '../../ide/detect-ide.js'; +import { DetectedIde, detectIdeFromEnv } from '../../ide/detect-ide.js'; export enum EventNames { START_SESSION = 'start_session', @@ -93,7 +93,7 @@ function determineSurface(): string { } else if (process.env['GITHUB_SHA']) { return 'GitHub'; } else if (process.env['TERM_PROGRAM'] === 'vscode') { - return detectIde() || DetectedIde.VSCode; + return detectIdeFromEnv() || DetectedIde.VSCode; } else { return 'SURFACE_NOT_SET'; }