diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index cba5824da4..59147c210f 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -36,6 +36,7 @@ import { type HookDefinition, type HookEventName, type OutputFormat, + coreEvents, GEMINI_MODEL_ALIAS_AUTO, } from '@google/gemini-cli-core'; import { @@ -47,7 +48,6 @@ import { import { loadSandboxConfig } from './sandboxConfig.js'; import { resolvePath } from '../utils/resolvePath.js'; -import { appEvents } from '../utils/events.js'; import { RESUME_LATEST } from '../utils/sessionUtils.js'; import { isWorkspaceTrusted } from './trustedFolders.js'; @@ -467,7 +467,7 @@ export async function loadCliConfig( requestSetting: promptForSetting, workspaceDir: cwd, enabledExtensionOverrides: argv.extensions, - eventEmitter: appEvents as EventEmitter, + eventEmitter: coreEvents as EventEmitter, clientVersion: await getVersion(), }); await extensionManager.loadExtensions(); @@ -772,7 +772,7 @@ export async function loadCliConfig( truncateToolOutputThreshold: settings.tools?.truncateToolOutputThreshold, truncateToolOutputLines: settings.tools?.truncateToolOutputLines, enableToolOutputTruncation: settings.tools?.enableToolOutputTruncation, - eventEmitter: appEvents, + eventEmitter: coreEvents, useWriteTodos: argv.useWriteTodos ?? settings.useWriteTodos, output: { format: (argv.outputFormat ?? settings.output?.format) as OutputFormat, diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index 7b12f864b3..e8fd45ed2e 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -85,6 +85,7 @@ vi.mock('./services/CommandService.js', () => ({ vi.mock('./services/FileCommandLoader.js'); vi.mock('./services/McpPromptLoader.js'); +vi.mock('./services/BuiltinCommandLoader.js'); describe('runNonInteractive', () => { let mockConfig: Config; @@ -1184,7 +1185,9 @@ describe('runNonInteractive', () => { './services/FileCommandLoader.js' ); const { McpPromptLoader } = await import('./services/McpPromptLoader.js'); - + const { BuiltinCommandLoader } = await import( + './services/BuiltinCommandLoader.js' + ); mockGetCommands.mockReturnValue([]); // No commands found, so it will fall through const events: ServerGeminiStreamEvent[] = [ { type: GeminiEventType.Content, value: 'Acknowledged' }, @@ -1209,13 +1212,17 @@ describe('runNonInteractive', () => { expect(FileCommandLoader).toHaveBeenCalledWith(mockConfig); expect(McpPromptLoader).toHaveBeenCalledTimes(1); expect(McpPromptLoader).toHaveBeenCalledWith(mockConfig); + expect(BuiltinCommandLoader).toHaveBeenCalledWith(mockConfig); // Check that instances were passed to CommandService.create expect(mockCommandServiceCreate).toHaveBeenCalledTimes(1); const loadersArg = mockCommandServiceCreate.mock.calls[0][0]; - expect(loadersArg).toHaveLength(2); - expect(loadersArg[0]).toBe(vi.mocked(McpPromptLoader).mock.instances[0]); - expect(loadersArg[1]).toBe(vi.mocked(FileCommandLoader).mock.instances[0]); + expect(loadersArg).toHaveLength(3); + expect(loadersArg[0]).toBe( + vi.mocked(BuiltinCommandLoader).mock.instances[0], + ); + expect(loadersArg[1]).toBe(vi.mocked(McpPromptLoader).mock.instances[0]); + expect(loadersArg[2]).toBe(vi.mocked(FileCommandLoader).mock.instances[0]); }); it('should allow a normally-excluded tool when --allowed-tools is set', async () => { diff --git a/packages/cli/src/nonInteractiveCliCommands.ts b/packages/cli/src/nonInteractiveCliCommands.ts index 912121a2dd..e09db71312 100644 --- a/packages/cli/src/nonInteractiveCliCommands.ts +++ b/packages/cli/src/nonInteractiveCliCommands.ts @@ -13,6 +13,7 @@ import { type Config, } from '@google/gemini-cli-core'; import { CommandService } from './services/CommandService.js'; +import { BuiltinCommandLoader } from './services/BuiltinCommandLoader.js'; import { FileCommandLoader } from './services/FileCommandLoader.js'; import { McpPromptLoader } from './services/McpPromptLoader.js'; import type { CommandContext } from './ui/commands/types.js'; @@ -40,7 +41,11 @@ export const handleSlashCommand = async ( } const commandService = await CommandService.create( - [new McpPromptLoader(config), new FileCommandLoader(config)], + [ + new BuiltinCommandLoader(config), + new McpPromptLoader(config), + new FileCommandLoader(config), + ], abortController.signal, ); const commands = commandService.getCommands(); diff --git a/packages/cli/src/ui/commands/mcpCommand.ts b/packages/cli/src/ui/commands/mcpCommand.ts index 97ac6973a6..4f4c098918 100644 --- a/packages/cli/src/ui/commands/mcpCommand.ts +++ b/packages/cli/src/ui/commands/mcpCommand.ts @@ -20,8 +20,10 @@ import { getErrorMessage, MCPOAuthTokenStorage, mcpServerRequiresOAuth, + CoreEvent, + coreEvents, } from '@google/gemini-cli-core'; -import { appEvents, AppEvent } from '../../utils/events.js'; + import { MessageType, type HistoryItemMcpStatus } from '../types.js'; import { McpServerEnablementManager, @@ -100,8 +102,7 @@ const authCommand: SlashCommand = { context.ui.addItem({ type: 'info', text: message }); }; - appEvents.on(AppEvent.OauthDisplayMessage, displayListener); - + coreEvents.on(CoreEvent.OauthDisplayMessage, displayListener); try { context.ui.addItem({ type: 'info', @@ -118,12 +119,7 @@ const authCommand: SlashCommand = { const mcpServerUrl = server.httpUrl || server.url; const authProvider = new MCPOAuthProvider(new MCPOAuthTokenStorage()); - await authProvider.authenticate( - serverName, - oauthConfig, - mcpServerUrl, - appEvents, - ); + await authProvider.authenticate(serverName, oauthConfig, mcpServerUrl); context.ui.addItem({ type: 'info', @@ -160,7 +156,7 @@ const authCommand: SlashCommand = { content: `Failed to authenticate with MCP server '${serverName}': ${getErrorMessage(error)}`, }; } finally { - appEvents.removeListener(AppEvent.OauthDisplayMessage, displayListener); + coreEvents.removeListener(CoreEvent.OauthDisplayMessage, displayListener); } }, completion: async (context: CommandContext, partialArg: string) => { diff --git a/packages/cli/src/ui/components/ConfigInitDisplay.test.tsx b/packages/cli/src/ui/components/ConfigInitDisplay.test.tsx index 3c98080823..9c7978400f 100644 --- a/packages/cli/src/ui/components/ConfigInitDisplay.test.tsx +++ b/packages/cli/src/ui/components/ConfigInitDisplay.test.tsx @@ -5,12 +5,25 @@ */ import { act } from 'react'; +import type { EventEmitter } from 'node:events'; import { render } from '../../test-utils/render.js'; import { waitFor } from '../../test-utils/async.js'; import { ConfigInitDisplay } from './ConfigInitDisplay.js'; -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { AppEvent } from '../../utils/events.js'; -import { MCPServerStatus, type McpClient } from '@google/gemini-cli-core'; +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + type MockInstance, +} from 'vitest'; +import { + CoreEvent, + MCPServerStatus, + type McpClient, + coreEvents, +} from '@google/gemini-cli-core'; import { Text } from 'ink'; // Mock GeminiSpinner @@ -18,30 +31,11 @@ vi.mock('./GeminiRespondingSpinner.js', () => ({ GeminiSpinner: () => Spinner, })); -// Mock appEvents -const { mockOn, mockOff, mockEmit } = vi.hoisted(() => ({ - mockOn: vi.fn(), - mockOff: vi.fn(), - mockEmit: vi.fn(), -})); - -vi.mock('../../utils/events.js', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - appEvents: { - on: mockOn, - off: mockOff, - emit: mockEmit, - }, - }; -}); - describe('ConfigInitDisplay', () => { + let onSpy: MockInstance; + beforeEach(() => { - mockOn.mockClear(); - mockOff.mockClear(); - mockEmit.mockClear(); + onSpy = vi.spyOn(coreEvents as EventEmitter, 'on'); }); afterEach(() => { @@ -55,10 +49,11 @@ describe('ConfigInitDisplay', () => { it('updates message on McpClientUpdate event', async () => { let listener: ((clients?: Map) => void) | undefined; - mockOn.mockImplementation((event, fn) => { - if (event === AppEvent.McpClientUpdate) { - listener = fn; + onSpy.mockImplementation((event: unknown, fn: unknown) => { + if (event === CoreEvent.McpClientUpdate) { + listener = fn as (clients?: Map) => void; } + return coreEvents; }); const { lastFrame } = render(); @@ -92,10 +87,11 @@ describe('ConfigInitDisplay', () => { it('truncates list of waiting servers if too many', async () => { let listener: ((clients?: Map) => void) | undefined; - mockOn.mockImplementation((event, fn) => { - if (event === AppEvent.McpClientUpdate) { - listener = fn; + onSpy.mockImplementation((event: unknown, fn: unknown) => { + if (event === CoreEvent.McpClientUpdate) { + listener = fn as (clients?: Map) => void; } + return coreEvents; }); const { lastFrame } = render(); @@ -127,10 +123,11 @@ describe('ConfigInitDisplay', () => { it('handles empty clients map', async () => { let listener: ((clients?: Map) => void) | undefined; - mockOn.mockImplementation((event, fn) => { - if (event === AppEvent.McpClientUpdate) { - listener = fn; + onSpy.mockImplementation((event: unknown, fn: unknown) => { + if (event === CoreEvent.McpClientUpdate) { + listener = fn as (clients?: Map) => void; } + return coreEvents; }); const { lastFrame } = render(); diff --git a/packages/cli/src/ui/components/ConfigInitDisplay.tsx b/packages/cli/src/ui/components/ConfigInitDisplay.tsx index 59529dc96d..b1dc71ff74 100644 --- a/packages/cli/src/ui/components/ConfigInitDisplay.tsx +++ b/packages/cli/src/ui/components/ConfigInitDisplay.tsx @@ -5,9 +5,13 @@ */ import { useEffect, useState } from 'react'; -import { AppEvent, appEvents } from './../../utils/events.js'; import { Box, Text } from 'ink'; -import { type McpClient, MCPServerStatus } from '@google/gemini-cli-core'; +import { + CoreEvent, + coreEvents, + type McpClient, + MCPServerStatus, +} from '@google/gemini-cli-core'; import { GeminiSpinner } from './GeminiRespondingSpinner.js'; import { theme } from '../semantic-colors.js'; @@ -45,9 +49,9 @@ export const ConfigInitDisplay = () => { } }; - appEvents.on(AppEvent.McpClientUpdate, onChange); + coreEvents.on(CoreEvent.McpClientUpdate, onChange); return () => { - appEvents.off(AppEvent.McpClientUpdate, onChange); + coreEvents.off(CoreEvent.McpClientUpdate, onChange); }; }, []); diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx index 295696553f..4a6a6a1c9b 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx @@ -20,8 +20,8 @@ import { type GeminiClient, SlashCommandStatus, makeFakeConfig, + coreEvents, } from '@google/gemini-cli-core'; -import { appEvents } from '../../utils/events.js'; const { logSlashCommand, @@ -1044,7 +1044,7 @@ describe('useSlashCommandProcessor', () => { // We should not see a change until we fire an event. await waitFor(() => expect(result.current.slashCommands).toEqual([])); act(() => { - appEvents.emit('extensionsStarting'); + coreEvents.emit('extensionsStarting'); }); await waitFor(() => expect(result.current.slashCommands).toEqual([newCommand]), diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index c4effdda3c..efd0762320 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -30,6 +30,7 @@ import { ToolConfirmationOutcome, Storage, IdeClient, + coreEvents, addMCPStatusChangeListener, removeMCPStatusChangeListener, MCPDiscoveryState, @@ -55,7 +56,6 @@ import { type ExtensionUpdateAction, type ExtensionUpdateStatus, } from '../state/extensions.js'; -import { appEvents } from '../../utils/events.js'; import { LogoutConfirmationDialog, LogoutChoice, @@ -295,8 +295,8 @@ export const useSlashCommandProcessor = ( // starting/stopping reloadCommands(); }; - appEvents.on('extensionsStarting', extensionEventListener); - appEvents.on('extensionsStopping', extensionEventListener); + coreEvents.on('extensionsStarting', extensionEventListener); + coreEvents.on('extensionsStopping', extensionEventListener); return () => { // eslint-disable-next-line @typescript-eslint/no-floating-promises @@ -305,8 +305,8 @@ export const useSlashCommandProcessor = ( ideClient.removeStatusChangeListener(listener); })(); removeMCPStatusChangeListener(listener); - appEvents.off('extensionsStarting', extensionEventListener); - appEvents.off('extensionsStopping', extensionEventListener); + coreEvents.off('extensionsStarting', extensionEventListener); + coreEvents.off('extensionsStopping', extensionEventListener); }; }, [config, reloadCommands]); diff --git a/packages/cli/src/utils/events.test.ts b/packages/cli/src/utils/events.test.ts index b37215c506..8055a3b286 100644 --- a/packages/cli/src/utils/events.test.ts +++ b/packages/cli/src/utils/events.test.ts @@ -10,13 +10,13 @@ import { appEvents, AppEvent } from './events.js'; describe('events', () => { it('should allow registering and emitting events', () => { const callback = vi.fn(); - appEvents.on(AppEvent.OauthDisplayMessage, callback); + appEvents.on(AppEvent.SelectionWarning, callback); - appEvents.emit(AppEvent.OauthDisplayMessage, 'test message'); + appEvents.emit(AppEvent.SelectionWarning); - expect(callback).toHaveBeenCalledWith('test message'); + expect(callback).toHaveBeenCalled(); - appEvents.off(AppEvent.OauthDisplayMessage, callback); + appEvents.off(AppEvent.SelectionWarning, callback); }); it('should work with events without data', () => { diff --git a/packages/cli/src/utils/events.ts b/packages/cli/src/utils/events.ts index 4e7d127028..4bf19d44ef 100644 --- a/packages/cli/src/utils/events.ts +++ b/packages/cli/src/utils/events.ts @@ -4,23 +4,18 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { ExtensionEvents, McpClient } from '@google/gemini-cli-core'; import { EventEmitter } from 'node:events'; export enum AppEvent { OpenDebugConsole = 'open-debug-console', - OauthDisplayMessage = 'oauth-display-message', Flicker = 'flicker', - McpClientUpdate = 'mcp-client-update', SelectionWarning = 'selection-warning', PasteTimeout = 'paste-timeout', } -export interface AppEvents extends ExtensionEvents { +export interface AppEvents { [AppEvent.OpenDebugConsole]: never[]; - [AppEvent.OauthDisplayMessage]: string[]; [AppEvent.Flicker]: never[]; - [AppEvent.McpClientUpdate]: Array | never>; [AppEvent.SelectionWarning]: never[]; [AppEvent.PasteTimeout]: never[]; } diff --git a/packages/core/src/mcp/oauth-provider.test.ts b/packages/core/src/mcp/oauth-provider.test.ts index 352cdcd721..cda9b4f712 100644 --- a/packages/core/src/mcp/oauth-provider.test.ts +++ b/packages/core/src/mcp/oauth-provider.test.ts @@ -42,11 +42,7 @@ import type { OAuthTokenResponse, OAuthClientRegistrationResponse, } from './oauth-provider.js'; -import { - MCPOAuthProvider, - OAUTH_DISPLAY_MESSAGE_EVENT, -} from './oauth-provider.js'; -import { EventEmitter } from 'node:events'; +import { MCPOAuthProvider } from './oauth-provider.js'; import type { OAuthToken } from './token-storage/types.js'; import { MCPOAuthTokenStorage } from './oauth-token-storage.js'; import { @@ -1195,20 +1191,17 @@ describe('MCPOAuthProvider', () => { ); const authProvider = new MCPOAuthProvider(); - const eventEmitter = new EventEmitter(); - const messagePromise = new Promise((resolve) => { - eventEmitter.on(OAUTH_DISPLAY_MESSAGE_EVENT, resolve); - }); await authProvider.authenticate( 'production-server', mockConfig, undefined, - eventEmitter, ); - const message = await messagePromise; - expect(message).toContain('production-server'); + expect(coreEvents.emitFeedback).toHaveBeenCalledWith( + 'info', + expect.stringContaining('production-server'), + ); }); }); diff --git a/packages/core/src/mcp/oauth-provider.ts b/packages/core/src/mcp/oauth-provider.ts index 30240adba9..5947c6edf7 100644 --- a/packages/core/src/mcp/oauth-provider.ts +++ b/packages/core/src/mcp/oauth-provider.ts @@ -8,7 +8,6 @@ import * as http from 'node:http'; import * as crypto from 'node:crypto'; import type * as net from 'node:net'; import { URL } from 'node:url'; -import type { EventEmitter } from 'node:events'; import { openBrowserSecurely } from '../utils/secure-browser-launcher.js'; import type { OAuthToken } from './token-storage/types.js'; import { MCPOAuthTokenStorage } from './oauth-token-storage.js'; @@ -744,15 +743,10 @@ export class MCPOAuthProvider { serverName: string, config: MCPOAuthConfig, mcpServerUrl?: string, - events?: EventEmitter, ): Promise { // Helper function to display messages through handler or fallback to console.log const displayMessage = (message: string) => { - if (events) { - events.emit(OAUTH_DISPLAY_MESSAGE_EVENT, message); - } else { - debugLogger.log(message); - } + coreEvents.emitFeedback('info', message); }; // If no authorization URL is provided, try to discover OAuth configuration diff --git a/packages/core/src/utils/events.ts b/packages/core/src/utils/events.ts index 79e440e9ad..8fd6a73751 100644 --- a/packages/core/src/utils/events.ts +++ b/packages/core/src/utils/events.ts @@ -5,6 +5,8 @@ */ import { EventEmitter } from 'node:events'; +import type { McpClient } from '../tools/mcp-client.js'; +import type { ExtensionEvents } from './extensionLoader.js'; /** * Defines the severity level for user-facing feedback. @@ -115,6 +117,8 @@ export enum CoreEvent { Output = 'output', MemoryChanged = 'memory-changed', ExternalEditorClosed = 'external-editor-closed', + McpClientUpdate = 'mcp-client-update', + OauthDisplayMessage = 'oauth-display-message', SettingsChanged = 'settings-changed', HookStart = 'hook-start', HookEnd = 'hook-end', @@ -123,13 +127,15 @@ export enum CoreEvent { RetryAttempt = 'retry-attempt', } -export interface CoreEvents { +export interface CoreEvents extends ExtensionEvents { [CoreEvent.UserFeedback]: [UserFeedbackPayload]; [CoreEvent.ModelChanged]: [ModelChangedPayload]; [CoreEvent.ConsoleLog]: [ConsoleLogPayload]; [CoreEvent.Output]: [OutputPayload]; [CoreEvent.MemoryChanged]: [MemoryChangedPayload]; [CoreEvent.ExternalEditorClosed]: never[]; + [CoreEvent.McpClientUpdate]: Array | never>; + [CoreEvent.OauthDisplayMessage]: string[]; [CoreEvent.SettingsChanged]: never[]; [CoreEvent.HookStart]: [HookStartPayload]; [CoreEvent.HookEnd]: [HookEndPayload];