From ab9eb9377fdfe7823be8ea0c7c394c5368b28951 Mon Sep 17 00:00:00 2001 From: Shreya Keshive Date: Wed, 16 Jul 2025 18:36:14 -0400 Subject: [PATCH] Add /ide status & /ide install commands to manage IDE integration (#4265) --- .../cli/src/services/CommandService.test.ts | 32 ++- packages/cli/src/services/CommandService.ts | 50 ++-- packages/cli/src/ui/App.test.tsx | 1 + .../cli/src/ui/commands/ideCommand.test.ts | 256 ++++++++++++++++++ packages/cli/src/ui/commands/ideCommand.ts | 165 +++++++++++ .../ui/hooks/slashCommandProcessor.test.ts | 36 ++- .../cli/src/ui/hooks/slashCommandProcessor.ts | 2 +- 7 files changed, 514 insertions(+), 28 deletions(-) create mode 100644 packages/cli/src/ui/commands/ideCommand.test.ts create mode 100644 packages/cli/src/ui/commands/ideCommand.ts diff --git a/packages/cli/src/services/CommandService.test.ts b/packages/cli/src/services/CommandService.test.ts index 5c28228e..d9799146 100644 --- a/packages/cli/src/services/CommandService.test.ts +++ b/packages/cli/src/services/CommandService.test.ts @@ -6,6 +6,7 @@ import { vi, describe, it, expect, beforeEach } from 'vitest'; import { CommandService } from './CommandService.js'; +import { type Config } from '@google/gemini-cli-core'; import { type SlashCommand } from '../ui/commands/types.js'; import { memoryCommand } from '../ui/commands/memoryCommand.js'; import { helpCommand } from '../ui/commands/helpCommand.js'; @@ -17,6 +18,7 @@ import { themeCommand } from '../ui/commands/themeCommand.js'; import { statsCommand } from '../ui/commands/statsCommand.js'; import { privacyCommand } from '../ui/commands/privacyCommand.js'; import { aboutCommand } from '../ui/commands/aboutCommand.js'; +import { ideCommand } from '../ui/commands/ideCommand.js'; import { extensionsCommand } from '../ui/commands/extensionsCommand.js'; import { toolsCommand } from '../ui/commands/toolsCommand.js'; import { compressCommand } from '../ui/commands/compressCommand.js'; @@ -50,6 +52,9 @@ vi.mock('../ui/commands/statsCommand.js', () => ({ vi.mock('../ui/commands/aboutCommand.js', () => ({ aboutCommand: { name: 'about', description: 'Mock About' }, })); +vi.mock('../ui/commands/ideCommand.js', () => ({ + ideCommand: vi.fn(), +})); vi.mock('../ui/commands/extensionsCommand.js', () => ({ extensionsCommand: { name: 'extensions', description: 'Mock Extensions' }, })); @@ -65,12 +70,20 @@ vi.mock('../ui/commands/mcpCommand.js', () => ({ describe('CommandService', () => { const subCommandLen = 14; + let mockConfig: vi.Mocked; + + beforeEach(() => { + mockConfig = { + getIdeMode: vi.fn(), + } as unknown as vi.Mocked; + vi.mocked(ideCommand).mockReturnValue(null); + }); describe('when using default production loader', () => { let commandService: CommandService; beforeEach(() => { - commandService = new CommandService(); + commandService = new CommandService(mockConfig); }); it('should initialize with an empty command tree', () => { @@ -106,6 +119,21 @@ describe('CommandService', () => { expect(commandNames).toContain('tools'); expect(commandNames).toContain('compress'); expect(commandNames).toContain('mcp'); + expect(commandNames).not.toContain('ide'); + }); + + it('should include ide command when ideMode is on', async () => { + mockConfig.getIdeMode.mockReturnValue(true); + vi.mocked(ideCommand).mockReturnValue({ + name: 'ide', + description: 'Mock IDE', + }); + await commandService.loadCommands(); + const tree = commandService.getCommands(); + + expect(tree.length).toBe(subCommandLen + 1); + const commandNames = tree.map((cmd) => cmd.name); + expect(commandNames).toContain('ide'); }); it('should overwrite any existing commands when called again', async () => { @@ -163,7 +191,7 @@ describe('CommandService', () => { const mockLoader = vi.fn().mockResolvedValue(mockCommands); // Act: Instantiate the service WITH the injected loader function. - const commandService = new CommandService(mockLoader); + const commandService = new CommandService(mockConfig, mockLoader); await commandService.loadCommands(); const tree = commandService.getCommands(); diff --git a/packages/cli/src/services/CommandService.ts b/packages/cli/src/services/CommandService.ts index 51fe2ad8..d8604276 100644 --- a/packages/cli/src/services/CommandService.ts +++ b/packages/cli/src/services/CommandService.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { Config } from '@google/gemini-cli-core'; import { SlashCommand } from '../ui/commands/types.js'; import { memoryCommand } from '../ui/commands/memoryCommand.js'; import { helpCommand } from '../ui/commands/helpCommand.js'; @@ -19,29 +20,42 @@ import { aboutCommand } from '../ui/commands/aboutCommand.js'; import { extensionsCommand } from '../ui/commands/extensionsCommand.js'; import { toolsCommand } from '../ui/commands/toolsCommand.js'; import { compressCommand } from '../ui/commands/compressCommand.js'; +import { ideCommand } from '../ui/commands/ideCommand.js'; -const loadBuiltInCommands = async (): Promise => [ - aboutCommand, - authCommand, - chatCommand, - clearCommand, - compressCommand, - docsCommand, - extensionsCommand, - helpCommand, - mcpCommand, - memoryCommand, - privacyCommand, - statsCommand, - themeCommand, - toolsCommand, -]; +const loadBuiltInCommands = async ( + config: Config | null, +): Promise => { + const allCommands = [ + aboutCommand, + authCommand, + chatCommand, + clearCommand, + compressCommand, + docsCommand, + extensionsCommand, + helpCommand, + ideCommand(config), + mcpCommand, + memoryCommand, + privacyCommand, + statsCommand, + themeCommand, + toolsCommand, + ]; + + return allCommands.filter( + (command): command is SlashCommand => command !== null, + ); +}; export class CommandService { private commands: SlashCommand[] = []; constructor( - private commandLoader: () => Promise = loadBuiltInCommands, + private config: Config | null, + private commandLoader: ( + config: Config | null, + ) => Promise = loadBuiltInCommands, ) { // The constructor can be used for dependency injection in the future. } @@ -49,7 +63,7 @@ export class CommandService { async loadCommands(): Promise { // For now, we only load the built-in commands. // File-based and remote commands will be added later. - this.commands = await this.commandLoader(); + this.commands = await this.commandLoader(this.config); } getCommands(): SlashCommand[] { diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index 4b65603b..0c18b042 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -135,6 +135,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { setFlashFallbackHandler: vi.fn(), getSessionId: vi.fn(() => 'test-session-id'), getUserTier: vi.fn().mockResolvedValue(undefined), + getIdeMode: vi.fn(() => false), }; }); return { diff --git a/packages/cli/src/ui/commands/ideCommand.test.ts b/packages/cli/src/ui/commands/ideCommand.test.ts new file mode 100644 index 00000000..9e5f798d --- /dev/null +++ b/packages/cli/src/ui/commands/ideCommand.test.ts @@ -0,0 +1,256 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach, afterEach } 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 { + getMCPDiscoveryState, + getMCPServerStatus, + IDE_SERVER_NAME, + MCPDiscoveryState, + MCPServerStatus, +} from '@google/gemini-cli-core'; + +vi.mock('child_process'); +vi.mock('glob'); +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const original = + await importOriginal(); + return { + ...original, + getMCPServerStatus: vi.fn(), + getMCPDiscoveryState: vi.fn(), + }; +}); + +describe('ideCommand', () => { + let mockContext: CommandContext; + let mockConfig: Config; + let execSyncSpy: vi.SpyInstance; + let globSyncSpy: vi.SpyInstance; + let platformSpy: vi.SpyInstance; + let getMCPServerStatusSpy: vi.SpyInstance; + let getMCPDiscoveryStateSpy: vi.SpyInstance; + + beforeEach(() => { + mockContext = { + ui: { + addItem: vi.fn(), + }, + } as unknown as CommandContext; + + mockConfig = { + getIdeMode: vi.fn(), + } as unknown as Config; + + execSyncSpy = vi.spyOn(child_process, 'execSync'); + globSyncSpy = vi.spyOn(glob, 'sync'); + platformSpy = vi.spyOn(process, 'platform', 'get'); + getMCPServerStatusSpy = vi.mocked(getMCPServerStatus); + getMCPDiscoveryStateSpy = vi.mocked(getMCPDiscoveryState); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return null if ideMode is not enabled', () => { + (mockConfig.getIdeMode as vi.Mock).mockReturnValue(false); + const command = ideCommand(mockConfig); + expect(command).toBeNull(); + }); + + it('should return the ide command if ideMode is enabled', () => { + (mockConfig.getIdeMode as vi.Mock).mockReturnValue(true); + const command = ideCommand(mockConfig); + expect(command).not.toBeNull(); + expect(command?.name).toBe('ide'); + expect(command?.subCommands).toHaveLength(2); + expect(command?.subCommands?.[0].name).toBe('status'); + expect(command?.subCommands?.[1].name).toBe('install'); + }); + + describe('status subcommand', () => { + beforeEach(() => { + (mockConfig.getIdeMode as vi.Mock).mockReturnValue(true); + }); + + it('should show connected status', () => { + getMCPServerStatusSpy.mockReturnValue(MCPServerStatus.CONNECTED); + const command = ideCommand(mockConfig); + const result = command?.subCommands?.[0].action(mockContext, ''); + expect(getMCPServerStatusSpy).toHaveBeenCalledWith(IDE_SERVER_NAME); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: '🟢 Connected', + }); + }); + + it('should show connecting status', () => { + getMCPServerStatusSpy.mockReturnValue(MCPServerStatus.CONNECTING); + const command = ideCommand(mockConfig); + const result = command?.subCommands?.[0].action(mockContext, ''); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: '🔄 Initializing...', + }); + }); + + it('should show discovery in progress status', () => { + getMCPServerStatusSpy.mockReturnValue(MCPServerStatus.DISCONNECTED); + getMCPDiscoveryStateSpy.mockReturnValue(MCPDiscoveryState.IN_PROGRESS); + const command = ideCommand(mockConfig); + const result = command?.subCommands?.[0].action(mockContext, ''); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: '🔄 Initializing...', + }); + }); + + it('should show disconnected status', () => { + getMCPServerStatusSpy.mockReturnValue(MCPServerStatus.DISCONNECTED); + getMCPDiscoveryStateSpy.mockReturnValue(MCPDiscoveryState.NOT_FOUND); + const command = ideCommand(mockConfig); + const result = command?.subCommands?.[0].action(mockContext, ''); + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: '🔴 Disconnected', + }); + }); + }); + + describe('install subcommand', () => { + beforeEach(() => { + (mockConfig.getIdeMode as vi.Mock).mockReturnValue(true); + platformSpy.mockReturnValue('linux'); + }); + + it('should show an error if VSCode is not installed', async () => { + execSyncSpy.mockImplementation(() => { + throw new Error('Command not found'); + }); + + const command = ideCommand(mockConfig); + await command?.subCommands?.[1].action(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + text: expect.stringContaining( + 'VS Code command-line tool "code" 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( + `code --install-extension ${vsixPath} --force`, + { stdio: 'pipe' }, + ); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'info', + text: `Installing VS Code 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( + `code --install-extension ${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.', + }), + 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]); + + const command = ideCommand(mockConfig); + await command?.subCommands?.[1].action(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + text: `Failed to install VS Code companion extension.`, + }), + expect.any(Number), + ); + }); + }); +}); diff --git a/packages/cli/src/ui/commands/ideCommand.ts b/packages/cli/src/ui/commands/ideCommand.ts new file mode 100644 index 00000000..0251e619 --- /dev/null +++ b/packages/cli/src/ui/commands/ideCommand.ts @@ -0,0 +1,165 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { fileURLToPath } from 'url'; +import { + Config, + getMCPDiscoveryState, + getMCPServerStatus, + IDE_SERVER_NAME, + MCPDiscoveryState, + MCPServerStatus, +} from '@google/gemini-cli-core'; +import { + CommandContext, + SlashCommand, + SlashCommandActionReturn, +} 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; + } + + return { + name: 'ide', + description: 'manage IDE integration', + subCommands: [ + { + name: 'status', + description: 'check status of IDE integration', + action: (_context: CommandContext): SlashCommandActionReturn => { + const status = getMCPServerStatus(IDE_SERVER_NAME); + const discoveryState = getMCPDiscoveryState(); + switch (status) { + case MCPServerStatus.CONNECTED: + return { + type: 'message', + messageType: 'info', + content: `🟢 Connected`, + }; + case MCPServerStatus.CONNECTING: + return { + type: 'message', + messageType: 'info', + content: `🔄 Initializing...`, + }; + case MCPServerStatus.DISCONNECTED: + default: + if (discoveryState === MCPDiscoveryState.IN_PROGRESS) { + return { + type: 'message', + messageType: 'info', + content: `🔄 Initializing...`, + }; + } else { + return { + type: 'message', + messageType: 'error', + content: `🔴 Disconnected`, + }; + } + } + }, + }, + { + name: 'install', + description: 'install required VS Code companion extension', + action: async (context) => { + if (!isVSCodeInstalled()) { + context.ui.addItem( + { + type: 'error', + text: `VS Code command-line tool "${VSCODE_COMMAND}" not found in your PATH.`, + }, + 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...`, + }, + 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/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts index 2d7a8ffd..399a923b 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts @@ -153,6 +153,7 @@ describe('useSlashCommandProcessor', () => { getCheckpointingEnabled: vi.fn(() => true), getBugCommand: vi.fn(() => undefined), getSessionId: vi.fn(() => 'test-session-id'), + getIdeMode: vi.fn(() => false), } as unknown as Config; mockCorgiMode = vi.fn(); mockUseSessionStats.mockReturnValue({ @@ -237,7 +238,10 @@ describe('useSlashCommandProcessor', () => { const mockLoader = async () => [newCommand]; // We create the instance outside the mock implementation. - const commandServiceInstance = new ActualCommandService(mockLoader); + const commandServiceInstance = new ActualCommandService( + mockConfig, + mockLoader, + ); // This mock ensures the hook uses our pre-configured instance. vi.mocked(CommandService).mockImplementation( @@ -271,7 +275,10 @@ describe('useSlashCommandProcessor', () => { }); const newCommand: SlashCommand = { name: 'test', action: mockAction }; const mockLoader = async () => [newCommand]; - const commandServiceInstance = new ActualCommandService(mockLoader); + const commandServiceInstance = new ActualCommandService( + mockConfig, + mockLoader, + ); vi.mocked(CommandService).mockImplementation( () => commandServiceInstance, ); @@ -301,7 +308,10 @@ describe('useSlashCommandProcessor', () => { }); const newCommand: SlashCommand = { name: 'test', action: mockAction }; const mockLoader = async () => [newCommand]; - const commandServiceInstance = new ActualCommandService(mockLoader); + const commandServiceInstance = new ActualCommandService( + mockConfig, + mockLoader, + ); vi.mocked(CommandService).mockImplementation( () => commandServiceInstance, ); @@ -333,7 +343,10 @@ describe('useSlashCommandProcessor', () => { }); const newCommand: SlashCommand = { name: 'test', action: mockAction }; const mockLoader = async () => [newCommand]; - const commandServiceInstance = new ActualCommandService(mockLoader); + const commandServiceInstance = new ActualCommandService( + mockConfig, + mockLoader, + ); vi.mocked(CommandService).mockImplementation( () => commandServiceInstance, ); @@ -360,7 +373,10 @@ describe('useSlashCommandProcessor', () => { const newAuthCommand: SlashCommand = { name: 'auth', action: mockAction }; const mockLoader = async () => [newAuthCommand]; - const commandServiceInstance = new ActualCommandService(mockLoader); + const commandServiceInstance = new ActualCommandService( + mockConfig, + mockLoader, + ); vi.mocked(CommandService).mockImplementation( () => commandServiceInstance, ); @@ -386,7 +402,10 @@ describe('useSlashCommandProcessor', () => { }); const newCommand: SlashCommand = { name: 'test', action: mockAction }; const mockLoader = async () => [newCommand]; - const commandServiceInstance = new ActualCommandService(mockLoader); + const commandServiceInstance = new ActualCommandService( + mockConfig, + mockLoader, + ); vi.mocked(CommandService).mockImplementation( () => commandServiceInstance, ); @@ -414,7 +433,10 @@ describe('useSlashCommandProcessor', () => { }; const mockLoader = async () => [parentCommand]; - const commandServiceInstance = new ActualCommandService(mockLoader); + const commandServiceInstance = new ActualCommandService( + mockConfig, + mockLoader, + ); vi.mocked(CommandService).mockImplementation( () => commandServiceInstance, ); diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 24758842..c1c65080 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -182,7 +182,7 @@ export const useSlashCommandProcessor = ( ], ); - const commandService = useMemo(() => new CommandService(), []); + const commandService = useMemo(() => new CommandService(config), [config]); useEffect(() => { const load = async () => {