feat(cli): add bare startup mode (#3448)

* feat(cli): add bare startup mode

Skip implicit startup discovery in bare mode while keeping explicit inputs such as include directories and extension overrides.

Add a repository plan document and targeted tests for config, startup, skills, extensions, and memory discovery.

* fix(bare): enforce explicit-only startup behavior

* fix(cli): preserve bare tools in non-interactive mode

* chore(docs): remove bare mode planning note
This commit is contained in:
易良 2026-04-20 10:01:59 +08:00 committed by GitHub
parent cfe142e9a3
commit 41f71ab7e7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 750 additions and 72 deletions

View file

@ -19,6 +19,7 @@ import {
validateDnsResolutionOrder,
startInteractiveUI,
} from './gemini.js';
import type { CliArgs } from './config/config.js';
import { type LoadedSettings } from './config/settings.js';
import { appEvents, AppEvent } from './utils/events.js';
import type { Config } from '@qwen-code/qwen-code-core';
@ -40,6 +41,7 @@ vi.mock('./config/settings.js', async (importOriginal) => {
return {
...actual,
loadSettings: vi.fn(),
createMinimalSettings: vi.fn(),
};
});
@ -109,6 +111,7 @@ vi.mock('./core/initializer.js', () => ({
describe('gemini.tsx main function', () => {
let originalEnvGeminiSandbox: string | undefined;
let originalEnvSandbox: string | undefined;
let originalEnvQwenCodeSimple: string | undefined;
let initialUnhandledRejectionListeners: NodeJS.UnhandledRejectionListener[] =
[];
@ -116,8 +119,10 @@ describe('gemini.tsx main function', () => {
// Store and clear sandbox-related env variables to ensure a consistent test environment
originalEnvGeminiSandbox = process.env['QWEN_SANDBOX'];
originalEnvSandbox = process.env['SANDBOX'];
originalEnvQwenCodeSimple = process.env['QWEN_CODE_SIMPLE'];
delete process.env['QWEN_SANDBOX'];
delete process.env['SANDBOX'];
delete process.env['QWEN_CODE_SIMPLE'];
initialUnhandledRejectionListeners =
process.listeners('unhandledRejection');
@ -135,6 +140,11 @@ describe('gemini.tsx main function', () => {
} else {
delete process.env['SANDBOX'];
}
if (originalEnvQwenCodeSimple !== undefined) {
process.env['QWEN_CODE_SIMPLE'] = originalEnvQwenCodeSimple;
} else {
delete process.env['QWEN_CODE_SIMPLE'];
}
const currentListeners = process.listeners('unhandledRejection');
const addedListener = currentListeners.find(
@ -215,6 +225,87 @@ describe('gemini.tsx main function', () => {
processExitSpy.mockRestore();
});
it('should skip full settings discovery in bare mode', async () => {
const originalArgv = process.argv;
process.argv = ['node', 'script.js', '--bare'];
const { loadCliConfig, parseArguments } = await import(
'./config/config.js'
);
const { loadSettings, createMinimalSettings } = await import(
'./config/settings.js'
);
const { loadSandboxConfig } = await import('./config/sandboxConfig.js');
const { relaunchAppInChildProcess } = await import('./utils/relaunch.js');
const nonInteractiveModule = await import('./nonInteractiveCli.js');
const processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((code) => {
throw new MockProcessExitError(code);
});
const minimalSettings = {
errors: [],
merged: {},
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
migrationWarnings: [],
getUserHooks: () => undefined,
getProjectHooks: () => undefined,
};
const configStub = {
isInteractive: () => false,
getQuestion: () => 'bare prompt',
getSandbox: () => false,
getDebugMode: () => false,
getListExtensions: () => false,
getMcpServers: () => ({}),
initialize: vi.fn().mockResolvedValue(undefined),
getIdeMode: () => false,
getExperimentalZedIntegration: () => false,
getScreenReader: () => false,
getGeminiMdFileCount: () => 0,
getProjectRoot: () => '/',
getOutputFormat: () => OutputFormat.TEXT,
getWarnings: () => [],
getModelsConfig: () => ({ getCurrentAuthType: () => null }),
getSessionId: () => 'test-session-id',
} as unknown as Config;
vi.mocked(parseArguments).mockResolvedValue({
bare: true,
} as unknown as CliArgs);
vi.mocked(createMinimalSettings).mockReturnValue(minimalSettings as never);
vi.mocked(loadSandboxConfig).mockResolvedValue(undefined);
vi.mocked(relaunchAppInChildProcess).mockResolvedValue(undefined);
vi.mocked(loadCliConfig).mockResolvedValue(configStub);
vi.spyOn(nonInteractiveModule, 'runNonInteractive').mockResolvedValue();
try {
await main();
} catch (error) {
if (!(error instanceof MockProcessExitError)) {
throw error;
}
} finally {
process.argv = originalArgv;
processExitSpy.mockRestore();
}
expect(createMinimalSettings).toHaveBeenCalledOnce();
expect(loadSettings).not.toHaveBeenCalled();
expect(loadCliConfig).toHaveBeenCalledWith(
{},
expect.objectContaining({ bare: true }),
process.cwd(),
undefined,
{
userHooks: undefined,
projectHooks: undefined,
},
);
});
it('should log unhandled promise rejections and open debug console on first error', async () => {
const processExitSpy = vi
.spyOn(process, 'exit')
@ -483,6 +574,7 @@ describe('gemini.tsx main function kitty protocol', () => {
appendSystemPrompt: undefined,
query: undefined,
yolo: undefined,
bare: undefined,
approvalMode: undefined,
telemetry: undefined,
checkpointing: undefined,