From 8fdb61aabfd2efa4551fc62f1c57c092a1d2bf5e Mon Sep 17 00:00:00 2001 From: Shreya Keshive Date: Sun, 21 Sep 2025 20:54:18 -0400 Subject: [PATCH] feat(ide): Read IDE info from discovery file (#8760) --- docs/ide-companion-spec.md | 57 +++++++----------------- packages/core/src/ide/detect-ide.test.ts | 32 +++++++++++++ packages/core/src/ide/detect-ide.ts | 22 ++++++--- packages/core/src/ide/ide-client.test.ts | 2 - packages/core/src/ide/ide-client.ts | 45 +++++++++++-------- 5 files changed, 89 insertions(+), 69 deletions(-) diff --git a/docs/ide-companion-spec.md b/docs/ide-companion-spec.md index b13622af52..63b52e4d66 100644 --- a/docs/ide-companion-spec.md +++ b/docs/ide-companion-spec.md @@ -30,24 +30,22 @@ For Gemini CLI to connect, it needs to discover which IDE instance it's running ```json { "port": 12345, - "workspacePath": "/path/to/project1:/path/to/project2" + "workspacePath": "/path/to/project1:/path/to/project2", + "authToken": "a-very-secret-token", + "ideInfo": { + "name": "vscode", + "displayName": "VS Code" + } } ``` - - `port` (number): The port of the MCP server. - - `workspacePath` (string): A list of all open workspace root paths, delimited by the OS-specific path separator (`:` for Linux/macOS, `;` for Windows). The CLI uses this path to ensure it's running in the same project folder that's open in the IDE. If the CLI's current working directory is not a sub-directory of `workspacePath`, the connection will be rejected. Your extension **MUST** provide the correct, absolute path(s) to the root of the open workspace(s). -- **Tie-Breaking with Environment Variables (Recommended):** For the most reliable experience, your extension **SHOULD** both create the discovery file and set the `GEMINI_CLI_IDE_SERVER_PORT` and `GEMINI_CLI_IDE_WORKSPACE_PATH` environment variables in the integrated terminal. The file serves as the primary discovery mechanism, but the environment variables are crucial for tie-breaking. If a user has multiple IDE windows open for the same workspace, the CLI uses the `GEMINI_CLI_IDE_SERVER_PORT` variable to identify and connect to the correct window's server. - - For prototyping, you may opt to _only_ set the environment variables. However, this is not a robust solution for a production extension, as environment variables may not be reliably set in all terminal sessions (e.g., restored terminals), which can lead to connection failures. -- **Authentication:** To secure the connection, the extension **SHOULD** generate a unique, secret token and include it in the discovery file. The CLI will then include this token in all requests to the MCP server. - - **Token Generation:** The extension should generate a random string to be used as a bearer token. - - **Discovery File Content:** The `authToken` field must be added to the JSON object in the discovery file: - ```json - { - "port": 12345, - "workspacePath": "/path/to/project", - "authToken": "a-very-secret-token" - } - ``` - - **Request Authorization:** The CLI will read the `authToken` from the file and include it in the `Authorization` header for all HTTP requests to the MCP server (e.g., `Authorization: Bearer a-very-secret-token`). Your server **MUST** validate this token on every request and reject any that are unauthorized. + - `port` (number, required): The port of the MCP server. + - `workspacePath` (string, required): A list of all open workspace root paths, delimited by the OS-specific path separator (`:` for Linux/macOS, `;` for Windows). The CLI uses this path to ensure it's running in the same project folder that's open in the IDE. If the CLI's current working directory is not a sub-directory of `workspacePath`, the connection will be rejected. Your extension **MUST** provide the correct, absolute path(s) to the root of the open workspace(s). + - `authToken` (string, required): A secret token for securing the connection. The CLI will include this token in an `Authorization: Bearer ` header on all requests. + - `ideInfo` (object, required): Information about the IDE. + - `name` (string, required): A short, lowercase identifier for the IDE (e.g., `vscode`, `jetbrains`). + - `displayName` (string, required): A user-friendly name for the IDE (e.g., `VS Code`, `JetBrains IDE`). +- **Authentication:** To secure the connection, the extension **MUST** generate a unique, secret token and include it in the discovery file. The CLI will then include this token in the `Authorization` header for all requests to the MCP server (e.g., `Authorization: Bearer a-very-secret-token`). Your server **MUST** validate this token on every request and reject any that are unauthorized. +- **Tie-Breaking with Environment Variables (Recommended):** For the most reliable experience, your extension **SHOULD** both create the discovery file and set the `GEMINI_CLI_IDE_SERVER_PORT` environment variable in the integrated terminal. The file serves as the primary discovery mechanism, but the environment variable is crucial for tie-breaking. If a user has multiple IDE windows open for the same workspace, the CLI uses the `GEMINI_CLI_IDE_SERVER_PORT` variable to identify and connect to the correct window's server. ## II. The Context Interface @@ -172,32 +170,7 @@ When the user rejects the changes (e.g., by closing the diff view without accept } ``` -## IV. Supporting Additional IDEs - -To add support for a new IDE, two main components in the Gemini CLI codebase need to be updated: the detection logic and the installer logic. - -### 1. IDE Detection (`@packages/core/src/ide/detect-ide.ts`) - -// TODO(skeshive): Determine whether we should discover the IDE via the port file - -The CLI must be able to identify when it is running inside a specific IDE's integrated terminal. This is primarily done by checking for unique environment variables. As a fallback, it can also inspect process information (like the command name) to help distinguish between IDEs if a unique environment variable is not available. - -- **Add to `DetectedIde` Enum:** First, add your new IDE to the `DetectedIde` enum. -- **Update `detectIdeFromEnv`:** Add a check in this function for an environment variable specific to your IDE (e.g., `if (process.env['MY_IDE_VAR']) { return DetectedIde.MyIde; }`). -- **Update `detectIde` (Optional):** If your IDE lacks a unique environment variable, you can add logic to the `detectIde` function to inspect `ideProcessInfo` (e.g., `ideProcessInfo.command`) as a secondary detection mechanism. - -### 2. Extension Installation (`@packages/core/src/ide/ide-installer.ts`) - -The CLI provides a command (`/ide install`) to help users automatically install the companion extension. While optional, implementing an `IdeInstaller` for your IDE is highly recommended to provide a seamless setup experience. - -- **Create an Installer Class:** Create a new class that implements the `IdeInstaller` interface. -- **Implement `install()`:** The `install` method should: - 1. Locate the IDE's command-line executable. The `VsCodeInstaller` provides a good example of searching common installation paths for different operating systems. - 2. Execute the command to install the extension by its marketplace ID (e.g., `"path/to/my-ide-cli" --install-extension my-publisher.my-extension-id`). - 3. Return a result object indicating success or failure. -- **Update `getIdeInstaller`:** Add a case to the `switch` statement in this factory function to return an instance of your new installer class when your `DetectedIde` enum is matched. - -## V. The Lifecycle Interface +## IV. The Lifecycle Interface The extension **MUST** manage its resources and the discovery file correctly based on the IDE's lifecycle. diff --git a/packages/core/src/ide/detect-ide.test.ts b/packages/core/src/ide/detect-ide.test.ts index 0aef991a49..07a02bcdec 100644 --- a/packages/core/src/ide/detect-ide.test.ts +++ b/packages/core/src/ide/detect-ide.test.ts @@ -80,3 +80,35 @@ describe('detectIde', () => { expect(detectIde(ideProcessInfoNoCode)).toBe(IDE_DEFINITIONS.vscodefork); }); }); + +describe('detectIde with ideInfoFromFile', () => { + const ideProcessInfo = { pid: 123, command: 'some/path/to/code' }; + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('should use the name and displayName from the file', () => { + const ideInfoFromFile = { + name: 'custom-ide', + displayName: 'Custom IDE', + }; + expect(detectIde(ideProcessInfo, ideInfoFromFile)).toEqual(ideInfoFromFile); + }); + + it('should fall back to env detection if name is missing', () => { + const ideInfoFromFile = { displayName: 'Custom IDE' }; + vi.stubEnv('TERM_PROGRAM', 'vscode'); + expect(detectIde(ideProcessInfo, ideInfoFromFile)).toBe( + IDE_DEFINITIONS.vscode, + ); + }); + + it('should fall back to env detection if displayName is missing', () => { + const ideInfoFromFile = { name: 'custom-ide' }; + vi.stubEnv('TERM_PROGRAM', 'vscode'); + expect(detectIde(ideProcessInfo, ideInfoFromFile)).toBe( + IDE_DEFINITIONS.vscode, + ); + }); +}); diff --git a/packages/core/src/ide/detect-ide.ts b/packages/core/src/ide/detect-ide.ts index 8589e379f3..637b98b735 100644 --- a/packages/core/src/ide/detect-ide.ts +++ b/packages/core/src/ide/detect-ide.ts @@ -16,10 +16,8 @@ export const IDE_DEFINITIONS = { vscodefork: { name: 'vscodefork', displayName: 'IDE' }, } as const; -export type IdeName = keyof typeof IDE_DEFINITIONS; - export interface IdeInfo { - name: IdeName; + name: string; displayName: string; } @@ -64,10 +62,20 @@ function verifyVSCode( return IDE_DEFINITIONS.vscodefork; } -export function detectIde(ideProcessInfo: { - pid: number; - command: string; -}): IdeInfo | undefined { +export function detectIde( + ideProcessInfo: { + pid: number; + command: string; + }, + ideInfoFromFile?: { name?: string; displayName?: string }, +): IdeInfo | undefined { + if (ideInfoFromFile?.name && ideInfoFromFile.displayName) { + return { + name: ideInfoFromFile.name, + displayName: ideInfoFromFile.displayName, + }; + } + // Only VSCode-based integrations are currently supported. if (process.env['TERM_PROGRAM'] !== 'vscode') { return undefined; diff --git a/packages/core/src/ide/ide-client.test.ts b/packages/core/src/ide/ide-client.test.ts index fe460abb16..0640232a6c 100644 --- a/packages/core/src/ide/ide-client.test.ts +++ b/packages/core/src/ide/ide-client.test.ts @@ -367,12 +367,10 @@ describe('IdeClient', () => { expect(result).toEqual(validConfig); expect(validateSpy).toHaveBeenCalledWith( '/invalid/workspace', - 'VS Code', '/test/workspace/sub-dir', ); expect(validateSpy).toHaveBeenCalledWith( '/test/workspace', - 'VS Code', '/test/workspace/sub-dir', ); }); diff --git a/packages/core/src/ide/ide-client.ts b/packages/core/src/ide/ide-client.ts index 69085d411e..e4c4006bf1 100644 --- a/packages/core/src/ide/ide-client.ts +++ b/packages/core/src/ide/ide-client.ts @@ -60,8 +60,8 @@ type StdioConfig = { type ConnectionConfig = { port?: string; - stdio?: StdioConfig; authToken?: string; + stdio?: StdioConfig; }; function getRealPath(path: string): string { @@ -87,6 +87,9 @@ export class IdeClient { }; private currentIde: IdeInfo | undefined; private ideProcessInfo: { pid: number; command: string } | undefined; + private connectionConfig: + | (ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo }) + | undefined; private authToken: string | undefined; private diffResponses = new Map void>(); private statusListeners = new Set<(state: IDEConnectionState) => void>(); @@ -106,7 +109,11 @@ export class IdeClient { IdeClient.instancePromise = (async () => { const client = new IdeClient(); client.ideProcessInfo = await getIdeProcessInfo(); - client.currentIde = detectIde(client.ideProcessInfo); + client.connectionConfig = await client.getConnectionConfigFromFile(); + client.currentIde = detectIde( + client.ideProcessInfo, + client.connectionConfig?.ideInfo, + ); return client; })(); } @@ -141,17 +148,16 @@ export class IdeClient { this.setState(IDEConnectionStatus.Connecting); - const configFromFile = await this.getConnectionConfigFromFile(); - if (configFromFile?.authToken) { - this.authToken = configFromFile.authToken; + this.connectionConfig = await this.getConnectionConfigFromFile(); + if (this.connectionConfig?.authToken) { + this.authToken = this.connectionConfig.authToken; } const workspacePath = - configFromFile?.workspacePath ?? + this.connectionConfig?.workspacePath ?? process.env['GEMINI_CLI_IDE_WORKSPACE_PATH']; const { isValid, error } = IdeClient.validateWorkspacePath( workspacePath, - this.currentIde.displayName, process.cwd(), ); @@ -160,18 +166,18 @@ export class IdeClient { return; } - if (configFromFile) { - if (configFromFile.port) { + if (this.connectionConfig) { + if (this.connectionConfig.port) { const connected = await this.establishHttpConnection( - configFromFile.port, + this.connectionConfig.port, ); if (connected) { return; } } - if (configFromFile.stdio) { + if (this.connectionConfig.stdio) { const connected = await this.establishStdioConnection( - configFromFile.stdio, + this.connectionConfig.stdio, ); if (connected) { return; @@ -494,20 +500,19 @@ export class IdeClient { static validateWorkspacePath( ideWorkspacePath: string | undefined, - currentIdeDisplayName: string | undefined, cwd: string, ): { isValid: boolean; error?: string } { if (ideWorkspacePath === undefined) { return { isValid: false, - error: `Failed to connect to IDE companion extension in ${currentIdeDisplayName}. Please ensure the extension is running. To install the extension, run /ide install.`, + error: `Failed to connect to IDE companion extension. Please ensure the extension is running. To install the extension, run /ide install.`, }; } if (ideWorkspacePath === '') { return { isValid: false, - error: `To use this feature, please open a workspace folder in ${currentIdeDisplayName} and try again.`, + error: `To use this feature, please open a workspace folder in your IDE and try again.`, }; } @@ -521,7 +526,7 @@ export class IdeClient { if (!isWithinWorkspace) { return { isValid: false, - error: `Directory mismatch. Gemini CLI is running in a different location than the open workspace in ${currentIdeDisplayName}. Please run the CLI from one of the following directories: ${ideWorkspacePaths.join( + error: `Directory mismatch. Gemini CLI is running in a different location than the open workspace in the IDE. Please run the CLI from one of the following directories: ${ideWorkspacePaths.join( ', ', )}`, }; @@ -564,7 +569,8 @@ export class IdeClient { } private async getConnectionConfigFromFile(): Promise< - (ConnectionConfig & { workspacePath?: string }) | undefined + | (ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo }) + | undefined > { if (!this.ideProcessInfo) { return undefined; @@ -594,6 +600,10 @@ export class IdeClient { return undefined; } + if (!portFiles) { + return undefined; + } + const fileRegex = new RegExp( `^gemini-ide-server-${this.ideProcessInfo.pid}-\\d+\\.json$`, ); @@ -630,7 +640,6 @@ export class IdeClient { } const { isValid } = IdeClient.validateWorkspacePath( content.workspacePath, - this.currentIde?.displayName, process.cwd(), ); return isValid;