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

@ -69,6 +69,7 @@ export async function handleQwenAuth(
prompt: undefined,
promptInteractive: undefined,
yolo: undefined,
bare: undefined,
approvalMode: undefined,
telemetry: undefined,
checkpointing: undefined,

View file

@ -579,6 +579,12 @@ describe('parseArguments', () => {
const argv = await parseArguments();
expect(argv.extensions).toEqual(['ext1', 'ext2']);
});
it('should parse --bare', async () => {
process.argv = ['node', 'script.js', '--bare'];
const argv = await parseArguments();
expect(argv.bare).toBe(true);
});
});
describe('loadCliConfig', () => {
@ -1242,6 +1248,17 @@ describe('Approval mode tool exclusion logic', () => {
}
});
it('should keep the bare toolset available in non-interactive bare mode', async () => {
process.argv = ['node', 'script.js', '--bare', '-p', 'test'];
const argv = await parseArguments();
const config = await loadCliConfig({}, argv, undefined, []);
const excludedTools = config.getPermissionsDeny();
expect(excludedTools).not.toContain(ToolNames.SHELL);
expect(excludedTools).not.toContain(ToolNames.EDIT);
expect(excludedTools).not.toContain(ToolNames.WRITE_FILE);
});
it('should merge approval mode exclusions with settings exclusions in auto-edit mode', async () => {
process.argv = [
'node',
@ -1648,6 +1665,90 @@ describe('loadCliConfig with includeDirectories', () => {
expected.length,
);
});
it('should ignore implicit startup context inputs in bare mode', async () => {
const mockCwd = path.resolve(path.sep, 'home', 'user', 'project');
const cliPath = path.resolve(path.sep, 'cli', 'path1');
const settingsPath = path.resolve(path.sep, 'settings', 'path1');
process.argv = [
'node',
'script.js',
'--bare',
'--include-directories',
cliPath,
];
const argv = await parseArguments();
const settings: Settings = {
context: {
includeDirectories: [settingsPath],
},
};
const config = await loadCliConfig(settings, argv, undefined, []);
expect(config.getWorkspaceContext().getDirectories()).toEqual([
mockCwd,
cliPath,
]);
});
it('should force minimal startup behavior in bare mode', async () => {
process.argv = ['node', 'script.js', '--bare'];
const argv = await parseArguments();
const settings: Settings = {
tools: {
core: [ToolNames.WEB_FETCH],
allowed: [ToolNames.WEB_FETCH],
exclude: [ToolNames.ASK_USER_QUESTION],
},
hooks: {
PreToolUse: [],
} as Record<string, unknown>,
memory: {
enableManagedAutoMemory: true,
},
security: {
allowedHttpHookUrls: ['https://hooks.example.com/*'],
},
mcp: {
allowed: ['test-server'],
},
mcpServers: {
'test-server': {
command: 'node',
args: ['server.js'],
},
},
};
const config = await loadCliConfig(settings, argv, undefined, []);
expect(config.getCoreTools()).toEqual([
ToolNames.READ_FILE,
ToolNames.EDIT,
ToolNames.SHELL,
]);
expect(config.getDisableAllHooks()).toBe(true);
expect(config.getManagedAutoMemoryEnabled()).toBe(false);
expect(config.getToolDiscoveryCommand()).toBeUndefined();
expect(config.getToolCallCommand()).toBeUndefined();
expect(config.getMcpServers()).toEqual({});
expect(config.getWebSearchConfig()).toBeUndefined();
expect(config.isLspEnabled()).toBe(false);
});
it('should ignore coreTools overrides in bare mode', async () => {
process.argv = ['node', 'script.js', '--bare', '--core-tools', 'web_fetch'];
const argv = await parseArguments();
const config = await loadCliConfig({}, argv, undefined, []);
expect(config.getCoreTools()).toEqual([
ToolNames.READ_FILE,
ToolNames.EDIT,
ToolNames.SHELL,
]);
});
});
describe('loadCliConfig chatCompression', () => {

View file

@ -28,6 +28,7 @@ import {
NativeLspClient,
createDebugLogger,
NativeLspService,
isBareMode,
isToolEnabled,
} from '@qwen-code/qwen-code-core';
import { extensionsCommand } from '../commands/extensions.js';
@ -115,6 +116,7 @@ export interface CliArgs {
systemPrompt: string | undefined;
appendSystemPrompt: string | undefined;
yolo: boolean | undefined;
bare: boolean | undefined;
approvalMode: string | undefined;
telemetry: boolean | undefined;
checkpointing: boolean | undefined;
@ -260,6 +262,12 @@ export async function parseArguments(): Promise<CliArgs> {
description: 'Run in debug mode?',
default: false,
})
.option('bare', {
type: 'boolean',
description:
'Minimal mode: skip implicit startup auto-discovery and only honor explicitly provided CLI inputs.',
default: false,
})
.option('proxy', {
type: 'string',
description: 'Proxy for Qwen Code, like schema://user:password@host:port',
@ -737,6 +745,7 @@ export async function loadCliConfig(
},
): Promise<Config> {
const debugMode = isDebugMode(argv);
const bareMode = isBareMode(argv.bare);
// Set runtime output directory from settings (env var QWEN_RUNTIME_DIR
// is auto-detected inside getRuntimeBaseDir() at each call site).
@ -771,20 +780,24 @@ export async function loadCliConfig(
);
let outputLanguageFilePath: string | undefined;
if (fs.existsSync(projectOutputLanguagePath)) {
outputLanguageFilePath = projectOutputLanguagePath;
} else if (fs.existsSync(globalOutputLanguagePath)) {
outputLanguageFilePath = globalOutputLanguagePath;
if (!bareMode) {
if (fs.existsSync(projectOutputLanguagePath)) {
outputLanguageFilePath = projectOutputLanguagePath;
} else if (fs.existsSync(globalOutputLanguagePath)) {
outputLanguageFilePath = globalOutputLanguagePath;
}
}
const fileService = new FileDiscoveryService(cwd);
const includeDirectories = (settings.context?.includeDirectories || [])
const includeDirectories = (
bareMode ? [] : (settings.context?.includeDirectories ?? [])
)
.map(resolvePath)
.concat((argv.includeDirectories || []).map(resolvePath));
// LSP configuration: enabled only via --experimental-lsp flag
const lspEnabled = argv.experimentalLsp === true;
const lspEnabled = !bareMode && argv.experimentalLsp === true;
let lspClient: LspClient | undefined;
const question = argv.promptInteractive || argv.prompt || '';
const inputFormat: InputFormat =
@ -810,7 +823,7 @@ export async function loadCliConfig(
approvalMode = parseApprovalModeValue(argv.approvalMode);
} else if (argv.yolo) {
approvalMode = ApprovalMode.YOLO;
} else if (settings.tools?.approvalMode) {
} else if (!bareMode && settings.tools?.approvalMode) {
approvalMode = parseApprovalModeValue(settings.tools.approvalMode);
} else {
approvalMode = ApprovalMode.DEFAULT;
@ -888,17 +901,19 @@ export async function loadCliConfig(
// not auto-approve semantics. They are passed via the `coreTools` Config param
// and handled by PermissionManager.coreToolsAllowList.
const resolvedCoreTools: string[] = [
...(argv.coreTools ?? []),
...(settings.tools?.core ?? []),
...(bareMode ? [] : (argv.coreTools ?? [])),
...(bareMode ? [] : (settings.tools?.core ?? [])),
];
const mergedAllow: string[] = [
...(settings.permissions?.allow ?? []),
...(settings.tools?.allowed ?? []),
...(bareMode ? [] : (settings.permissions?.allow ?? [])),
...(bareMode ? [] : (settings.tools?.allowed ?? [])),
];
const mergedAsk: string[] = [
...(bareMode ? [] : (settings.permissions?.ask ?? [])),
];
const mergedAsk: string[] = [...(settings.permissions?.ask ?? [])];
const mergedDeny: string[] = [
...(settings.permissions?.deny ?? []),
...(settings.tools?.exclude ?? []),
...(bareMode ? [] : (settings.permissions?.deny ?? [])),
...(bareMode ? [] : (settings.tools?.exclude ?? [])),
];
// argv.allowedTools adds allow rules (auto-approve).
@ -941,7 +956,12 @@ export async function loadCliConfig(
// the caller has explicitly allowed them. Stream-JSON input is excluded from
// this logic because approval can be sent programmatically via JSON messages.
const isAcpMode = argv.acp || argv.experimentalAcp;
if (!interactive && !isAcpMode && inputFormat !== InputFormat.STREAM_JSON) {
if (
!bareMode &&
!interactive &&
!isAcpMode &&
inputFormat !== InputFormat.STREAM_JSON
) {
const denyUnlessAllowed = (toolName: ToolName): void => {
if (!isExplicitlyAllowed(toolName)) {
const name = toolName as string;
@ -974,7 +994,7 @@ export async function loadCliConfig(
if (argv.allowedMcpServerNames) {
allowedMcpServers = new Set(argv.allowedMcpServerNames.filter(Boolean));
excludedMcpServers = undefined;
} else {
} else if (!bareMode) {
allowedMcpServers = settings.mcp?.allowed
? new Set(settings.mcp.allowed.filter(Boolean))
: undefined;
@ -985,7 +1005,7 @@ export async function loadCliConfig(
const selectedAuthType =
(argv.authType as AuthType | undefined) ||
settings.security?.auth?.selectedType ||
(bareMode ? undefined : settings.security?.auth?.selectedType) ||
/* getAuthTypeFromEnv means no authType was explicitly provided, we infer the authType from env vars */
getAuthTypeFromEnv();
@ -1005,7 +1025,10 @@ export async function loadCliConfig(
const { model: resolvedModel } = resolvedCliConfig;
const sandboxConfig = await loadSandboxConfig(settings, argv);
const sandboxConfig = await loadSandboxConfig(
bareMode ? ({} as Settings) : settings,
argv,
);
const screenReader =
argv.screenReader !== undefined
? argv.screenReader
@ -1054,16 +1077,21 @@ export async function loadCliConfig(
sandbox: sandboxConfig,
targetDir: cwd,
includeDirectories,
loadMemoryFromIncludeDirectories:
settings.context?.loadFromIncludeDirectories || false,
loadMemoryFromIncludeDirectories: bareMode
? includeDirectories.length > 0
: (settings.context?.loadFromIncludeDirectories ?? false),
importFormat: settings.context?.importFormat || 'tree',
debugMode,
question,
systemPrompt: argv.systemPrompt,
appendSystemPrompt: argv.appendSystemPrompt,
// Legacy fields kept for backward compatibility with getCoreTools() etc.
coreTools: argv.coreTools || settings.tools?.core || undefined,
allowedTools: argv.allowedTools || settings.tools?.allowed || undefined,
coreTools: bareMode
? undefined
: argv.coreTools || settings.tools?.core || undefined,
allowedTools: bareMode
? argv.allowedTools || undefined
: argv.allowedTools || settings.tools?.allowed || undefined,
excludeTools: mergedDeny,
// New unified permissions (PermissionManager source of truth).
permissions: {
@ -1085,10 +1113,12 @@ export async function loadCliConfig(
currentSettings.setValue(settingScope, key, [...currentRules, rule]);
}
},
toolDiscoveryCommand: settings.tools?.discoveryCommand,
toolCallCommand: settings.tools?.callCommand,
mcpServerCommand: settings.mcp?.serverCommand,
mcpServers: settings.mcpServers || {},
toolDiscoveryCommand: bareMode
? undefined
: settings.tools?.discoveryCommand,
toolCallCommand: bareMode ? undefined : settings.tools?.callCommand,
mcpServerCommand: bareMode ? undefined : settings.mcp?.serverCommand,
mcpServers: bareMode ? {} : settings.mcpServers || {},
allowedMcpServers: allowedMcpServers
? Array.from(allowedMcpServers)
: undefined,
@ -1133,9 +1163,14 @@ export async function loadCliConfig(
generationConfigSources: resolvedCliConfig.sources,
generationConfig: resolvedCliConfig.generationConfig,
warnings: resolvedCliConfig.warnings,
allowedHttpHookUrls: settings.security?.allowedHttpHookUrls ?? [],
bareMode,
allowedHttpHookUrls: bareMode
? []
: (settings.security?.allowedHttpHookUrls ?? []),
cliVersion: await getCliVersion(),
webSearch: buildWebSearchConfig(argv, settings, selectedAuthType),
webSearch: bareMode
? undefined
: buildWebSearchConfig(argv, settings, selectedAuthType),
ideMode,
chatCompression: settings.model?.chatCompression,
folderTrust,
@ -1154,14 +1189,18 @@ export async function loadCliConfig(
output: {
format: outputSettingsFormat,
},
enableManagedAutoMemory: settings.memory?.enableManagedAutoMemory ?? true,
enableManagedAutoMemory: bareMode
? false
: (settings.memory?.enableManagedAutoMemory ?? true),
enableManagedAutoDream: settings.memory?.enableManagedAutoDream ?? false,
fastModel: settings.fastModel || undefined,
// Use separated hooks if provided, otherwise fall back to merged hooks
userHooks: hooksConfig?.userHooks ?? settings.hooks,
projectHooks: hooksConfig?.projectHooks,
hooks: settings.hooks, // Keep for backward compatibility
disableAllHooks: settings.disableAllHooks ?? false,
userHooks: bareMode
? undefined
: (hooksConfig?.userHooks ?? settings.hooks),
projectHooks: bareMode ? undefined : hooksConfig?.projectHooks,
hooks: bareMode ? undefined : settings.hooks, // Keep for backward compatibility
disableAllHooks: bareMode ? true : (settings.disableAllHooks ?? false),
channel: argv.channel,
// CLI flag wins over settings.json. `--json-fd` is fd-only (no settings
// equivalent — fd passing is a spawn-time concern). `--json-file` and

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,

View file

@ -8,7 +8,9 @@ import {
AuthType,
InputFormat,
isDebugLoggingDegraded,
isBareMode,
logUserPrompt,
QWEN_CODE_SIMPLE_ENV_VAR,
Storage,
type Config,
createDebugLogger,
@ -23,7 +25,11 @@ import { validateAuthMethod } from './config/auth.js';
import * as cliConfig from './config/config.js';
import { loadCliConfig, parseArguments } from './config/config.js';
import type { DnsResolutionOrder, LoadedSettings } from './config/settings.js';
import { getSettingsWarnings, loadSettings } from './config/settings.js';
import {
createMinimalSettings,
getSettingsWarnings,
loadSettings,
} from './config/settings.js';
import {
initializeApp,
type InitializationResult,
@ -290,13 +296,24 @@ export async function startInteractiveUI(
export async function main() {
profileCheckpoint('main_entry');
setupUnhandledRejectionHandler();
const settings = loadSettings();
await cleanupCheckpoints();
profileCheckpoint('after_load_settings');
if (process.argv.includes('--bare')) {
process.env[QWEN_CODE_SIMPLE_ENV_VAR] = '1';
}
let argv = await parseArguments();
profileCheckpoint('after_parse_arguments');
if (isBareMode(argv.bare)) {
process.env[QWEN_CODE_SIMPLE_ENV_VAR] = '1';
}
const settings = isBareMode(argv.bare)
? createMinimalSettings()
: loadSettings();
await cleanupCheckpoints();
profileCheckpoint('after_load_settings');
// Check for invalid input combinations early to prevent crashes
if (argv.promptInteractive && !process.stdin.isTTY) {
writeStderrLine(
@ -439,7 +456,9 @@ export async function main() {
profileCheckpoint('after_sandbox_check');
// Initialize output language file before config loads to ensure it's included in context
initializeLlmOutputLanguage(settings.merged.general?.outputLanguage);
if (!isBareMode(argv.bare)) {
initializeLlmOutputLanguage(settings.merged.general?.outputLanguage);
}
{
const config = await loadCliConfig(

View file

@ -55,6 +55,20 @@ describe('BundledSkillLoader', () => {
expect(commands).toEqual([]);
});
it('should return empty array in bare mode', async () => {
const skill = makeSkill();
mockSkillManager.listSkills.mockResolvedValue([skill]);
(
mockConfig as Config & { getBareMode: ReturnType<typeof vi.fn> }
).getBareMode = vi.fn().mockReturnValue(true);
const loader = new BundledSkillLoader(mockConfig);
const commands = await loader.loadCommands(signal);
expect(commands).toEqual([]);
expect(mockSkillManager.listSkills).not.toHaveBeenCalled();
});
it('should load bundled skills as slash commands', async () => {
const skill = makeSkill();
mockSkillManager.listSkills.mockResolvedValue([skill]);

View file

@ -26,6 +26,11 @@ export class BundledSkillLoader implements ICommandLoader {
constructor(private readonly config: Config | null) {}
async loadCommands(_signal: AbortSignal): Promise<SlashCommand[]> {
if (this.config?.getBareMode?.()) {
debugLogger.debug('Bare mode enabled, skipping bundled skills');
return [];
}
const skillManager = this.config?.getSkillManager();
if (!skillManager) {
debugLogger.debug('SkillManager not available, skipping bundled skills');

View file

@ -308,6 +308,33 @@ describe('FileCommandLoader', () => {
}
});
it('skips auto-discovered commands in bare mode', async () => {
const userCommandsDir = Storage.getUserCommandsDir();
const projectCommandsDir = new Storage(
process.cwd(),
).getProjectCommandsDir();
mock({
[userCommandsDir]: {
'user.toml': 'prompt = "User prompt"',
},
[projectCommandsDir]: {
'project.toml': 'prompt = "Project prompt"',
},
});
const mockConfig = {
getProjectRoot: vi.fn(() => process.cwd()),
getExtensions: vi.fn(() => []),
getFolderTrustFeature: vi.fn(() => false),
getFolderTrust: vi.fn(() => false),
getBareMode: vi.fn(() => true),
} as unknown as Config;
const loader = new FileCommandLoader(mockConfig);
const commands = await loader.loadCommands(signal);
expect(commands).toEqual([]);
});
it('ignores files with TOML syntax errors', async () => {
const userCommandsDir = Storage.getUserCommandsDir();
mock({

View file

@ -79,6 +79,11 @@ export class FileCommandLoader implements ICommandLoader {
* @returns A promise that resolves to an array of all loaded SlashCommands.
*/
async loadCommands(signal: AbortSignal): Promise<SlashCommand[]> {
if (this.config?.getBareMode?.()) {
debugLogger.debug('Bare mode enabled, skipping auto-discovered commands');
return [];
}
const allCommands: SlashCommand[] = [];
const globOptions = {
nodir: true,