This commit is contained in:
Shreya Keshive
2026-01-27 17:15:27 -05:00
parent 89337d7d79
commit b55bf440f9
15 changed files with 324 additions and 672 deletions

View File

@@ -389,5 +389,5 @@ See the [Uninstall Guide](docs/cli/uninstall.md) for removal instructions.
---
<p align="center">
Built with ❤️ by Google and the open source community
Built with ❤️ by Google, shreya, and the open source community
</p>

View File

@@ -13,6 +13,8 @@ import {
StartSessionEvent,
logCliConfiguration,
startupProfiler,
type ConnectionConfig,
type IdeInfo,
} from '@google/gemini-cli-core';
import { type LoadedSettings } from '../config/settings.js';
import { performInitialAuth } from './auth.js';
@@ -23,6 +25,9 @@ export interface InitializationResult {
themeError: string | null;
shouldOpenAuthDialog: boolean;
geminiMdFileCount: number;
availableIdeConnections?: Array<
ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo }
>;
}
/**
@@ -52,10 +57,30 @@ export async function initializeApp(
new StartSessionEvent(config, config.getToolRegistry()),
);
let availableIdeConnections:
| Array<ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo }>
| undefined;
if (config.getIdeMode()) {
const ideClient = await IdeClient.getInstance();
await ideClient.connect();
logIdeConnection(config, new IdeConnectionEvent(IdeConnectionType.START));
// Try to auto-connect if possible (legacy behavior or single match)
// We attempt to connect. If it requires selection, we get a list back?
// No, I need to implement the logic here.
const connections = await ideClient.discoverAvailableConnections();
// Heuristic: If we have a PID match, prioritize it.
// Ideally IdeClient.getInstance() already did some detection but didn't connect.
// Actually IdeClient.connect() (without args) tries to find "the one" config.
// If I want to support multiple, I should check here.
if (connections.length > 1) {
// Multiple connections found, let the UI handle selection
availableIdeConnections = connections;
} else {
// 0 or 1 connection, or let connect() handle the "best guess" fallback
await ideClient.connect();
logIdeConnection(config, new IdeConnectionEvent(IdeConnectionType.START));
}
}
return {
@@ -63,5 +88,6 @@ export async function initializeApp(
themeError,
shouldOpenAuthDialog,
geminiMdFileCount: config.getGeminiMdFileCount(),
availableIdeConnections,
};
}

View File

@@ -65,7 +65,12 @@ import {
generateSummary,
type AgentsDiscoveredPayload,
ChangeAuthRequestedError,
IdeConnectionEvent,
IdeConnectionType,
logIdeConnection,
type ConnectionConfig,
} from '@google/gemini-cli-core';
import { IdeConnectionSelector } from './components/IdeConnectionSelector.js';
import { validateAuthMethod } from '../config/auth.js';
import process from 'node:process';
import { useHistory } from './hooks/useHistoryManager.js';
@@ -750,7 +755,40 @@ Logging in with Google... Restarting Gemini CLI to continue.
dispatchExtensionStateUpdate,
addConfirmUpdateExtensionRequest,
setText: (text: string) => buffer.setText(text),
promptIdeConnection: async () => {
const ideClient = await IdeClient.getInstance();
await ideClient.disconnect();
const connections = await ideClient.discoverAvailableConnections();
if (connections.length > 1) {
setAvailableIdeConnections(connections);
} else if (connections.length === 1) {
await ideClient.connect();
logIdeConnection(
config,
new IdeConnectionEvent(IdeConnectionType.START),
);
// Show success message
historyManager.addItem(
{
type: MessageType.INFO,
text: `Connected to IDE: ${connections[0].ideInfo?.displayName || 'Unknown IDE'}`,
},
Date.now(),
);
} else {
// Show error message
historyManager.addItem(
{
type: MessageType.ERROR,
text: 'No IDE connections found.',
},
Date.now(),
);
}
},
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[
setAuthState,
openThemeDialog,
@@ -1965,6 +2003,43 @@ Logging in with Google... Restarting Gemini CLI to continue.
],
);
// ... (existing imports)
// Inside AppContainer function:
// ... (existing state)
const [availableIdeConnections, setAvailableIdeConnections] = useState<
| Array<ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo }>
| undefined
>(initializationResult.availableIdeConnections);
// ... (existing effects/hooks)
if (availableIdeConnections && availableIdeConnections.length > 0) {
return (
<IdeConnectionSelector
connections={availableIdeConnections}
onSelect={async (
conn: ConnectionConfig & {
workspacePath?: string;
ideInfo?: IdeInfo;
},
) => {
const ideClient = await IdeClient.getInstance();
await ideClient.connect({ connectionConfig: conn });
logIdeConnection(
config,
new IdeConnectionEvent(IdeConnectionType.START),
);
setAvailableIdeConnections(undefined);
}}
onCancel={() => {
setAvailableIdeConnections(undefined);
}}
/>
);
}
if (authState === AuthState.AwaitingGoogleLoginRestart) {
return (
<LoginWithGoogleRestartDialog

View File

@@ -136,20 +136,6 @@ async function setIdeModeAndSyncConnection(
export const ideCommand = async (): Promise<SlashCommand> => {
const ideClient = await IdeClient.getInstance();
const currentIDE = ideClient.getCurrentIde();
if (!currentIDE) {
return {
name: 'ide',
description: 'Manage IDE integration',
kind: CommandKind.BUILT_IN,
autoExecute: false,
action: (): SlashCommandActionReturn =>
({
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: Antigravity, VS Code, or VS Code forks.`,
}) as const,
};
}
const ideSlashCommand: SlashCommand = {
name: 'ide',
@@ -181,6 +167,16 @@ export const ideCommand = async (): Promise<SlashCommand> => {
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (context) => {
if (!currentIDE) {
context.ui.addItem(
{
type: 'error',
text: 'No IDE detected. Cannot run installer.',
},
Date.now(),
);
return;
}
const installer = getIdeInstaller(currentIDE);
if (!installer) {
context.ui.addItem(
@@ -297,15 +293,30 @@ export const ideCommand = async (): Promise<SlashCommand> => {
},
};
const switchCommand: SlashCommand = {
name: 'switch',
description: 'Switch to a different IDE connection',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (context: CommandContext) => {
await context.ui.promptIdeConnection();
},
};
const { status } = ideClient.getConnectionStatus();
const isConnected = status === IDEConnectionStatus.Connected;
if (isConnected) {
ideSlashCommand.subCommands = [statusCommand, disableCommand];
ideSlashCommand.subCommands = [
statusCommand,
switchCommand,
disableCommand,
];
} else {
ideSlashCommand.subCommands = [
enableCommand,
statusCommand,
switchCommand,
installCommand,
];
}

View File

@@ -83,7 +83,9 @@ export interface CommandContext {
extensionsUpdateState: Map<string, ExtensionUpdateStatus>;
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
addConfirmUpdateExtensionRequest: (value: ConfirmationRequest) => void;
removeComponent: () => void;
promptIdeConnection: () => Promise<void>;
};
// Session-specific data
session: {

View File

@@ -0,0 +1,74 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import { type ConnectionConfig, type IdeInfo } from '@google/gemini-cli-core';
import {
RadioButtonSelect,
type RadioSelectItem,
} from './shared/RadioButtonSelect.js';
interface IdeConnectionSelectorProps {
connections: Array<
ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo }
>;
onSelect: (
connection: ConnectionConfig & {
workspacePath?: string;
ideInfo?: IdeInfo;
},
) => void;
onCancel: () => void;
}
export const IdeConnectionSelector = ({
connections,
onSelect,
onCancel,
}: IdeConnectionSelectorProps) => {
const items: Array<RadioSelectItem<number>> = connections.map(
(conn, index) => {
const label = `${conn.ideInfo?.displayName || 'Unknown IDE'} (${conn.workspacePath || 'No workspace'})`;
return {
label,
value: index,
key: index.toString(),
};
},
);
// Add an option to skip/cancel
items.push({
label: 'Do not connect to an IDE',
value: -1,
key: 'cancel',
});
return (
<Box
flexDirection="column"
padding={1}
borderStyle="round"
borderColor="cyan"
>
<Text bold color="cyan">
Multiple IDE connections found. Please select one:
</Text>
<Box marginTop={1}>
<RadioButtonSelect
items={items}
onSelect={(value: number) => {
if (value === -1) {
onCancel();
} else {
onSelect(connections[value]);
}
}}
/>
</Box>
</Box>
);
};

View File

@@ -213,7 +213,9 @@ describe('useSlashCommandProcessor', () => {
toggleDebugProfiler: vi.fn(),
dispatchExtensionStateUpdate: vi.fn(),
addConfirmUpdateExtensionRequest: vi.fn(),
setText: vi.fn(),
promptIdeConnection: vi.fn(),
},
new Map(), // extensionsUpdateState
true, // isConfigInitialized

View File

@@ -83,6 +83,7 @@ interface SlashCommandProcessorActions {
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void;
setText: (text: string) => void;
promptIdeConnection: () => Promise<void>;
}
/**
@@ -237,6 +238,7 @@ export const useSlashCommandProcessor = (
addConfirmUpdateExtensionRequest:
actions.addConfirmUpdateExtensionRequest,
removeComponent: () => setCustomDialog(null),
promptIdeConnection: actions.promptIdeConnection,
},
session: {
stats: session.stats,

View File

@@ -29,5 +29,6 @@ export function createNonInteractiveUI(): CommandContext['ui'] {
dispatchExtensionStateUpdate: (_action: ExtensionUpdateAction) => {},
addConfirmUpdateExtensionRequest: (_request) => {},
removeComponent: () => {},
promptIdeConnection: async () => {},
};
}

View File

@@ -13,9 +13,6 @@ beforeEach(() => {
});
describe('detectIde', () => {
const ideProcessInfo = { pid: 123, command: 'some/path/to/code' };
const ideProcessInfoNoCode = { pid: 123, command: 'some/path/to/fork' };
beforeEach(() => {
// Ensure these env vars don't leak from the host environment
vi.stubEnv('ANTIGRAVITY_CLI_ALIAS', '');
@@ -40,147 +37,93 @@ describe('detectIde', () => {
it('should return undefined if TERM_PROGRAM is not vscode', () => {
vi.stubEnv('TERM_PROGRAM', '');
expect(detectIde(ideProcessInfo)).toBeUndefined();
expect(detectIde()).toBeUndefined();
});
it('should detect Devin', () => {
vi.stubEnv('TERM_PROGRAM', 'vscode');
vi.stubEnv('__COG_BASHRC_SOURCED', '1');
expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.devin);
expect(detectIde()).toBe(IDE_DEFINITIONS.devin);
});
it('should detect Replit', () => {
vi.stubEnv('TERM_PROGRAM', 'vscode');
vi.stubEnv('REPLIT_USER', 'testuser');
expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.replit);
expect(detectIde()).toBe(IDE_DEFINITIONS.replit);
});
it('should detect Cursor', () => {
vi.stubEnv('TERM_PROGRAM', 'vscode');
vi.stubEnv('CURSOR_TRACE_ID', 'some-id');
expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.cursor);
expect(detectIde()).toBe(IDE_DEFINITIONS.cursor);
});
it('should detect Codespaces', () => {
vi.stubEnv('TERM_PROGRAM', 'vscode');
vi.stubEnv('CODESPACES', 'true');
vi.stubEnv('CURSOR_TRACE_ID', '');
expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.codespaces);
expect(detectIde()).toBe(IDE_DEFINITIONS.codespaces);
});
it('should detect Cloud Shell via EDITOR_IN_CLOUD_SHELL', () => {
vi.stubEnv('TERM_PROGRAM', 'vscode');
vi.stubEnv('EDITOR_IN_CLOUD_SHELL', 'true');
vi.stubEnv('CURSOR_TRACE_ID', '');
expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.cloudshell);
expect(detectIde()).toBe(IDE_DEFINITIONS.cloudshell);
});
it('should detect Cloud Shell via CLOUD_SHELL', () => {
vi.stubEnv('TERM_PROGRAM', 'vscode');
vi.stubEnv('CLOUD_SHELL', 'true');
vi.stubEnv('CURSOR_TRACE_ID', '');
expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.cloudshell);
expect(detectIde()).toBe(IDE_DEFINITIONS.cloudshell);
});
it('should detect Trae', () => {
vi.stubEnv('TERM_PROGRAM', 'vscode');
vi.stubEnv('TERM_PRODUCT', 'Trae');
vi.stubEnv('CURSOR_TRACE_ID', '');
expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.trae);
expect(detectIde()).toBe(IDE_DEFINITIONS.trae);
});
it('should detect Firebase Studio via MONOSPACE_ENV', () => {
vi.stubEnv('TERM_PROGRAM', 'vscode');
vi.stubEnv('MONOSPACE_ENV', 'true');
vi.stubEnv('CURSOR_TRACE_ID', '');
expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.firebasestudio);
});
it('should detect VSCode when no other IDE is detected and command includes "code"', () => {
vi.stubEnv('TERM_PROGRAM', 'vscode');
vi.stubEnv('MONOSPACE_ENV', '');
vi.stubEnv('CURSOR_TRACE_ID', '');
expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.vscode);
});
it('should detect VSCodeFork when no other IDE is detected and command does not include "code"', () => {
vi.stubEnv('TERM_PROGRAM', 'vscode');
vi.stubEnv('MONOSPACE_ENV', '');
vi.stubEnv('CURSOR_TRACE_ID', '');
expect(detectIde(ideProcessInfoNoCode)).toBe(IDE_DEFINITIONS.vscodefork);
expect(detectIde()).toBe(IDE_DEFINITIONS.firebasestudio);
});
it('should detect AntiGravity', () => {
vi.stubEnv('TERM_PROGRAM', 'vscode');
vi.stubEnv('ANTIGRAVITY_CLI_ALIAS', 'agy');
expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.antigravity);
expect(detectIde()).toBe(IDE_DEFINITIONS.antigravity);
});
it('should detect Sublime Text', () => {
vi.stubEnv('TERM_PROGRAM', 'sublime');
vi.stubEnv('ANTIGRAVITY_CLI_ALIAS', '');
expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.sublimetext);
expect(detectIde()).toBe(IDE_DEFINITIONS.sublimetext);
});
it('should prioritize Antigravity over Sublime Text', () => {
vi.stubEnv('TERM_PROGRAM', 'sublime');
vi.stubEnv('ANTIGRAVITY_CLI_ALIAS', 'agy');
expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.antigravity);
expect(detectIde()).toBe(IDE_DEFINITIONS.antigravity);
});
it('should detect JetBrains IDE via TERMINAL_EMULATOR', () => {
vi.stubEnv('TERMINAL_EMULATOR', 'JetBrains-JediTerm');
expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.jetbrains);
});
describe('JetBrains IDE detection via command', () => {
beforeEach(() => {
vi.stubEnv('TERMINAL_EMULATOR', 'JetBrains-JediTerm');
});
it.each([
[
'IntelliJ IDEA',
'/Applications/IntelliJ IDEA.app',
IDE_DEFINITIONS.intellijidea,
],
['WebStorm', '/Applications/WebStorm.app', IDE_DEFINITIONS.webstorm],
['PyCharm', '/Applications/PyCharm.app', IDE_DEFINITIONS.pycharm],
['GoLand', '/Applications/GoLand.app', IDE_DEFINITIONS.goland],
[
'Android Studio',
'/Applications/Android Studio.app',
IDE_DEFINITIONS.androidstudio,
],
['CLion', '/Applications/CLion.app', IDE_DEFINITIONS.clion],
['RustRover', '/Applications/RustRover.app', IDE_DEFINITIONS.rustrover],
['DataGrip', '/Applications/DataGrip.app', IDE_DEFINITIONS.datagrip],
['PhpStorm', '/Applications/PhpStorm.app', IDE_DEFINITIONS.phpstorm],
])('should detect %s via command', (_name, command, expectedIde) => {
const processInfo = { pid: 123, command };
expect(detectIde(processInfo)).toBe(expectedIde);
});
});
it('should return generic JetBrains when command does not match specific IDE', () => {
vi.stubEnv('TERMINAL_EMULATOR', 'JetBrains-JediTerm');
const genericProcessInfo = {
pid: 123,
command: '/Applications/SomeJetBrainsApp.app',
};
expect(detectIde(genericProcessInfo)).toBe(IDE_DEFINITIONS.jetbrains);
expect(detectIde()).toBe(IDE_DEFINITIONS.jetbrains);
});
it('should prioritize JetBrains detection over VS Code when TERMINAL_EMULATOR is set', () => {
vi.stubEnv('TERM_PROGRAM', 'vscode');
vi.stubEnv('TERMINAL_EMULATOR', 'JetBrains-JediTerm');
expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.jetbrains);
expect(detectIde()).toBe(IDE_DEFINITIONS.jetbrains);
});
});
describe('detectIde with ideInfoFromFile', () => {
const ideProcessInfo = { pid: 123, command: 'some/path/to/code' };
afterEach(() => {
vi.unstubAllEnvs();
});
@@ -205,24 +148,20 @@ describe('detectIde with ideInfoFromFile', () => {
name: 'custom-ide',
displayName: 'Custom IDE',
};
expect(detectIde(ideProcessInfo, ideInfoFromFile)).toEqual(ideInfoFromFile);
expect(detectIde(ideInfoFromFile)).toEqual(ideInfoFromFile);
});
it('should fall back to env detection if name is missing', () => {
const ideInfoFromFile = { displayName: 'Custom IDE' };
vi.stubEnv('TERM_PROGRAM', 'vscode');
vi.stubEnv('CURSOR_TRACE_ID', '');
expect(detectIde(ideProcessInfo, ideInfoFromFile)).toBe(
IDE_DEFINITIONS.vscode,
);
expect(detectIde(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');
vi.stubEnv('CURSOR_TRACE_ID', '');
expect(detectIde(ideProcessInfo, ideInfoFromFile)).toBe(
IDE_DEFINITIONS.vscode,
);
expect(detectIde(ideInfoFromFile)).toBe(IDE_DEFINITIONS.vscode);
});
});

View File

@@ -77,65 +77,10 @@ export function detectIdeFromEnv(): IdeInfo {
return IDE_DEFINITIONS.vscode;
}
function verifyVSCode(
ide: IdeInfo,
ideProcessInfo: {
pid: number;
command: string;
},
): IdeInfo {
if (ide.name !== IDE_DEFINITIONS.vscode.name) {
return ide;
}
if (
!ideProcessInfo.command ||
ideProcessInfo.command.toLowerCase().includes('code')
) {
return IDE_DEFINITIONS.vscode;
}
return IDE_DEFINITIONS.vscodefork;
}
function verifyJetBrains(
ide: IdeInfo,
ideProcessInfo: {
pid: number;
command: string;
},
): IdeInfo {
if (ide.name !== IDE_DEFINITIONS.jetbrains.name || !ideProcessInfo.command) {
return ide;
}
const command = ideProcessInfo.command.toLowerCase();
const jetbrainsProducts: Array<[string, IdeInfo]> = [
['idea', IDE_DEFINITIONS.intellijidea],
['webstorm', IDE_DEFINITIONS.webstorm],
['pycharm', IDE_DEFINITIONS.pycharm],
['goland', IDE_DEFINITIONS.goland],
['studio', IDE_DEFINITIONS.androidstudio],
['clion', IDE_DEFINITIONS.clion],
['rustrover', IDE_DEFINITIONS.rustrover],
['datagrip', IDE_DEFINITIONS.datagrip],
['phpstorm', IDE_DEFINITIONS.phpstorm],
];
for (const [product, ideInfo] of jetbrainsProducts) {
if (command.includes(product)) {
return ideInfo;
}
}
return ide;
}
export function detectIde(
ideProcessInfo: {
pid: number;
command: string;
},
ideInfoFromFile?: { name?: string; displayName?: string },
): IdeInfo | undefined {
export function detectIde(ideInfoFromFile?: {
name?: string;
displayName?: string;
}): IdeInfo | undefined {
if (ideInfoFromFile?.name && ideInfoFromFile.displayName) {
return {
name: ideInfoFromFile.name,
@@ -152,8 +97,5 @@ export function detectIde(
return undefined;
}
const ide = detectIdeFromEnv();
return isJetBrains()
? verifyJetBrains(ide, ideProcessInfo)
: verifyVSCode(ide, ideProcessInfo);
return detectIdeFromEnv();
}

View File

@@ -15,7 +15,6 @@ import {
} from 'vitest';
import { IdeClient, IDEConnectionStatus } from './ide-client.js';
import * as fs from 'node:fs';
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';
@@ -43,7 +42,6 @@ vi.mock('node:fs', async (importOriginal) => {
};
});
vi.mock('./process-utils.js');
vi.mock('@modelcontextprotocol/sdk/client/index.js');
vi.mock('@modelcontextprotocol/sdk/client/streamableHttp.js');
vi.mock('@modelcontextprotocol/sdk/client/stdio.js');
@@ -81,10 +79,6 @@ describe('IdeClient', () => {
// Mock dependencies
vi.spyOn(process, 'cwd').mockReturnValue('/test/workspace/sub-dir');
vi.mocked(detectIde).mockReturnValue(IDE_DEFINITIONS.vscode);
vi.mocked(getIdeProcessInfo).mockResolvedValue({
pid: 12345,
command: 'test-ide',
});
// Mock MCP client and transports
mockClient = {
@@ -118,10 +112,10 @@ describe('IdeClient', () => {
describe('connect', () => {
it('should connect using HTTP when port is provided in config file', async () => {
const config = { port: '8080' };
const config = { port: '8080', workspacePath: '/test/workspace' };
const configPath = path.join(
ideConfigDir,
'gemini-ide-server-12345.json',
'gemini-ide-server-12345-123.json',
);
await fs.promises.writeFile(configPath, JSON.stringify(config), 'utf8');
@@ -139,10 +133,13 @@ describe('IdeClient', () => {
});
it('should connect using stdio when stdio config is provided in file', async () => {
const config = { stdio: { command: 'test-cmd', args: ['--foo'] } };
const config = {
stdio: { command: 'test-cmd', args: ['--foo'] },
workspacePath: '/test/workspace',
};
const configPath = path.join(
ideConfigDir,
'gemini-ide-server-12345.json',
'gemini-ide-server-12345-123.json',
);
await fs.promises.writeFile(configPath, JSON.stringify(config), 'utf8');
@@ -163,10 +160,11 @@ describe('IdeClient', () => {
const config = {
port: '8080',
stdio: { command: 'test-cmd', args: ['--foo'] },
workspacePath: '/test/workspace',
};
const configPath = path.join(
ideConfigDir,
'gemini-ide-server-12345.json',
'gemini-ide-server-12345-123.json',
);
await fs.promises.writeFile(configPath, JSON.stringify(config), 'utf8');
@@ -214,10 +212,10 @@ describe('IdeClient', () => {
});
it('should prioritize file config over environment variables', async () => {
const config = { port: '8080' };
const config = { port: '8080', workspacePath: '/test/workspace' };
const configPath = path.join(
ideConfigDir,
'gemini-ide-server-12345.json',
'gemini-ide-server-12345-123.json',
);
await fs.promises.writeFile(configPath, JSON.stringify(config), 'utf8');
@@ -255,7 +253,7 @@ describe('IdeClient', () => {
const config = { port: '1234', workspacePath: '/test/workspace' };
const configPath = path.join(
ideConfigDir,
'gemini-ide-server-12345.json',
'gemini-ide-server-12345-123.json',
);
await fs.promises.writeFile(configPath, JSON.stringify(config), 'utf8');
@@ -509,10 +507,10 @@ describe('IdeClient', () => {
});
it('should return false if tool discovery fails', async () => {
const config = { port: '8080' };
const config = { port: '8080', workspacePath: '/test/workspace' };
const configPath = path.join(
ideConfigDir,
'gemini-ide-server-12345.json',
'gemini-ide-server-12345-123.json',
);
await fs.promises.writeFile(configPath, JSON.stringify(config), 'utf8');
@@ -528,10 +526,10 @@ describe('IdeClient', () => {
});
it('should return false if diffing tools are not available', async () => {
const config = { port: '8080' };
const config = { port: '8080', workspacePath: '/test/workspace' };
const configPath = path.join(
ideConfigDir,
'gemini-ide-server-12345.json',
'gemini-ide-server-12345-123.json',
);
await fs.promises.writeFile(configPath, JSON.stringify(config), 'utf8');
@@ -549,10 +547,10 @@ describe('IdeClient', () => {
});
it('should return false if only openDiff tool is available', async () => {
const config = { port: '8080' };
const config = { port: '8080', workspacePath: '/test/workspace' };
const configPath = path.join(
ideConfigDir,
'gemini-ide-server-12345.json',
'gemini-ide-server-12345-123.json',
);
await fs.promises.writeFile(configPath, JSON.stringify(config), 'utf8');
@@ -570,10 +568,10 @@ describe('IdeClient', () => {
});
it('should return true if connected and diffing tools are available', async () => {
const config = { port: '8080' };
const config = { port: '8080', workspacePath: '/test/workspace' };
const configPath = path.join(
ideConfigDir,
'gemini-ide-server-12345.json',
'gemini-ide-server-12345-123.json',
);
await fs.promises.writeFile(configPath, JSON.stringify(config), 'utf8');
@@ -842,10 +840,14 @@ describe('IdeClient', () => {
describe('authentication', () => {
it('should connect with an auth token if provided in the discovery file', async () => {
const authToken = 'test-auth-token';
const config = { port: '8080', authToken };
const config = {
port: '8080',
authToken,
workspacePath: '/test/workspace',
};
const configPath = path.join(
ideConfigDir,
'gemini-ide-server-12345.json',
'gemini-ide-server-12345-123.json',
);
await fs.promises.writeFile(configPath, JSON.stringify(config), 'utf8');

View File

@@ -14,7 +14,6 @@ import {
IdeDiffClosedNotificationSchema,
IdeDiffRejectedNotificationSchema,
} from './types.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';
@@ -59,7 +58,7 @@ type StdioConfig = {
args: string[];
};
type ConnectionConfig = {
export type ConnectionConfig = {
port?: string;
authToken?: string;
stdio?: StdioConfig;
@@ -77,7 +76,6 @@ export class IdeClient {
'IDE integration is currently disabled. To enable it, run /ide enable.',
};
private currentIde: IdeInfo | undefined;
private ideProcessInfo: { pid: number; command: string } | undefined;
private connectionConfig:
| (ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo })
| undefined;
@@ -99,12 +97,8 @@ export class IdeClient {
if (!IdeClient.instancePromise) {
IdeClient.instancePromise = (async () => {
const client = new IdeClient();
client.ideProcessInfo = await getIdeProcessInfo();
client.connectionConfig = await client.getConnectionConfigFromFile();
client.currentIde = detectIde(
client.ideProcessInfo,
client.connectionConfig?.ideInfo,
);
client.currentIde = detectIde(client.connectionConfig?.ideInfo);
return client;
})();
}
@@ -127,7 +121,15 @@ export class IdeClient {
this.trustChangeListeners.delete(listener);
}
async connect(options: { logToConsole?: boolean } = {}): Promise<void> {
async connect(
options: {
logToConsole?: boolean;
connectionConfig?: ConnectionConfig & {
workspacePath?: string;
ideInfo?: IdeInfo;
};
} = {},
): Promise<void> {
const logError = options.logToConsole ?? true;
if (!this.currentIde) {
this.setState(
@@ -140,7 +142,12 @@ export class IdeClient {
this.setState(IDEConnectionStatus.Connecting);
this.connectionConfig = await this.getConnectionConfigFromFile();
if (options.connectionConfig) {
this.connectionConfig = options.connectionConfig;
} else {
this.connectionConfig = await this.getConnectionConfigFromFile();
}
this.authToken =
this.connectionConfig?.authToken ??
process.env['GEMINI_CLI_IDE_AUTH_TOKEN'];
@@ -565,104 +572,81 @@ export class IdeClient {
return { command, args };
}
private async getConnectionConfigFromFile(): Promise<
| (ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo })
| undefined
async discoverAvailableConnections(): Promise<
Array<ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo }>
> {
if (!this.ideProcessInfo) {
return undefined;
}
// For backwards compatibility
try {
const portFile = path.join(
os.tmpdir(),
'gemini',
'ide',
`gemini-ide-server-${this.ideProcessInfo.pid}.json`,
);
const portFileContents = await fs.promises.readFile(portFile, 'utf8');
return JSON.parse(portFileContents);
} catch (_) {
// For newer extension versions, the file name matches the pattern
// /^gemini-ide-server-${pid}-\d+\.json$/. If multiple IDE
// windows are open, multiple files matching the pattern are expected to
// exist.
}
const portFileDir = path.join(os.tmpdir(), 'gemini', 'ide');
let portFiles;
try {
portFiles = await fs.promises.readdir(portFileDir);
} catch (e) {
logger.debug('Failed to read IDE connection directory:', e);
return undefined;
return [];
}
if (!portFiles) {
return undefined;
return [];
}
const fileRegex = new RegExp(
`^gemini-ide-server-${this.ideProcessInfo.pid}-\\d+\\.json$`,
);
const matchingFiles = portFiles
.filter((file) => fileRegex.test(file))
.sort();
if (matchingFiles.length === 0) {
return undefined;
}
const fileRegex = /^gemini-ide-server-(\d+)-(\d+)\.json$/;
const allFiles = portFiles.filter((file) => fileRegex.test(file)).sort();
let fileContents: string[];
try {
fileContents = await Promise.all(
matchingFiles.map((file) =>
allFiles.map((file) =>
fs.promises.readFile(path.join(portFileDir, file), 'utf8'),
),
);
} catch (e) {
logger.debug('Failed to read IDE connection config file(s):', e);
return undefined;
}
const parsedContents = fileContents.map((content) => {
try {
return JSON.parse(content);
} catch (e) {
logger.debug('Failed to parse JSON from config file: ', e);
return undefined;
}
});
const validWorkspaces = parsedContents.filter((content) => {
if (!content) {
return false;
}
const { isValid } = IdeClient.validateWorkspacePath(
content.workspacePath,
process.cwd(),
);
return isValid;
});
if (validWorkspaces.length === 0) {
return undefined;
return [];
}
if (validWorkspaces.length === 1) {
return validWorkspaces[0];
}
const configs = fileContents
.map((content) => {
try {
return JSON.parse(content);
} catch (e) {
logger.debug('Failed to parse JSON from config file: ', e);
return undefined;
}
})
.filter((config) => {
if (!config) {
return false;
}
// Basic validation
const { isValid } = IdeClient.validateWorkspacePath(
config.workspacePath,
process.cwd(),
);
return isValid;
});
const portFromEnv = this.getPortFromEnv();
if (portFromEnv) {
const matchingPort = validWorkspaces.find(
(content) => String(content.port) === portFromEnv,
);
if (matchingPort) {
return matchingPort;
return configs;
}
private async getConnectionConfigFromFile(): Promise<
| (ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo })
| undefined
> {
const available = await this.discoverAvailableConnections();
const portEnv = process.env['GEMINI_CLI_IDE_SERVER_PORT'];
if (available.length > 1 && portEnv) {
const match = available.find((c) => c.port?.toString() === portEnv);
if (match) {
return match;
}
}
return validWorkspaces[0];
if (available.length > 0) {
// Return the first available connection if specific port match isn't found
// This maintains backward compatibility with tests expecting a return value.
return available[0];
}
return undefined;
}
private async createProxyAwareFetch(ideServerHost: string) {

View File

@@ -1,182 +0,0 @@
/**
* @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');
// process (1000) -> powershell (900) -> code (800) -> wininit (700) -> root (0)
// Ancestors: [1000, 900, 800, 700]
// Target (great-grandchild of root): 900
const processes = [
{
ProcessId: 1000,
ParentProcessId: 900,
Name: 'node.exe',
CommandLine: 'node.exe',
},
{
ProcessId: 900,
ParentProcessId: 800,
Name: 'powershell.exe',
CommandLine: 'powershell.exe',
},
{
ProcessId: 800,
ParentProcessId: 700,
Name: 'code.exe',
CommandLine: 'code.exe',
},
{
ProcessId: 700,
ParentProcessId: 0,
Name: 'wininit.exe',
CommandLine: 'wininit.exe',
},
];
mockedExec.mockResolvedValueOnce({ stdout: JSON.stringify(processes) });
const result = await getIdeProcessInfo();
expect(result).toEqual({ pid: 900, command: 'powershell.exe' });
expect(mockedExec).toHaveBeenCalledWith(
expect.stringContaining('Get-CimInstance Win32_Process'),
expect.anything(),
);
});
it('should handle short process chains', async () => {
(os.platform as Mock).mockReturnValue('win32');
// process (1000) -> root (0)
const processes = [
{
ProcessId: 1000,
ParentProcessId: 0,
Name: 'node.exe',
CommandLine: 'node.exe',
},
];
mockedExec.mockResolvedValueOnce({ stdout: JSON.stringify(processes) });
const result = await getIdeProcessInfo();
expect(result).toEqual({ pid: 1000, command: 'node.exe' });
});
it('should handle PowerShell failure gracefully', async () => {
(os.platform as Mock).mockReturnValue('win32');
mockedExec.mockRejectedValueOnce(new Error('PowerShell failed'));
// Fallback to getProcessInfo for current PID
mockedExec.mockResolvedValueOnce({ stdout: '' }); // ps command fails on windows
const result = await getIdeProcessInfo();
expect(result).toEqual({ pid: 1000, command: '' });
});
it('should handle malformed JSON output gracefully', async () => {
(os.platform as Mock).mockReturnValue('win32');
mockedExec.mockResolvedValueOnce({ stdout: '{"invalid":json}' });
// Fallback to getProcessInfo for current PID
mockedExec.mockResolvedValueOnce({ stdout: '' });
const result = await getIdeProcessInfo();
expect(result).toEqual({ pid: 1000, command: '' });
});
it('should handle single process output from ConvertTo-Json', async () => {
(os.platform as Mock).mockReturnValue('win32');
const process = {
ProcessId: 1000,
ParentProcessId: 0,
Name: 'node.exe',
CommandLine: 'node.exe',
};
mockedExec.mockResolvedValueOnce({ stdout: JSON.stringify(process) });
const result = await getIdeProcessInfo();
expect(result).toEqual({ pid: 1000, command: 'node.exe' });
});
it('should handle missing process in map during traversal', async () => {
(os.platform as Mock).mockReturnValue('win32');
// process (1000) -> parent (900) -> missing (800)
const processes = [
{
ProcessId: 1000,
ParentProcessId: 900,
Name: 'node.exe',
CommandLine: 'node.exe',
},
{
ProcessId: 900,
ParentProcessId: 800,
Name: 'parent.exe',
CommandLine: 'parent.exe',
},
];
mockedExec.mockResolvedValueOnce({ stdout: JSON.stringify(processes) });
const result = await getIdeProcessInfo();
// Ancestors: [1000, 900]. Length < 3, returns last (900)
expect(result).toEqual({ pid: 900, command: 'parent.exe' });
});
});
});

View File

@@ -1,226 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { exec } from 'node:child_process';
import { promisify } from 'node:util';
import os from 'node:os';
import path from 'node:path';
const execAsync = promisify(exec);
const MAX_TRAVERSAL_DEPTH = 32;
interface ProcessInfo {
pid: number;
parentPid: number;
name: string;
command: string;
}
interface RawProcessInfo {
ProcessId?: number;
ParentProcessId?: number;
Name?: string;
CommandLine?: string;
}
/**
* Fetches the entire process table on Windows.
*/
async function getProcessTableWindows(): Promise<Map<number, ProcessInfo>> {
const processMap = new Map<number, ProcessInfo>();
try {
// Fetch ProcessId, ParentProcessId, Name, and CommandLine for all processes.
const powershellCommand =
'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name,CommandLine | ConvertTo-Json -Compress';
// Increase maxBuffer to handle large process lists (default is 1MB)
const { stdout } = await execAsync(`powershell "${powershellCommand}"`, {
maxBuffer: 10 * 1024 * 1024,
});
if (!stdout.trim()) {
return processMap;
}
let processes: RawProcessInfo | RawProcessInfo[];
try {
processes = JSON.parse(stdout);
} catch (_e) {
return processMap;
}
if (!Array.isArray(processes)) {
processes = [processes];
}
for (const p of processes) {
if (p && typeof p.ProcessId === 'number') {
processMap.set(p.ProcessId, {
pid: p.ProcessId,
parentPid: p.ParentProcessId || 0,
name: p.Name || '',
command: p.CommandLine || '',
});
}
}
} catch (_e) {
// Fallback or error handling if PowerShell fails
}
return processMap;
}
/**
* Fetches the parent process ID, name, and command for a given process ID on Unix.
*
* @param pid The process ID to inspect.
* @returns A promise that resolves to the parent's PID, name, and command.
*/
async function getProcessInfo(pid: number): Promise<{
parentPid: number;
name: string;
command: string;
}> {
try {
const command = `ps -o ppid=,command= -p ${pid}`;
const { stdout } = await execAsync(command);
const trimmedStdout = stdout.trim();
if (!trimmedStdout) {
return { parentPid: 0, name: '', command: '' };
}
const parts = trimmedStdout.split(/\s+/);
const ppidString = parts[0];
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,
command: fullCommand,
};
} catch (_e) {
return { parentPid: 0, name: '', command: '' };
}
}
/**
* 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 PID and command of the IDE process.
*/
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 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 getProcessInfo(parentPid);
if (grandParentPid > 1) {
idePid = grandParentPid;
}
} catch {
// Ignore if getting grandparent fails, we'll just use the parent pid.
}
const { command } = await getProcessInfo(idePid);
return { pid: idePid, command };
}
if (parentPid <= 1) {
break; // Reached the root
}
currentPid = parentPid;
} catch {
// Process in chain died
break;
}
}
const { command } = await getProcessInfo(currentPid);
return { pid: currentPid, command };
}
/**
* Finds the IDE process info on Windows using a snapshot approach.
*/
async function getIdeProcessInfoForWindows(): Promise<{
pid: number;
command: string;
}> {
// Fetch the entire process table in one go.
const processMap = await getProcessTableWindows();
const myPid = process.pid;
const myProc = processMap.get(myPid);
if (!myProc) {
// Fallback: try to get info for current process directly if snapshot fails
const { command } = await getProcessInfo(myPid);
return { pid: myPid, command };
}
// Perform tree traversal in memory.
// Strategy: Find the great-grandchild of the root process (pid 0 or non-existent parent).
const ancestors: ProcessInfo[] = [];
let curr: ProcessInfo | undefined = myProc;
for (let i = 0; i < MAX_TRAVERSAL_DEPTH && curr; i++) {
ancestors.push(curr);
if (curr.parentPid === 0 || !processMap.has(curr.parentPid)) {
break; // Reached root
}
curr = processMap.get(curr.parentPid);
}
if (ancestors.length >= 3) {
const target = ancestors[ancestors.length - 3];
return { pid: target.pid, command: target.command };
} else if (ancestors.length > 0) {
const target = ancestors[ancestors.length - 1];
return { pid: target.pid, command: target.command };
}
return { pid: myPid, command: myProc.command };
}
/**
* 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 and command as a fallback.
*
* @returns A promise that resolves to the PID and command of the IDE process.
*/
export async function getIdeProcessInfo(): Promise<{
pid: number;
command: string;
}> {
const platform = os.platform();
if (platform === 'win32') {
return getIdeProcessInfoForWindows();
}
return getIdeProcessInfoForUnix();
}