From 7bc876654254d9a11d66135735ad10f1066ad213 Mon Sep 17 00:00:00 2001 From: christine betts Date: Wed, 30 Jul 2025 21:26:31 +0000 Subject: [PATCH] Introduce IDE mode installer (#4877) --- packages/cli/src/config/config.test.ts | 81 +-------- packages/cli/src/config/config.ts | 9 +- packages/cli/src/ui/App.tsx | 7 +- .../cli/src/ui/commands/ideCommand.test.ts | 148 +++++----------- packages/cli/src/ui/commands/ideCommand.ts | 105 +++--------- .../ui/components/IDEContextDetailDisplay.tsx | 9 +- .../ui/hooks/slashCommandProcessor.test.ts | 14 +- packages/core/src/config/config.test.ts | 2 + packages/core/src/config/config.ts | 6 +- .../core/src/config/flashFallback.test.ts | 4 + packages/core/src/ide/detect-ide.ts | 25 +++ packages/core/src/ide/ide-client.ts | 40 ++++- packages/core/src/ide/ide-installer.test.ts | 90 ++++++++++ packages/core/src/ide/ide-installer.ts | 162 ++++++++++++++++++ packages/core/src/index.ts | 2 + packages/core/src/telemetry/telemetry.test.ts | 2 + packages/core/src/tools/tool-registry.test.ts | 2 + .../utils/flashFallback.integration.test.ts | 2 + 18 files changed, 433 insertions(+), 277 deletions(-) create mode 100644 packages/core/src/ide/detect-ide.ts create mode 100644 packages/core/src/ide/ide-installer.test.ts create mode 100644 packages/core/src/ide/ide-installer.ts diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 7f47660d..1dd09f4b 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -35,11 +35,13 @@ vi.mock('@google/gemini-cli-core', async () => { ); return { ...actualServer, - IdeClient: vi.fn().mockImplementation(() => ({ - getConnectionStatus: vi.fn(), - initialize: vi.fn(), - shutdown: vi.fn(), - })), + IdeClient: { + getInstance: vi.fn().mockReturnValue({ + getConnectionStatus: vi.fn(), + initialize: vi.fn(), + shutdown: vi.fn(), + }), + }, loadEnvironment: vi.fn(), loadServerHierarchicalMemory: vi.fn( (cwd, debug, fileService, extensionPaths, _maxDirs) => @@ -922,8 +924,6 @@ describe('loadCliConfig ideMode', () => { vi.resetAllMocks(); vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); process.env.GEMINI_API_KEY = 'test-api-key'; - // Explicitly delete TERM_PROGRAM and SANDBOX before each test - delete process.env.TERM_PROGRAM; delete process.env.SANDBOX; delete process.env.GEMINI_CLI_IDE_SERVER_PORT; }); @@ -942,72 +942,7 @@ describe('loadCliConfig ideMode', () => { expect(config.getIdeMode()).toBe(false); }); - it('should be false if --ide-mode is true but TERM_PROGRAM is not vscode', async () => { - process.argv = ['node', 'script.js', '--ide-mode']; - const settings: Settings = {}; - const argv = await parseArguments(); - const config = await loadCliConfig(settings, [], 'test-session', argv); - expect(config.getIdeMode()).toBe(false); - }); - - it('should be false if settings.ideMode is true but TERM_PROGRAM is not vscode', async () => { - process.argv = ['node', 'script.js']; - const argv = await parseArguments(); - const settings: Settings = { ideMode: true }; - const config = await loadCliConfig(settings, [], 'test-session', argv); - expect(config.getIdeMode()).toBe(false); - }); - - it('should be true when --ide-mode is set and TERM_PROGRAM is vscode', async () => { - process.argv = ['node', 'script.js', '--ide-mode']; - const argv = await parseArguments(); - process.env.TERM_PROGRAM = 'vscode'; - process.env.GEMINI_CLI_IDE_SERVER_PORT = '3000'; - const settings: Settings = {}; - const config = await loadCliConfig(settings, [], 'test-session', argv); - expect(config.getIdeMode()).toBe(true); - }); - - it('should be true when settings.ideMode is true and TERM_PROGRAM is vscode', async () => { - process.argv = ['node', 'script.js']; - const argv = await parseArguments(); - process.env.TERM_PROGRAM = 'vscode'; - process.env.GEMINI_CLI_IDE_SERVER_PORT = '3000'; - const settings: Settings = { ideMode: true }; - const config = await loadCliConfig(settings, [], 'test-session', argv); - expect(config.getIdeMode()).toBe(true); - }); - - it('should prioritize --ide-mode (true) over settings (false) when TERM_PROGRAM is vscode', async () => { - process.argv = ['node', 'script.js', '--ide-mode']; - const argv = await parseArguments(); - process.env.TERM_PROGRAM = 'vscode'; - process.env.GEMINI_CLI_IDE_SERVER_PORT = '3000'; - const settings: Settings = { ideMode: false }; - const config = await loadCliConfig(settings, [], 'test-session', argv); - expect(config.getIdeMode()).toBe(true); - }); - - it('should prioritize --no-ide-mode (false) over settings (true) even when TERM_PROGRAM is vscode', async () => { - process.argv = ['node', 'script.js', '--no-ide-mode']; - const argv = await parseArguments(); - process.env.TERM_PROGRAM = 'vscode'; - const settings: Settings = { ideMode: true }; - const config = await loadCliConfig(settings, [], 'test-session', argv); - expect(config.getIdeMode()).toBe(false); - }); - - it('should be false when --ide-mode is true, TERM_PROGRAM is vscode, but SANDBOX is set', async () => { - process.argv = ['node', 'script.js', '--ide-mode']; - const argv = await parseArguments(); - process.env.TERM_PROGRAM = 'vscode'; - process.env.SANDBOX = 'true'; - const settings: Settings = {}; - const config = await loadCliConfig(settings, [], 'test-session', argv); - expect(config.getIdeMode()).toBe(false); - }); - - it('should be false when settings.ideMode is true, TERM_PROGRAM is vscode, but SANDBOX is set', async () => { + it('should be false when settings.ideMode is true, but SANDBOX is set', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(); process.env.TERM_PROGRAM = 'vscode'; diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 1dd8519c..1cc78888 100644 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -269,14 +269,9 @@ export async function loadCliConfig( ); const ideMode = - (argv.ideMode ?? settings.ideMode ?? false) && - process.env.TERM_PROGRAM === 'vscode' && - !process.env.SANDBOX; + (argv.ideMode ?? settings.ideMode ?? false) && !process.env.SANDBOX; - let ideClient: IdeClient | undefined; - if (ideMode) { - ideClient = new IdeClient(); - } + const ideClient = IdeClient.getInstance(ideMode); const allExtensions = annotateActiveExtensions( extensions, diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index 7ac6936c..2e899cc1 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -967,7 +967,12 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { {showIDEContextDetail && ( - + )} {showErrorDetails && ( diff --git a/packages/cli/src/ui/commands/ideCommand.test.ts b/packages/cli/src/ui/commands/ideCommand.test.ts index d1d72466..3c73f52c 100644 --- a/packages/cli/src/ui/commands/ideCommand.test.ts +++ b/packages/cli/src/ui/commands/ideCommand.test.ts @@ -15,24 +15,16 @@ import { } from 'vitest'; import { ideCommand } from './ideCommand.js'; import { type CommandContext } from './types.js'; -import { type Config } from '@google/gemini-cli-core'; -import * as child_process from 'child_process'; -import { glob } from 'glob'; - -import { IDEConnectionStatus } from '@google/gemini-cli-core/index.js'; +import { type Config, DetectedIde } from '@google/gemini-cli-core'; +import * as core from '@google/gemini-cli-core'; vi.mock('child_process'); vi.mock('glob'); - -function regexEscape(value: string) { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} +vi.mock('@google/gemini-cli-core'); describe('ideCommand', () => { let mockContext: CommandContext; let mockConfig: Config; - let execSyncSpy: MockInstance; - let globSyncSpy: MockInstance; let platformSpy: MockInstance; beforeEach(() => { @@ -47,8 +39,6 @@ describe('ideCommand', () => { getIdeClient: vi.fn(), } as unknown as Config; - execSyncSpy = vi.spyOn(child_process, 'execSync'); - globSyncSpy = vi.spyOn(glob, 'sync'); platformSpy = vi.spyOn(process, 'platform', 'get'); }); @@ -64,6 +54,9 @@ describe('ideCommand', () => { it('should return the ide command if ideMode is enabled', () => { vi.mocked(mockConfig.getIdeMode).mockReturnValue(true); + vi.mocked(mockConfig.getIdeClient).mockReturnValue({ + getCurrentIde: () => DetectedIde.VSCode, + } as ReturnType); const command = ideCommand(mockConfig); expect(command).not.toBeNull(); expect(command?.name).toBe('ide'); @@ -78,12 +71,13 @@ describe('ideCommand', () => { vi.mocked(mockConfig.getIdeMode).mockReturnValue(true); vi.mocked(mockConfig.getIdeClient).mockReturnValue({ getConnectionStatus: mockGetConnectionStatus, + getCurrentIde: () => DetectedIde.VSCode, } as ReturnType); }); it('should show connected status', () => { mockGetConnectionStatus.mockReturnValue({ - status: IDEConnectionStatus.Connected, + status: core.IDEConnectionStatus.Connected, }); const command = ideCommand(mockConfig); const result = command!.subCommands![0].action!(mockContext, ''); @@ -97,7 +91,7 @@ describe('ideCommand', () => { it('should show connecting status', () => { mockGetConnectionStatus.mockReturnValue({ - status: IDEConnectionStatus.Connecting, + status: core.IDEConnectionStatus.Connecting, }); const command = ideCommand(mockConfig); const result = command!.subCommands![0].action!(mockContext, ''); @@ -110,7 +104,7 @@ describe('ideCommand', () => { }); it('should show disconnected status', () => { mockGetConnectionStatus.mockReturnValue({ - status: IDEConnectionStatus.Disconnected, + status: core.IDEConnectionStatus.Disconnected, }); const command = ideCommand(mockConfig); const result = command!.subCommands![0].action!(mockContext, ''); @@ -125,7 +119,7 @@ describe('ideCommand', () => { it('should show disconnected status with details', () => { const details = 'Something went wrong'; mockGetConnectionStatus.mockReturnValue({ - status: IDEConnectionStatus.Disconnected, + status: core.IDEConnectionStatus.Disconnected, details, }); const command = ideCommand(mockConfig); @@ -140,128 +134,68 @@ describe('ideCommand', () => { }); describe('install subcommand', () => { + const mockInstall = vi.fn(); beforeEach(() => { vi.mocked(mockConfig.getIdeMode).mockReturnValue(true); + vi.mocked(mockConfig.getIdeClient).mockReturnValue({ + getCurrentIde: () => DetectedIde.VSCode, + } as ReturnType); + vi.mocked(core.getIdeInstaller).mockReturnValue({ + install: mockInstall, + isInstalled: vi.fn(), + }); platformSpy.mockReturnValue('linux'); }); - it('should show an error if VSCode is not installed', async () => { - execSyncSpy.mockImplementation(() => { - throw new Error('Command not found'); + it('should install the extension', async () => { + mockInstall.mockResolvedValue({ + success: true, + message: 'Successfully installed.', }); - const command = ideCommand(mockConfig); - - await command!.subCommands![1].action!(mockContext, ''); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'error', - text: expect.stringMatching(/VS Code command-line tool .* not found/), - }), - expect.any(Number), - ); - }); - - it('should show an error if the VSIX file is not found', async () => { - execSyncSpy.mockReturnValue(''); // VSCode is installed - globSyncSpy.mockReturnValue([]); // No .vsix file found - const command = ideCommand(mockConfig); await command!.subCommands![1].action!(mockContext, ''); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'error', - text: 'Could not find the required VS Code companion extension. Please file a bug via /bug.', - }), - expect.any(Number), - ); - }); - - it('should install the extension if found in the bundle directory', async () => { - const vsixPath = '/path/to/bundle/gemini.vsix'; - execSyncSpy.mockReturnValue(''); // VSCode is installed - globSyncSpy.mockReturnValue([vsixPath]); // Found .vsix file - - const command = ideCommand(mockConfig); - await command!.subCommands![1].action!(mockContext, ''); - - expect(globSyncSpy).toHaveBeenCalledWith( - expect.stringContaining('.vsix'), - ); - expect(execSyncSpy).toHaveBeenCalledWith( - expect.stringMatching( - new RegExp( - `code(.cmd)? --install-extension ${regexEscape(vsixPath)} --force`, - ), - ), - { stdio: 'pipe' }, - ); + expect(core.getIdeInstaller).toHaveBeenCalledWith('vscode'); + expect(mockInstall).toHaveBeenCalled(); expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: 'info', - text: `Installing VS Code companion extension...`, + text: `Installing IDE companion extension...`, }), expect.any(Number), ); expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: 'info', - text: 'VS Code companion extension installed successfully. Restart gemini-cli in a fresh terminal window.', - }), - expect.any(Number), - ); - }); - - it('should install the extension if found in the dev directory', async () => { - const vsixPath = '/path/to/dev/gemini.vsix'; - execSyncSpy.mockReturnValue(''); // VSCode is installed - // First glob call for bundle returns nothing, second for dev returns path. - globSyncSpy.mockReturnValueOnce([]).mockReturnValueOnce([vsixPath]); - - const command = ideCommand(mockConfig); - await command!.subCommands![1].action!(mockContext, ''); - - expect(globSyncSpy).toHaveBeenCalledTimes(2); - expect(execSyncSpy).toHaveBeenCalledWith( - expect.stringMatching( - new RegExp( - `code(.cmd)? --install-extension ${regexEscape(vsixPath)} --force`, - ), - ), - { stdio: 'pipe' }, - ); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'info', - text: 'VS Code companion extension installed successfully. Restart gemini-cli in a fresh terminal window.', + text: 'Successfully installed.', }), expect.any(Number), ); }); it('should show an error if installation fails', async () => { - const vsixPath = '/path/to/bundle/gemini.vsix'; - const errorMessage = 'Installation failed'; - execSyncSpy - .mockReturnValueOnce('') // VSCode is installed check - .mockImplementation(() => { - // Installation command - const error: Error & { stderr?: Buffer } = new Error( - 'Command failed', - ); - error.stderr = Buffer.from(errorMessage); - throw error; - }); - globSyncSpy.mockReturnValue([vsixPath]); + mockInstall.mockResolvedValue({ + success: false, + message: 'Installation failed.', + }); const command = ideCommand(mockConfig); await command!.subCommands![1].action!(mockContext, ''); + expect(core.getIdeInstaller).toHaveBeenCalledWith('vscode'); + expect(mockInstall).toHaveBeenCalled(); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'info', + text: `Installing IDE companion extension...`, + }), + expect.any(Number), + ); expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: 'error', - text: `Failed to install VS Code companion extension.`, + text: 'Installation failed.', }), expect.any(Number), ); diff --git a/packages/cli/src/ui/commands/ideCommand.ts b/packages/cli/src/ui/commands/ideCommand.ts index 31f2371f..26b0f57d 100644 --- a/packages/cli/src/ui/commands/ideCommand.ts +++ b/packages/cli/src/ui/commands/ideCommand.ts @@ -4,40 +4,29 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { fileURLToPath } from 'url'; -import { Config, IDEConnectionStatus } from '@google/gemini-cli-core'; +import { + Config, + getIdeDisplayName, + getIdeInstaller, + IDEConnectionStatus, +} from '@google/gemini-cli-core'; import { CommandContext, SlashCommand, SlashCommandActionReturn, CommandKind, } from './types.js'; -import * as child_process from 'child_process'; -import * as process from 'process'; -import { glob } from 'glob'; -import * as path from 'path'; - -const VSCODE_COMMAND = process.platform === 'win32' ? 'code.cmd' : 'code'; -const VSCODE_COMPANION_EXTENSION_FOLDER = 'vscode-ide-companion'; - -function isVSCodeInstalled(): boolean { - try { - child_process.execSync( - process.platform === 'win32' - ? `where.exe ${VSCODE_COMMAND}` - : `command -v ${VSCODE_COMMAND}`, - { stdio: 'ignore' }, - ); - return true; - } catch { - return false; - } -} export const ideCommand = (config: Config | null): SlashCommand | null => { if (!config?.getIdeMode()) { return null; } + const currentIDE = config.getIdeClient().getCurrentIde(); + if (!currentIDE) { + throw new Error( + 'IDE slash command should not be available if not running in an IDE', + ); + } return { name: 'ide', @@ -49,7 +38,7 @@ export const ideCommand = (config: Config | null): SlashCommand | null => { description: 'check status of IDE integration', kind: CommandKind.BUILT_IN, action: (_context: CommandContext): SlashCommandActionReturn => { - const connection = config.getIdeClient()?.getConnectionStatus(); + const connection = config.getIdeClient().getConnectionStatus(); switch (connection?.status) { case IDEConnectionStatus.Connected: return { @@ -79,77 +68,37 @@ export const ideCommand = (config: Config | null): SlashCommand | null => { }, { name: 'install', - description: 'install required VS Code companion extension', + description: `install required IDE companion ${getIdeDisplayName(currentIDE)} extension `, kind: CommandKind.BUILT_IN, action: async (context) => { - if (!isVSCodeInstalled()) { + const installer = getIdeInstaller(currentIDE); + if (!installer) { context.ui.addItem( { type: 'error', - text: `VS Code command-line tool "${VSCODE_COMMAND}" not found in your PATH.`, + text: 'No installer available for your configured IDE.', }, Date.now(), ); return; } - const bundleDir = path.dirname(fileURLToPath(import.meta.url)); - // The VSIX file is copied to the bundle directory as part of the build. - let vsixFiles = glob.sync(path.join(bundleDir, '*.vsix')); - if (vsixFiles.length === 0) { - // If the VSIX file is not in the bundle, it might be a dev - // environment running with `npm start`. Look for it in the original - // package location, relative to the bundle dir. - const devPath = path.join( - bundleDir, - '..', - '..', - '..', - '..', - '..', - VSCODE_COMPANION_EXTENSION_FOLDER, - '*.vsix', - ); - vsixFiles = glob.sync(devPath); - } - if (vsixFiles.length === 0) { - context.ui.addItem( - { - type: 'error', - text: 'Could not find the required VS Code companion extension. Please file a bug via /bug.', - }, - Date.now(), - ); - return; - } - - const vsixPath = vsixFiles[0]; - const command = `${VSCODE_COMMAND} --install-extension ${vsixPath} --force`; context.ui.addItem( { type: 'info', - text: `Installing VS Code companion extension...`, + text: `Installing IDE companion extension...`, + }, + Date.now(), + ); + + const result = await installer.install(); + context.ui.addItem( + { + type: result.success ? 'info' : 'error', + text: result.message, }, Date.now(), ); - try { - child_process.execSync(command, { stdio: 'pipe' }); - context.ui.addItem( - { - type: 'info', - text: 'VS Code companion extension installed successfully. Restart gemini-cli in a fresh terminal window.', - }, - Date.now(), - ); - } catch (_error) { - context.ui.addItem( - { - type: 'error', - text: `Failed to install VS Code companion extension.`, - }, - Date.now(), - ); - } }, }, ], diff --git a/packages/cli/src/ui/components/IDEContextDetailDisplay.tsx b/packages/cli/src/ui/components/IDEContextDetailDisplay.tsx index f535c40a..a1739227 100644 --- a/packages/cli/src/ui/components/IDEContextDetailDisplay.tsx +++ b/packages/cli/src/ui/components/IDEContextDetailDisplay.tsx @@ -4,17 +4,19 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Box, Text } from 'ink'; import { type File, type IdeContext } from '@google/gemini-cli-core'; -import { Colors } from '../colors.js'; +import { Box, Text } from 'ink'; import path from 'node:path'; +import { Colors } from '../colors.js'; interface IDEContextDetailDisplayProps { ideContext: IdeContext | undefined; + detectedIdeDisplay: string | undefined; } export function IDEContextDetailDisplay({ ideContext, + detectedIdeDisplay, }: IDEContextDetailDisplayProps) { const openFiles = ideContext?.workspaceState?.openFiles; if (!openFiles || openFiles.length === 0) { @@ -30,7 +32,8 @@ export function IDEContextDetailDisplay({ paddingX={1} > - IDE Context (ctrl+e to toggle) + {detectedIdeDisplay ? detectedIdeDisplay : 'IDE'} Context (ctrl+e to + toggle) {openFiles.length > 0 && ( diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts index 30a14815..2dc206d7 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts @@ -16,6 +16,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { ...original, logSlashCommand, SlashCommandEvent, + getIdeInstaller: vi.fn().mockReturnValue(null), }; }); @@ -23,11 +24,16 @@ const { mockProcessExit } = vi.hoisted(() => ({ mockProcessExit: vi.fn((_code?: number): never => undefined as never), })); -vi.mock('node:process', () => ({ - default: { +vi.mock('node:process', () => { + const mockProcess = { exit: mockProcessExit, - }, -})); + platform: 'test-platform', + }; + return { + ...mockProcess, + default: mockProcess, + }; +}); const mockBuiltinLoadCommands = vi.fn(); vi.mock('../../services/BuiltinCommandLoader.js', () => ({ diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index dcc81b4f..165d2882 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -18,6 +18,7 @@ import { } from '../core/contentGenerator.js'; import { GeminiClient } from '../core/client.js'; import { GitService } from '../services/gitService.js'; +import { IdeClient } from '../ide/ide-client.js'; vi.mock('fs', async (importOriginal) => { const actual = await importOriginal(); @@ -119,6 +120,7 @@ describe('Server Config (config.ts)', () => { telemetry: TELEMETRY_SETTINGS, sessionId: SESSION_ID, model: MODEL, + ideClient: IdeClient.getInstance(false), }; beforeEach(() => { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index d8bce341..e62e2962 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -185,7 +185,7 @@ export interface ConfigParameters { noBrowser?: boolean; summarizeToolOutput?: Record; ideMode?: boolean; - ideClient?: IdeClient; + ideClient: IdeClient; } export class Config { @@ -229,7 +229,7 @@ export class Config { private readonly extensionContextFilePaths: string[]; private readonly noBrowser: boolean; private readonly ideMode: boolean; - private readonly ideClient: IdeClient | undefined; + private readonly ideClient: IdeClient; private inFallbackMode = false; private readonly maxSessionTurns: number; private readonly listExtensions: boolean; @@ -593,7 +593,7 @@ export class Config { return this.ideMode; } - getIdeClient(): IdeClient | undefined { + getIdeClient(): IdeClient { return this.ideClient; } diff --git a/packages/core/src/config/flashFallback.test.ts b/packages/core/src/config/flashFallback.test.ts index a0034ea1..0b68f993 100644 --- a/packages/core/src/config/flashFallback.test.ts +++ b/packages/core/src/config/flashFallback.test.ts @@ -7,6 +7,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { Config } from './config.js'; import { DEFAULT_GEMINI_MODEL, DEFAULT_GEMINI_FLASH_MODEL } from './models.js'; +import { IdeClient } from '../ide/ide-client.js'; import fs from 'node:fs'; vi.mock('node:fs'); @@ -25,6 +26,7 @@ describe('Flash Model Fallback Configuration', () => { debugMode: false, cwd: '/test', model: DEFAULT_GEMINI_MODEL, + ideClient: IdeClient.getInstance(false), }); // Initialize contentGeneratorConfig for testing @@ -49,6 +51,7 @@ describe('Flash Model Fallback Configuration', () => { debugMode: false, cwd: '/test', model: DEFAULT_GEMINI_MODEL, + ideClient: IdeClient.getInstance(false), }); // Should not crash when contentGeneratorConfig is undefined @@ -72,6 +75,7 @@ describe('Flash Model Fallback Configuration', () => { debugMode: false, cwd: '/test', model: 'custom-model', + ideClient: IdeClient.getInstance(false), }); expect(newConfig.getModel()).toBe('custom-model'); diff --git a/packages/core/src/ide/detect-ide.ts b/packages/core/src/ide/detect-ide.ts new file mode 100644 index 00000000..ae46789e --- /dev/null +++ b/packages/core/src/ide/detect-ide.ts @@ -0,0 +1,25 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export enum DetectedIde { + VSCode = 'vscode', +} + +export function getIdeDisplayName(ide: DetectedIde): string { + switch (ide) { + case DetectedIde.VSCode: + return 'VSCode'; + default: + throw new Error(`Unsupported IDE: ${ide}`); + } +} + +export function detectIde(): DetectedIde | undefined { + if (process.env.TERM_PROGRAM === 'vscode') { + return DetectedIde.VSCode; + } + return undefined; +} diff --git a/packages/core/src/ide/ide-client.ts b/packages/core/src/ide/ide-client.ts index 3c670d54..4dd720dd 100644 --- a/packages/core/src/ide/ide-client.ts +++ b/packages/core/src/ide/ide-client.ts @@ -4,6 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { + detectIde, + DetectedIde, + getIdeDisplayName, +} from '../ide/detect-ide.js'; import { ideContext, IdeContextNotificationSchema } from '../ide/ideContext.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; @@ -32,13 +37,34 @@ export class IdeClient { private state: IDEConnectionState = { status: IDEConnectionStatus.Disconnected, }; + private static instance: IdeClient; + private readonly currentIde: DetectedIde | undefined; + private readonly currentIdeDisplayName: string | undefined; - constructor() { + private constructor(ideMode: boolean) { + if (!ideMode) { + return; + } + this.currentIde = detectIde(); + if (this.currentIde) { + this.currentIdeDisplayName = getIdeDisplayName(this.currentIde); + } this.init().catch((err) => { logger.debug('Failed to initialize IdeClient:', err); }); } + static getInstance(ideMode: boolean): IdeClient { + if (!IdeClient.instance) { + IdeClient.instance = new IdeClient(ideMode); + } + return IdeClient.instance; + } + + getCurrentIde(): DetectedIde | undefined { + return this.currentIde; + } + getConnectionStatus(): IDEConnectionState { return this.state; } @@ -141,6 +167,14 @@ export class IdeClient { if (this.state.status === IDEConnectionStatus.Connected) { return; } + if (!this.currentIde) { + this.setState( + IDEConnectionStatus.Disconnected, + 'Not running in a supported IDE, skipping connection.', + ); + return; + } + this.setState(IDEConnectionStatus.Connecting); if (!this.validateWorkspacePath()) { @@ -154,4 +188,8 @@ export class IdeClient { await this.establishConnection(port); } + + getDetectedIdeDisplayName(): string | undefined { + return this.currentIdeDisplayName; + } } diff --git a/packages/core/src/ide/ide-installer.test.ts b/packages/core/src/ide/ide-installer.test.ts new file mode 100644 index 00000000..83459d6b --- /dev/null +++ b/packages/core/src/ide/ide-installer.test.ts @@ -0,0 +1,90 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { getIdeInstaller, IdeInstaller } from './ide-installer.js'; +import * as child_process from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import { DetectedIde } from './detect-ide.js'; + +vi.mock('child_process'); +vi.mock('fs'); +vi.mock('os'); + +describe('ide-installer', () => { + describe('getIdeInstaller', () => { + it('should return a VsCodeInstaller for "vscode"', () => { + const installer = getIdeInstaller(DetectedIde.VSCode); + expect(installer).not.toBeNull(); + // A more specific check might be needed if we export the class + expect(installer).toBeInstanceOf(Object); + }); + + it('should return null for an unknown IDE', () => { + const installer = getIdeInstaller('unknown' as DetectedIde); + expect(installer).toBeNull(); + }); + }); + + describe('VsCodeInstaller', () => { + let installer: IdeInstaller; + + beforeEach(() => { + // We get a new installer for each test to reset the find command logic + installer = getIdeInstaller(DetectedIde.VSCode)!; + vi.spyOn(child_process, 'execSync').mockImplementation(() => ''); + vi.spyOn(fs, 'existsSync').mockReturnValue(false); + vi.spyOn(os, 'homedir').mockReturnValue('/home/user'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('isInstalled', () => { + it('should return true if command is in PATH', async () => { + expect(await installer.isInstalled()).toBe(true); + }); + + it('should return true if command is in a known location', async () => { + vi.spyOn(child_process, 'execSync').mockImplementation(() => { + throw new Error('Command not found'); + }); + vi.spyOn(fs, 'existsSync').mockReturnValue(true); + // Re-create the installer so it re-runs findVsCodeCommand + installer = getIdeInstaller(DetectedIde.VSCode)!; + expect(await installer.isInstalled()).toBe(true); + }); + + it('should return false if command is not found', async () => { + vi.spyOn(child_process, 'execSync').mockImplementation(() => { + throw new Error('Command not found'); + }); + vi.spyOn(fs, 'existsSync').mockReturnValue(false); + // Re-create the installer so it re-runs findVsCodeCommand + installer = getIdeInstaller(DetectedIde.VSCode)!; + expect(await installer.isInstalled()).toBe(false); + }); + }); + + describe('install', () => { + it('should return a failure message if VS Code is not installed', async () => { + vi.spyOn(child_process, 'execSync').mockImplementation(() => { + throw new Error('Command not found'); + }); + vi.spyOn(fs, 'existsSync').mockReturnValue(false); + // Re-create the installer so it re-runs findVsCodeCommand + installer = getIdeInstaller(DetectedIde.VSCode)!; + const result = await installer.install(); + expect(result.success).toBe(false); + expect(result.message).toContain( + 'not found in your PATH or common installation locations', + ); + }); + }); + }); +}); diff --git a/packages/core/src/ide/ide-installer.ts b/packages/core/src/ide/ide-installer.ts new file mode 100644 index 00000000..725f4f7c --- /dev/null +++ b/packages/core/src/ide/ide-installer.ts @@ -0,0 +1,162 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as child_process from 'child_process'; +import * as process from 'process'; +import { glob } from 'glob'; +import * as path from 'path'; +import * as fs from 'fs'; +import * as os from 'os'; +import { fileURLToPath } from 'url'; +import { DetectedIde } from './detect-ide.js'; + +const VSCODE_COMMAND = process.platform === 'win32' ? 'code.cmd' : 'code'; +const VSCODE_COMPANION_EXTENSION_FOLDER = 'vscode-ide-companion'; + +export interface IdeInstaller { + install(): Promise; + isInstalled(): Promise; +} + +export interface InstallResult { + success: boolean; + message: string; +} + +async function findVsCodeCommand(): Promise { + // 1. Check PATH first. + try { + child_process.execSync( + process.platform === 'win32' + ? `where.exe ${VSCODE_COMMAND}` + : `command -v ${VSCODE_COMMAND}`, + { stdio: 'ignore' }, + ); + return VSCODE_COMMAND; + } catch { + // Not in PATH, continue to check common locations. + } + + // 2. Check common installation locations. + const locations: string[] = []; + const platform = process.platform; + const homeDir = os.homedir(); + + if (platform === 'darwin') { + // macOS + locations.push( + '/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code', + path.join(homeDir, 'Library/Application Support/Code/bin/code'), + ); + } else if (platform === 'linux') { + // Linux + locations.push( + '/usr/share/code/bin/code', + '/snap/bin/code', + path.join(homeDir, '.local/share/code/bin/code'), + ); + } else if (platform === 'win32') { + // Windows + locations.push( + path.join( + process.env.ProgramFiles || 'C:\\Program Files', + 'Microsoft VS Code', + 'bin', + 'code.cmd', + ), + path.join( + homeDir, + 'AppData', + 'Local', + 'Programs', + 'Microsoft VS Code', + 'bin', + 'code.cmd', + ), + ); + } + + for (const location of locations) { + if (fs.existsSync(location)) { + return location; + } + } + + return null; +} + +class VsCodeInstaller implements IdeInstaller { + private vsCodeCommand: Promise; + + constructor() { + this.vsCodeCommand = findVsCodeCommand(); + } + + async isInstalled(): Promise { + return (await this.vsCodeCommand) !== null; + } + + async install(): Promise { + const commandPath = await this.vsCodeCommand; + if (!commandPath) { + return { + success: false, + message: `VS Code command-line tool not found in your PATH or common installation locations.`, + }; + } + + const bundleDir = path.dirname(fileURLToPath(import.meta.url)); + // The VSIX file is copied to the bundle directory as part of the build. + let vsixFiles = glob.sync(path.join(bundleDir, '*.vsix')); + if (vsixFiles.length === 0) { + // If the VSIX file is not in the bundle, it might be a dev + // environment running with `npm start`. Look for it in the original + // package location, relative to the bundle dir. + const devPath = path.join( + bundleDir, // .../packages/core/dist/src/ide + '..', // .../packages/core/dist/src + '..', // .../packages/core/dist + '..', // .../packages/core + '..', // .../packages + VSCODE_COMPANION_EXTENSION_FOLDER, + '*.vsix', + ); + vsixFiles = glob.sync(devPath); + } + if (vsixFiles.length === 0) { + return { + success: false, + message: + 'Could not find the required VS Code companion extension. Please file a bug via /bug.', + }; + } + + const vsixPath = vsixFiles[0]; + const command = `"${commandPath}" --install-extension "${vsixPath}" --force`; + try { + child_process.execSync(command, { stdio: 'pipe' }); + return { + success: true, + message: + 'VS Code companion extension installed successfully. Restart gemini-cli in a fresh terminal window.', + }; + } catch (_error) { + return { + success: false, + message: 'Failed to install VS Code companion extension.', + }; + } + } +} + +export function getIdeInstaller(ide: DetectedIde): IdeInstaller | null { + switch (ide) { + case 'vscode': + return new VsCodeInstaller(); + default: + return null; + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ecc408fe..93862c12 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -48,6 +48,8 @@ export * from './services/gitService.js'; // Export IDE specific logic export * from './ide/ide-client.js'; export * from './ide/ideContext.js'; +export * from './ide/ide-installer.js'; +export { getIdeDisplayName, DetectedIde } from './ide/detect-ide.js'; // Export Shell Execution Service export * from './services/shellExecutionService.js'; diff --git a/packages/core/src/telemetry/telemetry.test.ts b/packages/core/src/telemetry/telemetry.test.ts index 9734e382..8ebb3d9a 100644 --- a/packages/core/src/telemetry/telemetry.test.ts +++ b/packages/core/src/telemetry/telemetry.test.ts @@ -12,6 +12,7 @@ import { } from './sdk.js'; import { Config } from '../config/config.js'; import { NodeSDK } from '@opentelemetry/sdk-node'; +import { IdeClient } from '../ide/ide-client.js'; vi.mock('@opentelemetry/sdk-node'); vi.mock('../config/config.js'); @@ -29,6 +30,7 @@ describe('telemetry', () => { targetDir: '/test/dir', debugMode: false, cwd: '/test/dir', + ideClient: IdeClient.getInstance(false), }); vi.spyOn(mockConfig, 'getTelemetryEnabled').mockReturnValue(true); vi.spyOn(mockConfig, 'getTelemetryOtlpEndpoint').mockReturnValue( diff --git a/packages/core/src/tools/tool-registry.test.ts b/packages/core/src/tools/tool-registry.test.ts index 27a7c28b..de7c6309 100644 --- a/packages/core/src/tools/tool-registry.test.ts +++ b/packages/core/src/tools/tool-registry.test.ts @@ -30,6 +30,7 @@ import { Schema, } from '@google/genai'; import { spawn } from 'node:child_process'; +import { IdeClient } from '../ide/ide-client.js'; import fs from 'node:fs'; vi.mock('node:fs'); @@ -139,6 +140,7 @@ const baseConfigParams: ConfigParameters = { geminiMdFileCount: 0, approvalMode: ApprovalMode.DEFAULT, sessionId: 'test-session-id', + ideClient: IdeClient.getInstance(false), }; describe('ToolRegistry', () => { diff --git a/packages/core/src/utils/flashFallback.integration.test.ts b/packages/core/src/utils/flashFallback.integration.test.ts index 9211ad2f..7f18b24f 100644 --- a/packages/core/src/utils/flashFallback.integration.test.ts +++ b/packages/core/src/utils/flashFallback.integration.test.ts @@ -17,6 +17,7 @@ import { import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js'; import { retryWithBackoff } from './retry.js'; import { AuthType } from '../core/contentGenerator.js'; +import { IdeClient } from '../ide/ide-client.js'; vi.mock('node:fs'); @@ -34,6 +35,7 @@ describe('Flash Fallback Integration', () => { debugMode: false, cwd: '/test', model: 'gemini-2.5-pro', + ideClient: IdeClient.getInstance(false), }); // Reset simulation state for each test