mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-28 14:04:41 -07:00
feat(ide): Read IDE info from discovery file (#8760)
This commit is contained in:
+15
-42
@@ -30,24 +30,22 @@ For Gemini CLI to connect, it needs to discover which IDE instance it's running
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"port": 12345,
|
"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.
|
- `port` (number, required): 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).
|
- `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).
|
||||||
- **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.
|
- `authToken` (string, required): A secret token for securing the connection. The CLI will include this token in an `Authorization: Bearer <token>` header on all requests.
|
||||||
- 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.
|
- `ideInfo` (object, required): Information about the IDE.
|
||||||
- **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.
|
- `name` (string, required): A short, lowercase identifier for the IDE (e.g., `vscode`, `jetbrains`).
|
||||||
- **Token Generation:** The extension should generate a random string to be used as a bearer token.
|
- `displayName` (string, required): A user-friendly name for the IDE (e.g., `VS Code`, `JetBrains IDE`).
|
||||||
- **Discovery File Content:** The `authToken` field must be added to the JSON object in the discovery file:
|
- **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.
|
||||||
```json
|
- **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.
|
||||||
{
|
|
||||||
"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.
|
|
||||||
|
|
||||||
## II. The Context Interface
|
## 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
|
## IV. The Lifecycle Interface
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
The extension **MUST** manage its resources and the discovery file correctly based on the IDE's lifecycle.
|
The extension **MUST** manage its resources and the discovery file correctly based on the IDE's lifecycle.
|
||||||
|
|
||||||
|
|||||||
@@ -80,3 +80,35 @@ describe('detectIde', () => {
|
|||||||
expect(detectIde(ideProcessInfoNoCode)).toBe(IDE_DEFINITIONS.vscodefork);
|
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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -16,10 +16,8 @@ export const IDE_DEFINITIONS = {
|
|||||||
vscodefork: { name: 'vscodefork', displayName: 'IDE' },
|
vscodefork: { name: 'vscodefork', displayName: 'IDE' },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type IdeName = keyof typeof IDE_DEFINITIONS;
|
|
||||||
|
|
||||||
export interface IdeInfo {
|
export interface IdeInfo {
|
||||||
name: IdeName;
|
name: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,10 +62,20 @@ function verifyVSCode(
|
|||||||
return IDE_DEFINITIONS.vscodefork;
|
return IDE_DEFINITIONS.vscodefork;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function detectIde(ideProcessInfo: {
|
export function detectIde(
|
||||||
pid: number;
|
ideProcessInfo: {
|
||||||
command: string;
|
pid: number;
|
||||||
}): IdeInfo | undefined {
|
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.
|
// Only VSCode-based integrations are currently supported.
|
||||||
if (process.env['TERM_PROGRAM'] !== 'vscode') {
|
if (process.env['TERM_PROGRAM'] !== 'vscode') {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
@@ -367,12 +367,10 @@ describe('IdeClient', () => {
|
|||||||
expect(result).toEqual(validConfig);
|
expect(result).toEqual(validConfig);
|
||||||
expect(validateSpy).toHaveBeenCalledWith(
|
expect(validateSpy).toHaveBeenCalledWith(
|
||||||
'/invalid/workspace',
|
'/invalid/workspace',
|
||||||
'VS Code',
|
|
||||||
'/test/workspace/sub-dir',
|
'/test/workspace/sub-dir',
|
||||||
);
|
);
|
||||||
expect(validateSpy).toHaveBeenCalledWith(
|
expect(validateSpy).toHaveBeenCalledWith(
|
||||||
'/test/workspace',
|
'/test/workspace',
|
||||||
'VS Code',
|
|
||||||
'/test/workspace/sub-dir',
|
'/test/workspace/sub-dir',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -60,8 +60,8 @@ type StdioConfig = {
|
|||||||
|
|
||||||
type ConnectionConfig = {
|
type ConnectionConfig = {
|
||||||
port?: string;
|
port?: string;
|
||||||
stdio?: StdioConfig;
|
|
||||||
authToken?: string;
|
authToken?: string;
|
||||||
|
stdio?: StdioConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getRealPath(path: string): string {
|
function getRealPath(path: string): string {
|
||||||
@@ -87,6 +87,9 @@ export class IdeClient {
|
|||||||
};
|
};
|
||||||
private currentIde: IdeInfo | undefined;
|
private currentIde: IdeInfo | undefined;
|
||||||
private ideProcessInfo: { pid: number; command: string } | undefined;
|
private ideProcessInfo: { pid: number; command: string } | undefined;
|
||||||
|
private connectionConfig:
|
||||||
|
| (ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo })
|
||||||
|
| undefined;
|
||||||
private authToken: string | undefined;
|
private authToken: string | undefined;
|
||||||
private diffResponses = new Map<string, (result: DiffUpdateResult) => void>();
|
private diffResponses = new Map<string, (result: DiffUpdateResult) => void>();
|
||||||
private statusListeners = new Set<(state: IDEConnectionState) => void>();
|
private statusListeners = new Set<(state: IDEConnectionState) => void>();
|
||||||
@@ -106,7 +109,11 @@ export class IdeClient {
|
|||||||
IdeClient.instancePromise = (async () => {
|
IdeClient.instancePromise = (async () => {
|
||||||
const client = new IdeClient();
|
const client = new IdeClient();
|
||||||
client.ideProcessInfo = await getIdeProcessInfo();
|
client.ideProcessInfo = await getIdeProcessInfo();
|
||||||
client.currentIde = detectIde(client.ideProcessInfo);
|
client.connectionConfig = await client.getConnectionConfigFromFile();
|
||||||
|
client.currentIde = detectIde(
|
||||||
|
client.ideProcessInfo,
|
||||||
|
client.connectionConfig?.ideInfo,
|
||||||
|
);
|
||||||
return client;
|
return client;
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
@@ -141,17 +148,16 @@ export class IdeClient {
|
|||||||
|
|
||||||
this.setState(IDEConnectionStatus.Connecting);
|
this.setState(IDEConnectionStatus.Connecting);
|
||||||
|
|
||||||
const configFromFile = await this.getConnectionConfigFromFile();
|
this.connectionConfig = await this.getConnectionConfigFromFile();
|
||||||
if (configFromFile?.authToken) {
|
if (this.connectionConfig?.authToken) {
|
||||||
this.authToken = configFromFile.authToken;
|
this.authToken = this.connectionConfig.authToken;
|
||||||
}
|
}
|
||||||
const workspacePath =
|
const workspacePath =
|
||||||
configFromFile?.workspacePath ??
|
this.connectionConfig?.workspacePath ??
|
||||||
process.env['GEMINI_CLI_IDE_WORKSPACE_PATH'];
|
process.env['GEMINI_CLI_IDE_WORKSPACE_PATH'];
|
||||||
|
|
||||||
const { isValid, error } = IdeClient.validateWorkspacePath(
|
const { isValid, error } = IdeClient.validateWorkspacePath(
|
||||||
workspacePath,
|
workspacePath,
|
||||||
this.currentIde.displayName,
|
|
||||||
process.cwd(),
|
process.cwd(),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -160,18 +166,18 @@ export class IdeClient {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (configFromFile) {
|
if (this.connectionConfig) {
|
||||||
if (configFromFile.port) {
|
if (this.connectionConfig.port) {
|
||||||
const connected = await this.establishHttpConnection(
|
const connected = await this.establishHttpConnection(
|
||||||
configFromFile.port,
|
this.connectionConfig.port,
|
||||||
);
|
);
|
||||||
if (connected) {
|
if (connected) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (configFromFile.stdio) {
|
if (this.connectionConfig.stdio) {
|
||||||
const connected = await this.establishStdioConnection(
|
const connected = await this.establishStdioConnection(
|
||||||
configFromFile.stdio,
|
this.connectionConfig.stdio,
|
||||||
);
|
);
|
||||||
if (connected) {
|
if (connected) {
|
||||||
return;
|
return;
|
||||||
@@ -494,20 +500,19 @@ export class IdeClient {
|
|||||||
|
|
||||||
static validateWorkspacePath(
|
static validateWorkspacePath(
|
||||||
ideWorkspacePath: string | undefined,
|
ideWorkspacePath: string | undefined,
|
||||||
currentIdeDisplayName: string | undefined,
|
|
||||||
cwd: string,
|
cwd: string,
|
||||||
): { isValid: boolean; error?: string } {
|
): { isValid: boolean; error?: string } {
|
||||||
if (ideWorkspacePath === undefined) {
|
if (ideWorkspacePath === undefined) {
|
||||||
return {
|
return {
|
||||||
isValid: false,
|
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 === '') {
|
if (ideWorkspacePath === '') {
|
||||||
return {
|
return {
|
||||||
isValid: false,
|
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) {
|
if (!isWithinWorkspace) {
|
||||||
return {
|
return {
|
||||||
isValid: false,
|
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<
|
private async getConnectionConfigFromFile(): Promise<
|
||||||
(ConnectionConfig & { workspacePath?: string }) | undefined
|
| (ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo })
|
||||||
|
| undefined
|
||||||
> {
|
> {
|
||||||
if (!this.ideProcessInfo) {
|
if (!this.ideProcessInfo) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -594,6 +600,10 @@ export class IdeClient {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!portFiles) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
const fileRegex = new RegExp(
|
const fileRegex = new RegExp(
|
||||||
`^gemini-ide-server-${this.ideProcessInfo.pid}-\\d+\\.json$`,
|
`^gemini-ide-server-${this.ideProcessInfo.pid}-\\d+\\.json$`,
|
||||||
);
|
);
|
||||||
@@ -630,7 +640,6 @@ export class IdeClient {
|
|||||||
}
|
}
|
||||||
const { isValid } = IdeClient.validateWorkspacePath(
|
const { isValid } = IdeClient.validateWorkspacePath(
|
||||||
content.workspacePath,
|
content.workspacePath,
|
||||||
this.currentIde?.displayName,
|
|
||||||
process.cwd(),
|
process.cwd(),
|
||||||
);
|
);
|
||||||
return isValid;
|
return isValid;
|
||||||
|
|||||||
Reference in New Issue
Block a user