diff --git a/packages/cli/index.ts b/packages/cli/index.ts index 5444fe1b74d..a0ac3a6084f 100644 --- a/packages/cli/index.ts +++ b/packages/cli/index.ts @@ -6,66 +6,108 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { main } from './src/gemini.js'; -import { FatalError, writeToStderr } from '@google/gemini-cli-core'; -import { runExitCleanup } from './src/utils/cleanup.js'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; // --- Global Entry Point --- +const VERSION_FLAGS = new Set(['-v', '--version']); -// Suppress known race condition error in node-pty on Windows -// Tracking bug: https://github.com/microsoft/node-pty/issues/827 -process.on('uncaughtException', (error) => { - if ( - process.platform === 'win32' && - error instanceof Error && - error.message === 'Cannot resize a pty that has already exited' - ) { - // This error happens on Windows with node-pty when resizing a pty that has just exited. - // It is a race condition in node-pty that we cannot prevent, so we silence it. - return; - } - - // For other errors, we rely on the default behavior, but since we attached a listener, - // we must manually replicate it. - if (error instanceof Error) { - writeToStderr(error.stack + '\n'); - } else { - writeToStderr(String(error) + '\n'); - } - process.exit(1); -}); +function isVersionOnlyRequest(args: string[]): boolean { + return args.length === 1 && VERSION_FLAGS.has(args[0] ?? ''); +} -main().catch(async (error) => { - // Set a timeout to force exit if cleanup hangs - const cleanupTimeout = setTimeout(() => { - writeToStderr('Cleanup timed out, forcing exit...\n'); - process.exit(1); - }, 5000); +function getCliVersionFromPackageJson(): string { + const __dirname = dirname(fileURLToPath(import.meta.url)); + const packageJsonPath = join(__dirname, 'package.json'); + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')) as { + version?: string; + }; + return packageJson.version ?? 'unknown'; +} +if (isVersionOnlyRequest(process.argv.slice(2))) { try { - await runExitCleanup(); - } catch (cleanupError) { - writeToStderr( - `Error during final cleanup: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}\n`, + process.stdout.write(`${getCliVersionFromPackageJson()}\n`); + process.exit(0); + } catch (error) { + process.stderr.write( + `Failed to read CLI version: ${error instanceof Error ? error.message : String(error)}\n`, ); - } finally { - clearTimeout(cleanupTimeout); + process.exit(1); } +} + +async function run() { + const [{ main }, coreModule, { runExitCleanup }] = await Promise.all([ + import('./src/gemini.js'), + import('@google/gemini-cli-core'), + import('./src/utils/cleanup.js'), + ]); + const { FatalError, writeToStderr } = coreModule; - if (error instanceof FatalError) { - let errorMessage = error.message; - if (!process.env['NO_COLOR']) { - errorMessage = `\x1b[31m${errorMessage}\x1b[0m`; + // Suppress known race condition error in node-pty on Windows + // Tracking bug: https://github.com/microsoft/node-pty/issues/827 + process.on('uncaughtException', (error) => { + if ( + process.platform === 'win32' && + error instanceof Error && + error.message === 'Cannot resize a pty that has already exited' + ) { + // This error happens on Windows with node-pty when resizing a pty that has just exited. + // It is a race condition in node-pty that we cannot prevent, so we silence it. + return; } - writeToStderr(errorMessage + '\n'); - process.exit(error.exitCode); - } - writeToStderr('An unexpected critical error occurred:'); - if (error instanceof Error) { - writeToStderr(error.stack + '\n'); - } else { - writeToStderr(String(error) + '\n'); - } + // For other errors, we rely on the default behavior, but since we attached a listener, + // we must manually replicate it. + if (error instanceof Error) { + writeToStderr(error.stack + '\n'); + } else { + writeToStderr(String(error) + '\n'); + } + process.exit(1); + }); + + await main().catch(async (error) => { + // Set a timeout to force exit if cleanup hangs + const cleanupTimeout = setTimeout(() => { + writeToStderr('Cleanup timed out, forcing exit...\n'); + process.exit(1); + }, 5000); + + try { + await runExitCleanup(); + } catch (cleanupError) { + writeToStderr( + `Error during final cleanup: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}\n`, + ); + } finally { + clearTimeout(cleanupTimeout); + } + + if (error instanceof FatalError) { + let errorMessage = error.message; + if (!process.env['NO_COLOR']) { + errorMessage = `\x1b[31m${errorMessage}\x1b[0m`; + } + writeToStderr(errorMessage + '\n'); + process.exit(error.exitCode); + } + + writeToStderr('An unexpected critical error occurred:'); + if (error instanceof Error) { + writeToStderr(error.stack + '\n'); + } else { + writeToStderr(String(error) + '\n'); + } + process.exit(1); + }); +} + +void run().catch((error) => { + process.stderr.write( + `Failed to start Gemini CLI: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`, + ); process.exit(1); }); diff --git a/packages/cli/src/commands/extensions.test.tsx b/packages/cli/src/commands/extensions.test.tsx index 0630b398ff7..c892d1e6db0 100644 --- a/packages/cli/src/commands/extensions.test.tsx +++ b/packages/cli/src/commands/extensions.test.tsx @@ -31,7 +31,7 @@ vi.mock('./extensions/validate.js', () => ({ })); // Mock gemini.js -vi.mock('../gemini.js', () => ({ +vi.mock('../utils/outputListeners.js', () => ({ initializeOutputListenersAndFlush: vi.fn(), })); diff --git a/packages/cli/src/commands/extensions.tsx b/packages/cli/src/commands/extensions.tsx index ec646cfc829..41eb1d0545f 100644 --- a/packages/cli/src/commands/extensions.tsx +++ b/packages/cli/src/commands/extensions.tsx @@ -15,8 +15,8 @@ import { linkCommand } from './extensions/link.js'; import { newCommand } from './extensions/new.js'; import { validateCommand } from './extensions/validate.js'; import { configureCommand } from './extensions/configure.js'; -import { initializeOutputListenersAndFlush } from '../gemini.js'; import { defer } from '../deferred.js'; +import { initializeOutputListenersAndFlush } from '../utils/outputListeners.js'; export const extensionsCommand: CommandModule = { command: 'extensions ', diff --git a/packages/cli/src/commands/hooks.tsx b/packages/cli/src/commands/hooks.tsx index fdb4594d041..357e486fc3b 100644 --- a/packages/cli/src/commands/hooks.tsx +++ b/packages/cli/src/commands/hooks.tsx @@ -6,7 +6,7 @@ import type { CommandModule } from 'yargs'; import { migrateCommand } from './hooks/migrate.js'; -import { initializeOutputListenersAndFlush } from '../gemini.js'; +import { initializeOutputListenersAndFlush } from '../utils/outputListeners.js'; export const hooksCommand: CommandModule = { command: 'hooks ', diff --git a/packages/cli/src/commands/mcp.ts b/packages/cli/src/commands/mcp.ts index d2b7f85f034..1e19808ddb8 100644 --- a/packages/cli/src/commands/mcp.ts +++ b/packages/cli/src/commands/mcp.ts @@ -10,8 +10,8 @@ import { addCommand } from './mcp/add.js'; import { removeCommand } from './mcp/remove.js'; import { listCommand } from './mcp/list.js'; import { enableCommand, disableCommand } from './mcp/enableDisable.js'; -import { initializeOutputListenersAndFlush } from '../gemini.js'; import { defer } from '../deferred.js'; +import { initializeOutputListenersAndFlush } from '../utils/outputListeners.js'; export const mcpCommand: CommandModule = { command: 'mcp', diff --git a/packages/cli/src/commands/skills.test.tsx b/packages/cli/src/commands/skills.test.tsx index e7b9a4eb9dd..a940bcbd187 100644 --- a/packages/cli/src/commands/skills.test.tsx +++ b/packages/cli/src/commands/skills.test.tsx @@ -15,7 +15,7 @@ vi.mock('./skills/disable.js', () => ({ disableCommand: { command: 'disable ' }, })); -vi.mock('../gemini.js', () => ({ +vi.mock('../utils/outputListeners.js', () => ({ initializeOutputListenersAndFlush: vi.fn(), })); diff --git a/packages/cli/src/commands/skills.tsx b/packages/cli/src/commands/skills.tsx index 8a51c4150e5..2e700401c2f 100644 --- a/packages/cli/src/commands/skills.tsx +++ b/packages/cli/src/commands/skills.tsx @@ -11,8 +11,8 @@ import { disableCommand } from './skills/disable.js'; import { installCommand } from './skills/install.js'; import { linkCommand } from './skills/link.js'; import { uninstallCommand } from './skills/uninstall.js'; -import { initializeOutputListenersAndFlush } from '../gemini.js'; import { defer } from '../deferred.js'; +import { initializeOutputListenersAndFlush } from '../utils/outputListeners.js'; export const skillsCommand: CommandModule = { command: 'skills ', diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 2e238765e83..6aeb023d924 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -6,7 +6,6 @@ import React from 'react'; import { render } from 'ink'; -import { AppContainer } from './ui/AppContainer.js'; import { loadCliConfig, parseArguments } from './config/config.js'; import * as cliConfig from './config/config.js'; import { readStdin } from './utils/readStdin.js'; @@ -44,9 +43,6 @@ import { WarningPriority, type Config, type ResumedSessionData, - type OutputPayload, - type ConsoleLogPayload, - type UserFeedbackPayload, sessionId, logUserPrompt, AuthType, @@ -55,7 +51,6 @@ import { debugLogger, recordSlowRender, coreEvents, - CoreEvent, createWorkingStdio, patchStdio, writeToStdout, @@ -110,6 +105,7 @@ import { setupTerminalAndTheme } from './utils/terminalTheme.js'; import { profiler } from './ui/components/DebugProfiler.js'; import { runDeferredCommand } from './deferred.js'; import { SlashCommandConflictHandler } from './services/SlashCommandConflictHandler.js'; +import { initializeOutputListenersAndFlush } from './utils/outputListeners.js'; const SLOW_RENDER_MS = 200; @@ -207,7 +203,10 @@ export async function startInteractiveUI( }); } - const version = await getVersion(); + const [{ AppContainer }, version] = await Promise.all([ + import('./ui/AppContainer.js'), + getVersion(), + ]); setWindowTitle(basename(workspaceRoot), settings); const consolePatcher = new ConsolePatcher({ @@ -555,9 +554,9 @@ export async function main() { ); await runExitCleanup(); process.exit(ExitCodes.SUCCESS); - } else { + } else if (partialConfig.isInteractive()) { // Relaunch app so we always have a child process that can be internally - // restarted if needed. + // restarted if needed in interactive mode. await relaunchAppInChildProcess(memoryArgs, [], remoteAdminSettings); } } @@ -657,41 +656,7 @@ export async function main() { }); } - await setupTerminalAndTheme(config, settings); - - const initAppHandle = startupProfiler.start('initialize_app'); - const initializationResult = await initializeApp(config, settings); - initAppHandle?.end(); - - if ( - settings.merged.security.auth.selectedType === - AuthType.LOGIN_WITH_GOOGLE && - config.isBrowserLaunchSuppressed() - ) { - // Do oauth before app renders to make copying the link possible. - await getOauthClient(settings.merged.security.auth.selectedType, config); - } - - if (config.getExperimentalZedIntegration()) { - return runZedIntegration(config, settings, argv); - } - let input = config.getQuestion(); - const useAlternateBuffer = shouldEnterAlternateScreen( - isAlternateBufferEnabled(config), - config.getScreenReader(), - ); - const rawStartupWarnings = await getStartupWarnings(); - const startupWarnings: StartupWarning[] = [ - ...rawStartupWarnings.map((message) => ({ - id: `startup-${createHash('sha256').update(message).digest('hex').substring(0, 16)}`, - message, - priority: WarningPriority.High, - })), - ...(await getUserStartupWarnings(settings.merged, undefined, { - isAlternateBuffer: useAlternateBuffer, - })), - ]; // Handle --resume flag let resumedSessionData: ResumedSessionData | undefined = undefined; @@ -715,9 +680,60 @@ export async function main() { } } + const isInteractiveMode = config.isInteractive(); + const requiresUiInitialization = + isInteractiveMode || config.getExperimentalZedIntegration(); + let initializationResult: InitializationResult | undefined; + + await setupTerminalAndTheme(config, settings); + + if (requiresUiInitialization) { + const initAppHandle = startupProfiler.start('initialize_app'); + initializationResult = await initializeApp(config, settings); + initAppHandle?.end(); + + if ( + settings.merged.security.auth.selectedType === + AuthType.LOGIN_WITH_GOOGLE && + config.isBrowserLaunchSuppressed() + ) { + // Do oauth before app renders to make copying the link possible. + await getOauthClient( + settings.merged.security.auth.selectedType, + config, + ); + } + + if (config.getExperimentalZedIntegration()) { + return runZedIntegration(config, settings, argv); + } + } + cliStartupHandle?.end(); // Render UI, passing necessary config values. Check that there is no command line question. - if (config.isInteractive()) { + if (isInteractiveMode) { + if (!initializationResult) { + throw new Error( + 'Interactive initialization result missing during startup.', + ); + } + + const useAlternateBuffer = shouldEnterAlternateScreen( + isAlternateBufferEnabled(config), + config.getScreenReader(), + ); + const rawStartupWarnings = await getStartupWarnings(); + const startupWarnings: StartupWarning[] = [ + ...rawStartupWarnings.map((message) => ({ + id: `startup-${createHash('sha256').update(message).digest('hex').substring(0, 16)}`, + message, + priority: WarningPriority.High, + })), + ...(await getUserStartupWarnings(settings.merged, undefined, { + isAlternateBuffer: useAlternateBuffer, + })), + ]; + await startInteractiveUI( config, settings, @@ -835,41 +851,7 @@ function setWindowTitle(title: string, settings: LoadedSettings) { } } -export function initializeOutputListenersAndFlush() { - // If there are no listeners for output, make sure we flush so output is not - // lost. - if (coreEvents.listenerCount(CoreEvent.Output) === 0) { - // In non-interactive mode, ensure we drain any buffered output or logs to stderr - coreEvents.on(CoreEvent.Output, (payload: OutputPayload) => { - if (payload.isStderr) { - writeToStderr(payload.chunk, payload.encoding); - } else { - writeToStdout(payload.chunk, payload.encoding); - } - }); - - if (coreEvents.listenerCount(CoreEvent.ConsoleLog) === 0) { - coreEvents.on(CoreEvent.ConsoleLog, (payload: ConsoleLogPayload) => { - if (payload.type === 'error' || payload.type === 'warn') { - writeToStderr(payload.content); - } else { - writeToStdout(payload.content); - } - }); - } - - if (coreEvents.listenerCount(CoreEvent.UserFeedback) === 0) { - coreEvents.on(CoreEvent.UserFeedback, (payload: UserFeedbackPayload) => { - if (payload.severity === 'error' || payload.severity === 'warning') { - writeToStderr(payload.message); - } else { - writeToStdout(payload.message); - } - }); - } - } - coreEvents.drainBacklogs(); -} +export { initializeOutputListenersAndFlush }; function setupAdminControlsListener() { let pendingSettings: AdminControlsSettings | undefined; diff --git a/packages/cli/src/ui/editors/editorSettingsManager.test.ts b/packages/cli/src/ui/editors/editorSettingsManager.test.ts new file mode 100644 index 00000000000..47eb525f268 --- /dev/null +++ b/packages/cli/src/ui/editors/editorSettingsManager.test.ts @@ -0,0 +1,32 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi } from 'vitest'; + +const mockHasValidEditorCommand = vi.hoisted(() => vi.fn(() => true)); + +vi.mock('@google/gemini-cli-core', () => ({ + allowEditorTypeInSandbox: vi.fn(() => true), + hasValidEditorCommand: mockHasValidEditorCommand, + EDITOR_DISPLAY_NAMES: { + code: 'VS Code', + vim: 'Vim', + }, +})); + +describe('editorSettingsManager', () => { + it('computes editor availability lazily', async () => { + const { editorSettingsManager } = await import( + './editorSettingsManager.js' + ); + + expect(mockHasValidEditorCommand).not.toHaveBeenCalled(); + + const editors = editorSettingsManager.getAvailableEditorDisplays(); + expect(editors.length).toBeGreaterThan(1); + expect(mockHasValidEditorCommand).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/cli/src/ui/editors/editorSettingsManager.ts b/packages/cli/src/ui/editors/editorSettingsManager.ts index d8aab97a6e9..887a3799300 100644 --- a/packages/cli/src/ui/editors/editorSettingsManager.ts +++ b/packages/cli/src/ui/editors/editorSettingsManager.ts @@ -18,14 +18,14 @@ export interface EditorDisplay { } class EditorSettingsManager { - private readonly availableEditors: EditorDisplay[]; + private availableEditors: EditorDisplay[] | undefined; - constructor() { + private computeAvailableEditors(): EditorDisplay[] { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const editorTypes = Object.keys( EDITOR_DISPLAY_NAMES, ).sort() as EditorType[]; - this.availableEditors = [ + return [ { name: 'None', type: 'not_set', @@ -50,6 +50,9 @@ class EditorSettingsManager { } getAvailableEditorDisplays(): EditorDisplay[] { + if (!this.availableEditors) { + this.availableEditors = this.computeAvailableEditors(); + } return this.availableEditors; } } diff --git a/packages/cli/src/utils/outputListeners.test.ts b/packages/cli/src/utils/outputListeners.test.ts new file mode 100644 index 00000000000..920c6df9156 --- /dev/null +++ b/packages/cli/src/utils/outputListeners.test.ts @@ -0,0 +1,53 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi } from 'vitest'; + +const coreEventsMocks = vi.hoisted(() => ({ + listenerCount: vi.fn((event: string) => (event === 'output' ? 1 : 0)), + on: vi.fn(), + drainBacklogs: vi.fn(), +})); + +vi.mock('@google/gemini-cli-core', () => ({ + CoreEvent: { + Output: 'output', + ConsoleLog: 'consoleLog', + UserFeedback: 'userFeedback', + }, + coreEvents: coreEventsMocks, + writeToStderr: vi.fn(), + writeToStdout: vi.fn(), +})); + +describe('initializeOutputListenersAndFlush', () => { + it('registers console/user feedback fallbacks independently of output listeners', async () => { + const { initializeOutputListenersAndFlush } = await import( + './outputListeners.js' + ); + + initializeOutputListenersAndFlush(); + + expect(coreEventsMocks.listenerCount).toHaveBeenCalledWith('output'); + expect(coreEventsMocks.listenerCount).toHaveBeenCalledWith('consoleLog'); + expect(coreEventsMocks.listenerCount).toHaveBeenCalledWith('userFeedback'); + + // Even with existing output listeners, we still add missing fallback listeners. + expect(coreEventsMocks.on).not.toHaveBeenCalledWith( + 'output', + expect.any(Function), + ); + expect(coreEventsMocks.on).toHaveBeenCalledWith( + 'consoleLog', + expect.any(Function), + ); + expect(coreEventsMocks.on).toHaveBeenCalledWith( + 'userFeedback', + expect.any(Function), + ); + expect(coreEventsMocks.drainBacklogs).toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/utils/outputListeners.ts b/packages/cli/src/utils/outputListeners.ts new file mode 100644 index 00000000000..7dcf73846c0 --- /dev/null +++ b/packages/cli/src/utils/outputListeners.ts @@ -0,0 +1,51 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + CoreEvent, + type ConsoleLogPayload, + coreEvents, + type OutputPayload, + type UserFeedbackPayload, + writeToStderr, + writeToStdout, +} from '@google/gemini-cli-core'; + +export function initializeOutputListenersAndFlush() { + // If there are no listeners for output, make sure we flush so output is not + // lost. + if (coreEvents.listenerCount(CoreEvent.Output) === 0) { + // In non-interactive mode, ensure we drain any buffered output or logs to stderr. + coreEvents.on(CoreEvent.Output, (payload: OutputPayload) => { + if (payload.isStderr) { + writeToStderr(payload.chunk, payload.encoding); + } else { + writeToStdout(payload.chunk, payload.encoding); + } + }); + } + + if (coreEvents.listenerCount(CoreEvent.ConsoleLog) === 0) { + coreEvents.on(CoreEvent.ConsoleLog, (payload: ConsoleLogPayload) => { + if (payload.type === 'error' || payload.type === 'warn') { + writeToStderr(payload.content); + } else { + writeToStdout(payload.content); + } + }); + } + + if (coreEvents.listenerCount(CoreEvent.UserFeedback) === 0) { + coreEvents.on(CoreEvent.UserFeedback, (payload: UserFeedbackPayload) => { + if (payload.severity === 'error' || payload.severity === 'warning') { + writeToStderr(payload.message); + } else { + writeToStdout(payload.message); + } + }); + } + coreEvents.drainBacklogs(); +}