diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 9506859a..d06b4dc7 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -74,6 +74,18 @@ This guide provides solutions to common issues and debugging tips, including top - **Cause:** The `DEBUG` and `DEBUG_MODE` variables are automatically excluded from project `.env` files to prevent interference with gemini-cli behavior. - **Solution:** Use a `.gemini/.env` file instead, or configure the `excludedProjectEnvVars` setting in your `settings.json` to exclude fewer variables. +## Exit Codes + +The Gemini CLI uses specific exit codes to indicate the reason for termination. This is especially useful for scripting and automation. + +| Exit Code | Error Type | Description | +| --------- | -------------------------- | --------------------------------------------------------------------------------------------------- | +| 41 | `FatalAuthenticationError` | An error occurred during the authentication process. | +| 42 | `FatalInputError` | Invalid or missing input was provided to the CLI. (non-interactive mode only) | +| 44 | `FatalSandboxError` | An error occurred with the sandboxing environment (e.g., Docker, Podman, or Seatbelt). | +| 52 | `FatalConfigError` | A configuration file (`settings.json`) is invalid or contains errors. | +| 53 | `FatalTurnLimitedError` | The maximum number of conversational turns for the session was reached. (non-interactive mode only) | + ## Debugging Tips - **CLI debugging:** diff --git a/packages/cli/index.ts b/packages/cli/index.ts index 6b7e87a5..e247b095 100644 --- a/packages/cli/index.ts +++ b/packages/cli/index.ts @@ -8,9 +8,18 @@ import './src/gemini.js'; import { main } from './src/gemini.js'; +import { FatalError } from '@google/gemini-cli-core'; // --- Global Entry Point --- main().catch((error) => { + if (error instanceof FatalError) { + let errorMessage = error.message; + if (!process.env['NO_COLOR']) { + errorMessage = `\x1b[31m${errorMessage}\x1b[0m`; + } + console.error(errorMessage); + process.exit(error.exitCode); + } console.error('An unexpected critical error occurred:'); if (error instanceof Error) { console.error(error.stack); diff --git a/packages/cli/src/config/sandboxConfig.ts b/packages/cli/src/config/sandboxConfig.ts index 182176bb..2b0a5dc8 100644 --- a/packages/cli/src/config/sandboxConfig.ts +++ b/packages/cli/src/config/sandboxConfig.ts @@ -5,6 +5,7 @@ */ import type { SandboxConfig } from '@google/gemini-cli-core'; +import { FatalSandboxError } from '@google/gemini-cli-core'; import commandExists from 'command-exists'; import * as os from 'node:os'; import { getPackageJson } from '../utils/package.js'; @@ -51,21 +52,19 @@ function getSandboxCommand( if (typeof sandbox === 'string' && sandbox) { if (!isSandboxCommand(sandbox)) { - console.error( - `ERROR: invalid sandbox command '${sandbox}'. Must be one of ${VALID_SANDBOX_COMMANDS.join( + throw new FatalSandboxError( + `Invalid sandbox command '${sandbox}'. Must be one of ${VALID_SANDBOX_COMMANDS.join( ', ', )}`, ); - process.exit(1); } // confirm that specified command exists if (commandExists.sync(sandbox)) { return sandbox; } - console.error( - `ERROR: missing sandbox command '${sandbox}' (from GEMINI_SANDBOX)`, + throw new FatalSandboxError( + `Missing sandbox command '${sandbox}' (from GEMINI_SANDBOX)`, ); - process.exit(1); } // look for seatbelt, docker, or podman, in that order @@ -80,11 +79,10 @@ function getSandboxCommand( // throw an error if user requested sandbox but no command was found if (sandbox === true) { - console.error( - 'ERROR: GEMINI_SANDBOX is true but failed to determine command for sandbox; ' + + throw new FatalSandboxError( + 'GEMINI_SANDBOX is true but failed to determine command for sandbox; ' + 'install docker or podman or specify command in GEMINI_SANDBOX', ); - process.exit(1); } return ''; diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index b1e3c3b6..16a2d60f 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -4,7 +4,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -import stripAnsi from 'strip-ansi'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { main, @@ -16,6 +15,7 @@ import type { SettingsFile } from './config/settings.js'; import { LoadedSettings, loadSettings } from './config/settings.js'; import { appEvents, AppEvent } from './utils/events.js'; import type { Config } from '@google/gemini-cli-core'; +import { FatalConfigError } from '@google/gemini-cli-core'; // Custom error to identify mock process.exit calls class MockProcessExitError extends Error { @@ -75,7 +75,6 @@ vi.mock('./utils/sandbox.js', () => ({ })); describe('gemini.tsx main function', () => { - let consoleErrorSpy: ReturnType; let loadSettingsMock: ReturnType>; let originalEnvGeminiSandbox: string | undefined; let originalEnvSandbox: string | undefined; @@ -97,7 +96,6 @@ describe('gemini.tsx main function', () => { delete process.env['GEMINI_SANDBOX']; delete process.env['SANDBOX']; - consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); initialUnhandledRejectionListeners = process.listeners('unhandledRejection'); }); @@ -126,7 +124,7 @@ describe('gemini.tsx main function', () => { vi.restoreAllMocks(); }); - it('should call process.exit(1) if settings have errors', async () => { + it('should throw InvalidConfigurationError if settings have errors', async () => { const settingsError = { message: 'Test settings error', path: '/test/settings.json', @@ -158,28 +156,7 @@ describe('gemini.tsx main function', () => { loadSettingsMock.mockReturnValue(mockLoadedSettings); - try { - await main(); - // If main completes without throwing, the test should fail because process.exit was expected - expect.fail('main function did not exit as expected'); - } catch (error) { - expect(error).toBeInstanceOf(MockProcessExitError); - if (error instanceof MockProcessExitError) { - expect(error.code).toBe(1); - } - } - - // Verify console.error was called with the error message - expect(consoleErrorSpy).toHaveBeenCalledTimes(2); - expect(stripAnsi(String(consoleErrorSpy.mock.calls[0][0]))).toBe( - 'Error in /test/settings.json: Test settings error', - ); - expect(stripAnsi(String(consoleErrorSpy.mock.calls[1][0]))).toBe( - 'Please fix /test/settings.json and try again.', - ); - - // Verify process.exit was called. - expect(processExitSpy).toHaveBeenCalledWith(1); + await expect(main()).rejects.toThrow(FatalConfigError); }); it('should log unhandled promise rejections and open debug console on first error', async () => { diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 21a7b55b..66892e09 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -34,6 +34,7 @@ import { logIdeConnection, IdeConnectionEvent, IdeConnectionType, + FatalConfigError, } from '@google/gemini-cli-core'; import { validateAuthMethod } from './config/auth.js'; import { setMaxSizedBoxDebugging } from './ui/components/shared/MaxSizedBox.js'; @@ -173,15 +174,12 @@ export async function main() { await cleanupCheckpoints(); if (settings.errors.length > 0) { - for (const error of settings.errors) { - let errorMessage = `Error in ${error.path}: ${error.message}`; - if (!process.env['NO_COLOR']) { - errorMessage = `\x1b[31m${errorMessage}\x1b[0m`; - } - console.error(errorMessage); - console.error(`Please fix ${error.path} and try again.`); - } - process.exit(1); + const errorMessages = settings.errors.map( + (error) => `Error in ${error.path}: ${error.message}`, + ); + throw new FatalConfigError( + `${errorMessages.join('\n')}\nPlease fix the configuration file(s) and try again.`, + ); } const argv = await parseArguments(settings.merged); diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index f7fc00e4..7de83930 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -38,7 +38,6 @@ describe('runNonInteractive', () => { let mockCoreExecuteToolCall: vi.Mock; let mockShutdownTelemetry: vi.Mock; let consoleErrorSpy: vi.SpyInstance; - let processExitSpy: vi.SpyInstance; let processStdoutSpy: vi.SpyInstance; let mockGeminiClient: { sendMessageStream: vi.Mock; @@ -49,9 +48,6 @@ describe('runNonInteractive', () => { mockShutdownTelemetry = vi.mocked(shutdownTelemetry); consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - processExitSpy = vi - .spyOn(process, 'exit') - .mockImplementation((() => {}) as (code?: number) => never); processStdoutSpy = vi .spyOn(process.stdout, 'write') .mockImplementation(() => true); @@ -202,7 +198,6 @@ describe('runNonInteractive', () => { expect(consoleErrorSpy).toHaveBeenCalledWith( 'Error executing tool errorTool: Execution failed', ); - expect(processExitSpy).not.toHaveBeenCalled(); expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2); expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith( 2, @@ -228,12 +223,9 @@ describe('runNonInteractive', () => { throw apiError; }); - await runNonInteractive(mockConfig, 'Initial fail', 'prompt-id-4'); - - expect(consoleErrorSpy).toHaveBeenCalledWith( - '[API Error: API connection failed]', - ); - expect(processExitSpy).toHaveBeenCalledWith(1); + await expect( + runNonInteractive(mockConfig, 'Initial fail', 'prompt-id-4'), + ).rejects.toThrow(apiError); }); it('should not exit if a tool is not found, and should send error back to model', async () => { @@ -272,7 +264,6 @@ describe('runNonInteractive', () => { expect(consoleErrorSpy).toHaveBeenCalledWith( 'Error executing tool nonexistentTool: Tool "nonexistentTool" not found in registry.', ); - expect(processExitSpy).not.toHaveBeenCalled(); expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2); expect(processStdoutSpy).toHaveBeenCalledWith( "Sorry, I can't find that tool.", @@ -281,9 +272,10 @@ describe('runNonInteractive', () => { it('should exit when max session turns are exceeded', async () => { vi.mocked(mockConfig.getMaxSessionTurns).mockReturnValue(0); - await runNonInteractive(mockConfig, 'Trigger loop', 'prompt-id-6'); - expect(consoleErrorSpy).toHaveBeenCalledWith( - '\n Reached max session turns for this session. Increase the number of turns by specifying maxSessionTurns in settings.json.', + await expect( + runNonInteractive(mockConfig, 'Trigger loop', 'prompt-id-6'), + ).rejects.toThrow( + 'Reached max session turns for this session. Increase the number of turns by specifying maxSessionTurns in settings.json.', ); }); diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 9f9ffd1c..73e8ae23 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -11,6 +11,8 @@ import { isTelemetrySdkInitialized, GeminiEventType, parseAndFormatApiError, + FatalInputError, + FatalTurnLimitedError, } from '@google/gemini-cli-core'; import type { Content, Part } from '@google/genai'; @@ -53,8 +55,9 @@ export async function runNonInteractive( if (!shouldProceed || !processedQuery) { // An error occurred during @include processing (e.g., file not found). // The error message is already logged by handleAtCommand. - console.error('Exiting due to an error processing the @ command.'); - process.exit(1); + throw new FatalInputError( + 'Exiting due to an error processing the @ command.', + ); } let currentMessages: Content[] = [ @@ -68,10 +71,9 @@ export async function runNonInteractive( config.getMaxSessionTurns() >= 0 && turnCount > config.getMaxSessionTurns() ) { - console.error( - '\n Reached max session turns for this session. Increase the number of turns by specifying maxSessionTurns in settings.json.', + throw new FatalTurnLimitedError( + 'Reached max session turns for this session. Increase the number of turns by specifying maxSessionTurns in settings.json.', ); - return; } const toolCallRequests: ToolCallRequestInfo[] = []; @@ -126,7 +128,7 @@ export async function runNonInteractive( config.getContentGeneratorConfig()?.authType, ), ); - process.exit(1); + throw error; } finally { consolePatcher.cleanup(); if (isTelemetrySdkInitialized()) { diff --git a/packages/cli/src/utils/sandbox.ts b/packages/cli/src/utils/sandbox.ts index 3b328e4b..dc4ef3bd 100644 --- a/packages/cli/src/utils/sandbox.ts +++ b/packages/cli/src/utils/sandbox.ts @@ -17,6 +17,7 @@ import { } from '../config/settings.js'; import { promisify } from 'node:util'; import type { Config, SandboxConfig } from '@google/gemini-cli-core'; +import { FatalSandboxError } from '@google/gemini-cli-core'; import { ConsolePatcher } from '../ui/utils/ConsolePatcher.js'; const execAsync = promisify(exec); @@ -198,8 +199,9 @@ export async function start_sandbox( if (config.command === 'sandbox-exec') { // disallow BUILD_SANDBOX if (process.env['BUILD_SANDBOX']) { - console.error('ERROR: cannot BUILD_SANDBOX when using macOS Seatbelt'); - process.exit(1); + throw new FatalSandboxError( + 'Cannot BUILD_SANDBOX when using macOS Seatbelt', + ); } const profile = (process.env['SEATBELT_PROFILE'] ??= 'permissive-open'); @@ -214,10 +216,9 @@ export async function start_sandbox( ); } if (!fs.existsSync(profileFile)) { - console.error( - `ERROR: missing macos seatbelt profile file '${profileFile}'`, + throw new FatalSandboxError( + `Missing macos seatbelt profile file '${profileFile}'`, ); - process.exit(1); } // Log on STDERR so it doesn't clutter the output on STDOUT console.error(`using macos seatbelt (profile: ${profile}) ...`); @@ -325,13 +326,12 @@ export async function start_sandbox( console.error(data.toString()); }); proxyProcess.on('close', (code, signal) => { - console.error( - `ERROR: proxy command '${proxyCommand}' exited with code ${code}, signal ${signal}`, - ); if (sandboxProcess?.pid) { process.kill(-sandboxProcess.pid, 'SIGTERM'); } - process.exit(1); + throw new FatalSandboxError( + `Proxy command '${proxyCommand}' exited with code ${code}, signal ${signal}`, + ); }); console.log('waiting for proxy to start ...'); await execAsync( @@ -366,11 +366,10 @@ export async function start_sandbox( // note this can only be done with binary linked from gemini-cli repo if (process.env['BUILD_SANDBOX']) { if (!gcPath.includes('gemini-cli/packages/')) { - console.error( - 'ERROR: cannot build sandbox using installed gemini binary; ' + + throw new FatalSandboxError( + 'Cannot build sandbox using installed gemini binary; ' + 'run `npm link ./packages/cli` under gemini-cli repo to switch to linked binary.', ); - process.exit(1); } else { console.error('building sandbox ...'); const gcRoot = gcPath.split('/packages/')[0]; @@ -403,10 +402,9 @@ export async function start_sandbox( image === LOCAL_DEV_SANDBOX_IMAGE_NAME ? 'Try running `npm run build:all` or `npm run build:sandbox` under the gemini-cli repo to build it locally, or check the image name and your network connection.' : 'Please check the image name, your network connection, or notify gemini-cli-dev@google.com if the issue persists.'; - console.error( - `ERROR: Sandbox image '${image}' is missing or could not be pulled. ${remedy}`, + throw new FatalSandboxError( + `Sandbox image '${image}' is missing or could not be pulled. ${remedy}`, ); - process.exit(1); } // use interactive mode and auto-remove container on exit @@ -484,17 +482,15 @@ export async function start_sandbox( mount = `${from}:${to}:${opts}`; // check that from path is absolute if (!path.isAbsolute(from)) { - console.error( - `ERROR: path '${from}' listed in SANDBOX_MOUNTS must be absolute`, + throw new FatalSandboxError( + `Path '${from}' listed in SANDBOX_MOUNTS must be absolute`, ); - process.exit(1); } // check that from path exists on host if (!fs.existsSync(from)) { - console.error( - `ERROR: missing mount path '${from}' listed in SANDBOX_MOUNTS`, + throw new FatalSandboxError( + `Missing mount path '${from}' listed in SANDBOX_MOUNTS`, ); - process.exit(1); } console.error(`SANDBOX_MOUNTS: ${from} -> ${to} (${opts})`); args.push('--volume', mount); @@ -665,10 +661,9 @@ export async function start_sandbox( console.error(`SANDBOX_ENV: ${env}`); args.push('--env', env); } else { - console.error( - 'ERROR: SANDBOX_ENV must be a comma-separated list of key=value pairs', + throw new FatalSandboxError( + 'SANDBOX_ENV must be a comma-separated list of key=value pairs', ); - process.exit(1); } } } @@ -776,13 +771,12 @@ export async function start_sandbox( console.error(data.toString().trim()); }); proxyProcess.on('close', (code, signal) => { - console.error( - `ERROR: proxy container command '${proxyContainerCommand}' exited with code ${code}, signal ${signal}`, - ); if (sandboxProcess?.pid) { process.kill(-sandboxProcess.pid, 'SIGTERM'); } - process.exit(1); + throw new FatalSandboxError( + `Proxy container command '${proxyContainerCommand}' exited with code ${code}, signal ${signal}`, + ); }); console.log('waiting for proxy to start ...'); await execAsync( diff --git a/packages/core/src/code_assist/oauth2.ts b/packages/core/src/code_assist/oauth2.ts index fc401655..f71f5a0f 100644 --- a/packages/core/src/code_assist/oauth2.ts +++ b/packages/core/src/code_assist/oauth2.ts @@ -18,7 +18,7 @@ import open from 'open'; import path from 'node:path'; import { promises as fs } from 'node:fs'; import type { Config } from '../config/config.js'; -import { getErrorMessage } from '../utils/errors.js'; +import { getErrorMessage, FatalAuthenticationError } from '../utils/errors.js'; import { UserAccountManager } from '../utils/userAccountManager.js'; import { AuthType } from '../core/contentGenerator.js'; import readline from 'node:readline'; @@ -142,7 +142,9 @@ async function initOauthClient( } } if (!success) { - process.exit(1); + throw new FatalAuthenticationError( + 'Failed to authenticate with user code.', + ); } } else { const webLogin = await authWithWeb(client); @@ -166,7 +168,7 @@ async function initOauthClient( console.error( 'Failed to open browser automatically. Please try running again with NO_BROWSER=true set.', ); - process.exit(1); + throw new FatalAuthenticationError('Failed to open browser.'); }); } catch (err) { console.error( @@ -174,7 +176,7 @@ async function initOauthClient( err, '\nPlease try running again with NO_BROWSER=true set.', ); - process.exit(1); + throw new FatalAuthenticationError('Failed to open browser.'); } console.log('Waiting for authentication...'); diff --git a/packages/core/src/utils/errors.ts b/packages/core/src/utils/errors.ts index a57186b2..a02399ea 100644 --- a/packages/core/src/utils/errors.ts +++ b/packages/core/src/utils/errors.ts @@ -25,6 +25,41 @@ export function getErrorMessage(error: unknown): string { } } +export class FatalError extends Error { + constructor( + message: string, + readonly exitCode: number, + ) { + super(message); + } +} + +export class FatalAuthenticationError extends FatalError { + constructor(message: string) { + super(message, 41); + } +} +export class FatalInputError extends FatalError { + constructor(message: string) { + super(message, 42); + } +} +export class FatalSandboxError extends FatalError { + constructor(message: string) { + super(message, 44); + } +} +export class FatalConfigError extends FatalError { + constructor(message: string) { + super(message, 52); + } +} +export class FatalTurnLimitedError extends FatalError { + constructor(message: string) { + super(message, 53); + } +} + export class ForbiddenError extends Error {} export class UnauthorizedError extends Error {} export class BadRequestError extends Error {}