Merge branch 'main' into feat/image-attachment

This commit is contained in:
LaZzyMan 2026-02-10 14:16:21 +08:00
commit 56030f9291
609 changed files with 26677 additions and 12343 deletions

View file

@ -15,8 +15,6 @@ import type {
import { Config } from '@qwen-code/qwen-code-core';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import type { Settings } from './settings.js';
export const server = setupServer();
// TODO(richieforeman): Consider moving this to test setup globally.
@ -271,7 +269,7 @@ describe('Configuration Integration Tests', () => {
'test',
];
const argv = await parseArguments({} as Settings);
const argv = await parseArguments();
// Verify that the argument was parsed correctly
expect(argv.approvalMode).toBe('auto-edit');
@ -295,7 +293,7 @@ describe('Configuration Integration Tests', () => {
'test',
];
const argv = await parseArguments({} as Settings);
const argv = await parseArguments();
expect(argv.approvalMode).toBe('plan');
expect(argv.prompt).toBe('test');
@ -318,7 +316,7 @@ describe('Configuration Integration Tests', () => {
'test',
];
const argv = await parseArguments({} as Settings);
const argv = await parseArguments();
expect(argv.approvalMode).toBe('yolo');
expect(argv.prompt).toBe('test');
@ -341,7 +339,7 @@ describe('Configuration Integration Tests', () => {
'test',
];
const argv = await parseArguments({} as Settings);
const argv = await parseArguments();
expect(argv.approvalMode).toBe('default');
expect(argv.prompt).toBe('test');
@ -357,7 +355,7 @@ describe('Configuration Integration Tests', () => {
try {
process.argv = ['node', 'script.js', '--yolo', '-p', 'test'];
const argv = await parseArguments({} as Settings);
const argv = await parseArguments();
expect(argv.yolo).toBe(true);
expect(argv.approvalMode).toBeUndefined(); // Should NOT be set when using --yolo
@ -374,7 +372,7 @@ describe('Configuration Integration Tests', () => {
process.argv = ['node', 'script.js', '--approval-mode', 'invalid_mode'];
// Should throw during argument parsing due to yargs validation
await expect(parseArguments({} as Settings)).rejects.toThrow();
await expect(parseArguments()).rejects.toThrow();
} finally {
process.argv = originalArgv;
}
@ -393,7 +391,7 @@ describe('Configuration Integration Tests', () => {
];
// Should throw during argument parsing due to conflict validation
await expect(parseArguments({} as Settings)).rejects.toThrow();
await expect(parseArguments()).rejects.toThrow();
} finally {
process.argv = originalArgv;
}
@ -406,7 +404,7 @@ describe('Configuration Integration Tests', () => {
// Test that no approval mode arguments defaults to no flags set
process.argv = ['node', 'script.js', '-p', 'test'];
const argv = await parseArguments({} as Settings);
const argv = await parseArguments();
expect(argv.approvalMode).toBeUndefined();
expect(argv.yolo).toBe(false);

File diff suppressed because it is too large Load diff

View file

@ -29,6 +29,7 @@ import {
ShellTool,
WriteFileTool,
NativeLspClient,
createDebugLogger,
NativeLspService,
} from '@qwen-code/qwen-code-core';
import { extensionsCommand } from '../commands/extensions.js';
@ -51,16 +52,9 @@ import { mcpCommand } from '../commands/mcp.js';
import { isWorkspaceTrusted } from './trustedFolders.js';
import { buildWebSearchConfig } from './webSearch.js';
import { writeStderrLine } from '../utils/stdioHelpers.js';
// Simple console logger for now - replace with actual logger if available
const logger = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
debug: (...args: any[]) => console.debug('[DEBUG]', ...args),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
warn: (...args: any[]) => console.warn('[WARN]', ...args),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
error: (...args: any[]) => console.error('[ERROR]', ...args),
};
const debugLogger = createDebugLogger('CONFIG');
const VALID_APPROVAL_MODE_VALUES = [
'plan',
@ -103,7 +97,6 @@ export interface CliArgs {
debug: boolean | undefined;
prompt: string | undefined;
promptInteractive: string | undefined;
allFiles: boolean | undefined;
yolo: boolean | undefined;
approvalMode: string | undefined;
telemetry: boolean | undefined;
@ -117,7 +110,6 @@ export interface CliArgs {
allowedTools: string[] | undefined;
acp: boolean | undefined;
experimentalAcp: boolean | undefined;
experimentalSkills: boolean | undefined;
experimentalLsp: boolean | undefined;
extensions: string[] | undefined;
listExtensions: boolean | undefined;
@ -133,7 +125,6 @@ export interface CliArgs {
webSearchDefault: string | undefined;
screenReader: boolean | undefined;
vlmSwitchMode: string | undefined;
useSmartEdit: boolean | undefined;
inputFormat?: string | undefined;
outputFormat: string | undefined;
includePartialMessages?: boolean;
@ -168,14 +159,15 @@ function normalizeOutputFormat(
return OutputFormat.TEXT;
}
export async function parseArguments(settings: Settings): Promise<CliArgs> {
export async function parseArguments(): Promise<CliArgs> {
let rawArgv = hideBin(process.argv);
// hack: if the first argument is the CLI entry point, remove it
if (
rawArgv.length > 0 &&
(rawArgv[0].endsWith('/dist/qwen-cli/cli.js') ||
rawArgv[0].endsWith('/dist/cli.js'))
rawArgv[0].endsWith('/dist/cli.js') ||
rawArgv[0].endsWith('/dist/cli/cli.js'))
) {
rawArgv = rawArgv.slice(1);
}
@ -291,12 +283,6 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
type: 'string',
description: 'Sandbox image URI.',
})
.option('all-files', {
alias: ['a'],
type: 'boolean',
description: 'Include ALL files in context?',
default: false,
})
.option('yolo', {
alias: 'y',
type: 'boolean',
@ -327,15 +313,9 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
})
.option('experimental-skills', {
type: 'boolean',
description: 'Enable experimental Skills feature',
default: (() => {
const legacySkills = (
settings as Settings & {
tools?: { experimental?: { skills?: boolean } };
}
).tools?.experimental?.skills;
return settings.experimental?.skills ?? legacySkills ?? false;
})(),
description:
'Deprecated: Skills are now enabled by default. This flag is ignored.',
hidden: true,
})
.option('experimental-lsp', {
type: 'boolean',
@ -513,17 +493,13 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
'checkpointing',
'Use the "general.checkpointing.enabled" setting in settings.json instead. This flag will be removed in a future version.',
)
.deprecateOption(
'all-files',
'Use @ includes in the application instead. This flag will be removed in a future version.',
)
.deprecateOption(
'prompt',
'Use the positional prompt instead. This flag will be removed in a future version.',
)
// Ensure validation flows through .fail() for clean UX
.fail((msg: string, err: Error | undefined, yargs: Argv) => {
console.error(msg || err?.message || 'Unknown error');
writeStderrLine(msg || err?.message || 'Unknown error');
yargs.showHelp();
process.exit(1);
})
@ -615,7 +591,7 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
// Handle deprecated --experimental-acp flag
if (result['experimentalAcp']) {
console.warn(
writeStderrLine(
'\x1b[33m⚠ Warning: --experimental-acp is deprecated and will be removed in a future release. Please use --acp instead.\x1b[0m',
);
// Map experimental-acp to acp if acp is not explicitly set
@ -638,7 +614,6 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
export async function loadHierarchicalGeminiMemory(
currentWorkingDirectory: string,
includeDirectoriesToReadGemini: readonly string[] = [],
debugMode: boolean,
fileService: FileDiscoveryService,
extensionContextFilePaths: string[] = [],
folderTrust: boolean,
@ -653,17 +628,10 @@ export async function loadHierarchicalGeminiMemory(
// function to signal that it should skip the workspace search.
const effectiveCwd = isHomeDirectory ? '' : currentWorkingDirectory;
if (debugMode) {
logger.debug(
`CLI: Delegating hierarchical memory load to server for CWD: ${currentWorkingDirectory} (memoryImportFormat: ${memoryImportFormat})`,
);
}
// Directly call the server function with the corrected path.
return loadServerHierarchicalMemory(
effectiveCwd,
includeDirectoriesToReadGemini,
debugMode,
fileService,
extensionContextFilePaths,
folderTrust,
@ -710,11 +678,7 @@ export async function loadCliConfig(
'output-language.md',
);
if (fs.existsSync(outputLanguageFilePath)) {
if (debugMode) {
logger.debug(
`Found output-language.md, adding to context files: ${outputLanguageFilePath}`,
);
}
// output-language.md found - will be added to context files
} else {
outputLanguageFilePath = undefined;
}
@ -764,7 +728,7 @@ export async function loadCliConfig(
approvalMode !== ApprovalMode.DEFAULT &&
approvalMode !== ApprovalMode.PLAN
) {
logger.warn(
writeStderrLine(
`Approval mode overridden to "default" because the current folder is not trusted.`,
);
approvalMode = ApprovalMode.DEFAULT;
@ -931,7 +895,7 @@ export async function loadCliConfig(
sessionData = await sessionService.loadSession(argv.resume);
if (!sessionData) {
const message = `No saved session found with ID ${argv.resume}. Run \`qwen --resume\` without an ID to choose from existing sessions.`;
console.log(message);
writeStderrLine(message);
process.exit(1);
}
}
@ -951,7 +915,6 @@ export async function loadCliConfig(
importFormat: settings.context?.importFormat || 'tree',
debugMode,
question,
fullContext: argv.allFiles || false,
coreTools: argv.coreTools || settings.tools?.core || undefined,
allowedTools: argv.allowedTools || settings.tools?.allowed || undefined,
excludeTools,
@ -990,7 +953,6 @@ export async function loadCliConfig(
maxSessionTurns:
argv.maxSessionTurns ?? settings.model?.maxSessionTurns ?? -1,
experimentalZedIntegration: argv.acp || argv.experimentalAcp || false,
experimentalSkills: argv.experimentalSkills || false,
listExtensions: argv.listExtensions || false,
overrideExtensions: overrideExtensions || argv.extensions,
noBrowser: !!process.env['NO_BROWSER'],
@ -1020,7 +982,6 @@ export async function loadCliConfig(
truncateToolOutputLines: settings.tools?.truncateToolOutputLines,
enableToolOutputTruncation: settings.tools?.enableToolOutputTruncation,
eventEmitter: appEvents,
useSmartEdit: argv.useSmartEdit ?? settings.useSmartEdit,
gitCoAuthor: settings.general?.gitCoAuthor,
output: {
format: outputSettingsFormat,
@ -1056,7 +1017,7 @@ export async function loadCliConfig(
lspClient = new NativeLspClient(lspService);
config.setLspClient(lspClient);
} catch (err) {
logger.warn('Failed to initialize native LSP service:', err);
debugLogger.warn('Failed to initialize native LSP service:', err);
}
}

View file

@ -45,7 +45,6 @@ export enum Command {
PASTE_CLIPBOARD_IMAGE = 'pasteClipboardImage',
// App level bindings
SHOW_ERROR_DETAILS = 'showErrorDetails',
TOGGLE_TOOL_DESCRIPTIONS = 'toggleToolDescriptions',
TOGGLE_IDE_CONTEXT_DETAIL = 'toggleIDEContextDetail',
QUIT = 'quit',
@ -166,7 +165,6 @@ export const defaultKeyBindings: KeyBindingConfig = {
],
// App level bindings
[Command.SHOW_ERROR_DETAILS]: [{ key: 'o', ctrl: true }],
[Command.TOGGLE_TOOL_DESCRIPTIONS]: [{ key: 't', ctrl: true }],
[Command.TOGGLE_IDE_CONTEXT_DETAIL]: [{ key: 'g', ctrl: true }],
[Command.QUIT]: [{ key: 'c', ctrl: true }],

View file

@ -643,6 +643,105 @@ describe('Settings Loading and Merging', () => {
expect(writtenContent[SETTINGS_VERSION_KEY]).toBe(SETTINGS_VERSION);
});
it('should consolidate disableAutoUpdate and disableUpdateNag - both false means enableAutoUpdate is true', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
// V1 settings with both disable* settings as false
const legacySettingsContent = {
disableAutoUpdate: false,
disableUpdateNag: false,
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(legacySettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
// Both are false, so enableAutoUpdate should be true
expect(settings.merged.general?.enableAutoUpdate).toBe(true);
});
it('should consolidate disableAutoUpdate and disableUpdateNag - any true means enableAutoUpdate is false', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
// V1 settings with disableAutoUpdate=false but disableUpdateNag=true
const legacySettingsContent = {
disableAutoUpdate: false,
disableUpdateNag: true,
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(legacySettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
// disableUpdateNag is true, so enableAutoUpdate should be false
expect(settings.merged.general?.enableAutoUpdate).toBe(false);
});
it('should consolidate disableAutoUpdate and disableUpdateNag - disableAutoUpdate=true takes precedence', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
// V1 settings with disableAutoUpdate=true
const legacySettingsContent = {
disableAutoUpdate: true,
disableUpdateNag: false,
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(legacySettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
// disableAutoUpdate is true, so enableAutoUpdate should be false
expect(settings.merged.general?.enableAutoUpdate).toBe(false);
});
it('should bump version to 3 even when V2 settings already have V3-compatible content', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
// V2 settings that already have V3-compatible keys (no migration needed)
const v2SettingsWithV3Content = {
$version: 2,
general: {
enableAutoUpdate: true,
},
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(v2SettingsWithV3Content);
return '{}';
},
);
loadSettings(MOCK_WORKSPACE_DIR);
// Version should be bumped to 3 even though no keys needed migration
const writeCall = (fs.writeFileSync as Mock).mock.calls.find(
(call: unknown[]) => call[0] === USER_SETTINGS_PATH,
);
expect(writeCall).toBeDefined();
const writtenContent = JSON.parse(writeCall[1] as string);
expect(writtenContent.$version).toBe(SETTINGS_VERSION);
});
it('should correctly merge and migrate legacy array properties from multiple scopes', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
const legacyUserSettings = {
@ -2586,11 +2685,274 @@ describe('Settings Loading and Merging', () => {
expect(process.env['TESTTEST']).toEqual('1234');
});
it('does not load env files from untrusted spaces', () => {
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false });
it('does not load project .env files from untrusted workspaces', () => {
delete process.env['PROJECT_ENV_VAR'];
const cwdSpy = vi
.spyOn(process, 'cwd')
.mockReturnValue(MOCK_WORKSPACE_DIR);
const projectEnvPath = path.join(MOCK_WORKSPACE_DIR, '.env');
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: false,
source: 'file',
});
(mockFsExistsSync as Mock).mockImplementation((p: fs.PathLike) =>
[USER_SETTINGS_PATH, projectEnvPath].includes(p.toString()),
);
const userSettingsContent: Settings = {
ui: {
theme: 'dark',
},
security: {
folderTrust: {
enabled: true,
},
},
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
if (p === projectEnvPath) return 'PROJECT_ENV_VAR=from_project';
return '{}';
},
);
loadEnvironment(loadSettings(MOCK_WORKSPACE_DIR).merged);
expect(process.env['TESTTEST']).not.toEqual('1234');
// Project .env should NOT be loaded when workspace is untrusted
expect(process.env['PROJECT_ENV_VAR']).toBeUndefined();
cwdSpy.mockRestore();
});
describe('settings.env field', () => {
const originalEnv = { ...process.env };
beforeEach(() => {
process.env = { ...originalEnv };
delete process.env['ENV_FROM_SETTINGS'];
delete process.env['ENV_OVERRIDE_TEST'];
delete process.env['SYSTEM_ENV_VAR'];
delete process.env['MULTI_VAR_A'];
delete process.env['MULTI_VAR_B'];
delete process.env['MULTI_VAR_C'];
delete process.env['USER_ENV_VAR'];
delete process.env['WORKSPACE_ENV_VAR'];
});
afterEach(() => {
process.env = originalEnv;
});
it('should load environment variables from settings.env as fallback', () => {
const userSettingsContent: Settings = {
env: {
ENV_FROM_SETTINGS: 'settings_value',
},
};
(mockFsExistsSync as Mock).mockImplementation((p: fs.PathLike) =>
[USER_SETTINGS_PATH].includes(p.toString()),
);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
return '{}';
},
);
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true,
source: 'file',
});
// loadSettings internally calls loadEnvironment with userSettings
loadSettings(MOCK_WORKSPACE_DIR);
expect(process.env['ENV_FROM_SETTINGS']).toEqual('settings_value');
});
it('should allow .env file to override settings.env values', () => {
const geminiEnvPath = path.resolve(path.join(QWEN_DIR, '.env'));
const userSettingsContent: Settings = {
env: {
ENV_OVERRIDE_TEST: 'from_settings',
},
};
(mockFsExistsSync as Mock).mockImplementation((p: fs.PathLike) =>
[USER_SETTINGS_PATH, geminiEnvPath].includes(p.toString()),
);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
if (p === geminiEnvPath) return 'ENV_OVERRIDE_TEST=from_dotenv';
return '{}';
},
);
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true,
source: 'file',
});
// loadSettings internally calls loadEnvironment with merged settings
loadSettings(MOCK_WORKSPACE_DIR);
// .env file has higher priority than settings.env (loaded first, no-override)
expect(process.env['ENV_OVERRIDE_TEST']).toEqual('from_dotenv');
});
it('should not override existing system environment variables', () => {
process.env['SYSTEM_ENV_VAR'] = 'system_value';
const geminiEnvPath = path.resolve(path.join(QWEN_DIR, '.env'));
const userSettingsContent: Settings = {
env: {
SYSTEM_ENV_VAR: 'from_settings',
},
};
(mockFsExistsSync as Mock).mockImplementation((p: fs.PathLike) =>
[USER_SETTINGS_PATH, geminiEnvPath].includes(p.toString()),
);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
if (p === geminiEnvPath) return 'SYSTEM_ENV_VAR=from_dotenv';
return '{}';
},
);
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true,
source: 'file',
});
// loadSettings internally calls loadEnvironment with userSettings
loadSettings(MOCK_WORKSPACE_DIR);
// System environment variable should have highest priority
expect(process.env['SYSTEM_ENV_VAR']).toEqual('system_value');
});
it('should support multiple env variables in settings.env', () => {
const userSettingsContent: Settings = {
env: {
MULTI_VAR_A: 'value_a',
MULTI_VAR_B: 'value_b',
MULTI_VAR_C: 'value_c',
},
};
(mockFsExistsSync as Mock).mockImplementation((p: fs.PathLike) =>
[USER_SETTINGS_PATH].includes(p.toString()),
);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
return '{}';
},
);
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true,
source: 'file',
});
// loadSettings internally calls loadEnvironment with userSettings
loadSettings(MOCK_WORKSPACE_DIR);
expect(process.env['MULTI_VAR_A']).toEqual('value_a');
expect(process.env['MULTI_VAR_B']).toEqual('value_b');
expect(process.env['MULTI_VAR_C']).toEqual('value_c');
});
it('should load settings.env from both user and workspace settings', () => {
const workspaceSettingsContent = {
env: {
WORKSPACE_ENV_VAR: 'workspace_value',
},
};
const userSettingsContent: Settings = {
env: {
USER_ENV_VAR: 'user_value',
},
};
(mockFsExistsSync as Mock).mockImplementation((p: fs.PathLike) =>
[USER_SETTINGS_PATH, MOCK_WORKSPACE_SETTINGS_PATH].includes(
p.toString(),
),
);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
);
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true,
source: 'file',
});
// loadSettings internally calls loadEnvironment with merged settings
loadSettings(MOCK_WORKSPACE_DIR);
// Both user-level and workspace-level env should be loaded
expect(process.env['USER_ENV_VAR']).toEqual('user_value');
expect(process.env['WORKSPACE_ENV_VAR']).toEqual('workspace_value');
});
it('should load user-level settings.env even when workspace is untrusted', () => {
const userSettingsContent: Settings = {
env: {
USER_ENV_VAR: 'user_value',
},
};
const workspaceSettingsContent = {
env: {
WORKSPACE_ENV_VAR: 'workspace_value',
},
};
(mockFsExistsSync as Mock).mockImplementation((p: fs.PathLike) =>
[USER_SETTINGS_PATH, MOCK_WORKSPACE_SETTINGS_PATH].includes(
p.toString(),
),
);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
);
// Workspace is untrusted
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: false,
source: 'file',
});
loadSettings(MOCK_WORKSPACE_DIR);
// User-level settings.env should still be loaded even when untrusted
expect(process.env['USER_ENV_VAR']).toEqual('user_value');
// Workspace-level settings.env should NOT be loaded (filtered by mergeSettings)
expect(process.env['WORKSPACE_ENV_VAR']).toBeUndefined();
});
});
});

View file

@ -30,6 +30,7 @@ import {
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
import { customDeepMerge, type MergeableObject } from '../utils/deepMerge.js';
import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js';
import { writeStderrLine } from '../utils/stdioHelpers.js';
function getMergeStrategyForPath(path: string[]): MergeStrategy | undefined {
let current: SettingDefinition | undefined = undefined;
@ -56,7 +57,7 @@ export const DEFAULT_EXCLUDED_ENV_VARS = ['DEBUG', 'DEBUG_MODE'];
const MIGRATE_V2_OVERWRITE = true;
// Settings version to track migration state
export const SETTINGS_VERSION = 2;
export const SETTINGS_VERSION = 3;
export const SETTINGS_VERSION_KEY = '$version';
const MIGRATION_MAP: Record<string, string> = {
@ -73,8 +74,6 @@ const MIGRATION_MAP: Record<string, string> = {
customThemes: 'ui.customThemes',
customWittyPhrases: 'ui.customWittyPhrases',
debugKeystrokeLogging: 'general.debugKeystrokeLogging',
disableAutoUpdate: 'general.disableAutoUpdate',
disableUpdateNag: 'general.disableUpdateNag',
dnsResolutionOrder: 'advanced.dnsResolutionOrder',
enforcedAuthType: 'security.auth.enforcedType',
excludeTools: 'tools.exclude',
@ -127,6 +126,43 @@ const MIGRATION_MAP: Record<string, string> = {
visionModelPreview: 'experimental.visionModelPreview',
};
// Settings that need boolean inversion during migration (V1 -> V3)
// Old negative naming -> new positive naming with inverted value
const INVERTED_BOOLEAN_MIGRATIONS: Record<string, string> = {
disableAutoUpdate: 'general.enableAutoUpdate',
disableUpdateNag: 'general.enableAutoUpdate',
disableLoadingPhrases: 'ui.accessibility.enableLoadingPhrases',
disableFuzzySearch: 'context.fileFiltering.enableFuzzySearch',
disableCacheControl: 'model.generationConfig.enableCacheControl',
};
// Consolidated settings: multiple old V1 keys that map to a single new key.
// Policy: if ANY of the old disable* settings is true, the new enable* should be false.
const CONSOLIDATED_SETTINGS: Record<string, string[]> = {
'general.enableAutoUpdate': ['disableAutoUpdate', 'disableUpdateNag'],
};
// V2 nested paths that need inversion when migrating to V3
const INVERTED_V2_PATHS: Record<string, string> = {
'general.disableAutoUpdate': 'general.enableAutoUpdate',
'general.disableUpdateNag': 'general.enableAutoUpdate',
'ui.accessibility.disableLoadingPhrases':
'ui.accessibility.enableLoadingPhrases',
'context.fileFiltering.disableFuzzySearch':
'context.fileFiltering.enableFuzzySearch',
'model.generationConfig.disableCacheControl':
'model.generationConfig.enableCacheControl',
};
// Consolidated V2 paths: multiple old paths that map to a single new path.
// Policy: if ANY of the old disable* settings is true, the new enable* should be false.
const CONSOLIDATED_V2_PATHS: Record<string, string[]> = {
'general.enableAutoUpdate': [
'general.disableAutoUpdate',
'general.disableUpdateNag',
],
};
export function getSystemSettingsPath(): string {
if (process.env['QWEN_CODE_SYSTEM_SETTINGS_PATH']) {
return process.env['QWEN_CODE_SYSTEM_SETTINGS_PATH'];
@ -168,7 +204,7 @@ export interface SummarizeToolOutputSettings {
}
export interface AccessibilitySettings {
disableLoadingPhrases?: boolean;
enableLoadingPhrases?: boolean;
screenReader?: boolean;
}
@ -209,6 +245,14 @@ function setNestedProperty(
current[lastKey] = value;
}
// Dynamically determine the top-level keys from the V2 settings structure.
const KNOWN_V2_CONTAINERS = new Set([
...Object.values(MIGRATION_MAP).map((path) => path.split('.')[0]),
...Object.values(INVERTED_BOOLEAN_MIGRATIONS).map(
(path) => path.split('.')[0],
),
]);
export function needsMigration(settings: Record<string, unknown>): boolean {
// Check version field first - if present and matches current version, no migration needed
if (SETTINGS_VERSION_KEY in settings) {
@ -237,10 +281,20 @@ export function needsMigration(settings: Record<string, unknown>): boolean {
return true;
});
return hasV1Keys;
// Also check for old inverted boolean keys (disable* -> enable*)
const hasInvertedBooleanKeys = Object.keys(INVERTED_BOOLEAN_MIGRATIONS).some(
(v1Key) => v1Key in settings,
);
return hasV1Keys || hasInvertedBooleanKeys;
}
function migrateSettingsToV2(
/**
* Migrates V1 (flat) settings directly to V3.
* This includes both structural migration (flat -> nested) and boolean
* inversion (disable* -> enable*), so migrateV2ToV3 will be skipped.
*/
function migrateV1ToV3(
flatSettings: Record<string, unknown>,
): Record<string, unknown> | null {
if (!needsMigration(flatSettings)) {
@ -272,6 +326,43 @@ function migrateSettingsToV2(
}
}
// Handle consolidated settings first (multiple old keys -> single new key)
// Policy: if ANY of the old disable* settings is true, the new enable* should be false
for (const [newPath, oldKeys] of Object.entries(CONSOLIDATED_SETTINGS)) {
let hasAnyDisable = false;
let hasAnyValue = false;
for (const oldKey of oldKeys) {
if (flatKeys.has(oldKey)) {
hasAnyValue = true;
const oldValue = flatSettings[oldKey];
if (typeof oldValue === 'boolean' && oldValue === true) {
hasAnyDisable = true;
}
flatKeys.delete(oldKey);
}
}
if (hasAnyValue) {
// enableAutoUpdate = !hasAnyDisable (if any disable* was true, enable should be false)
setNestedProperty(v2Settings, newPath, !hasAnyDisable);
}
}
// Handle remaining V1 settings that need boolean inversion (disable* -> enable*)
// Skip keys that were already handled by consolidated settings
const consolidatedKeys = new Set(Object.values(CONSOLIDATED_SETTINGS).flat());
for (const [oldKey, newPath] of Object.entries(INVERTED_BOOLEAN_MIGRATIONS)) {
if (consolidatedKeys.has(oldKey)) {
continue;
}
if (flatKeys.has(oldKey)) {
const oldValue = flatSettings[oldKey];
if (typeof oldValue === 'boolean') {
setNestedProperty(v2Settings, newPath, !oldValue);
}
flatKeys.delete(oldKey);
}
}
// Preserve mcpServers at the top level
if (flatSettings['mcpServers']) {
v2Settings['mcpServers'] = flatSettings['mcpServers'];
@ -310,6 +401,90 @@ function migrateSettingsToV2(
return v2Settings;
}
// Migrate V2 settings to V3 (invert disable* -> enable* booleans)
function migrateV2ToV3(
settings: Record<string, unknown>,
): Record<string, unknown> | null {
const version = settings[SETTINGS_VERSION_KEY];
if (typeof version === 'number' && version >= 3) {
return null;
}
let changed = false;
const result = structuredClone(settings);
const processedPaths = new Set<string>();
// Handle consolidated V2 paths first (multiple old paths -> single new path)
// Policy: if ANY of the old disable* settings is true, the new enable* should be false
for (const [newPath, oldPaths] of Object.entries(CONSOLIDATED_V2_PATHS)) {
let hasAnyDisable = false;
let hasAnyValue = false;
for (const oldPath of oldPaths) {
const oldValue = getNestedProperty(result, oldPath);
if (typeof oldValue === 'boolean') {
hasAnyValue = true;
if (oldValue === true) {
hasAnyDisable = true;
}
deleteNestedProperty(result, oldPath);
processedPaths.add(oldPath);
changed = true;
}
}
if (hasAnyValue) {
// enableAutoUpdate = !hasAnyDisable (if any disable* was true, enable should be false)
setNestedProperty(result, newPath, !hasAnyDisable);
}
}
// Handle remaining V2 paths that need inversion
for (const [oldPath, newPath] of Object.entries(INVERTED_V2_PATHS)) {
if (processedPaths.has(oldPath)) {
continue;
}
const oldValue = getNestedProperty(result, oldPath);
if (typeof oldValue === 'boolean') {
// Remove old property
deleteNestedProperty(result, oldPath);
// Set new property with inverted value
setNestedProperty(result, newPath, !oldValue);
changed = true;
}
}
if (changed) {
result[SETTINGS_VERSION_KEY] = SETTINGS_VERSION;
return result;
}
// Even if no changes, bump version to 3 to skip future migration checks
if (typeof version === 'number' && version < SETTINGS_VERSION) {
result[SETTINGS_VERSION_KEY] = SETTINGS_VERSION;
return result;
}
return null;
}
function deleteNestedProperty(
obj: Record<string, unknown>,
path: string,
): void {
const keys = path.split('.');
const lastKey = keys.pop();
if (!lastKey) return;
let current: Record<string, unknown> = obj;
for (const key of keys) {
const next = current[key];
if (typeof next !== 'object' || next === null) {
return;
}
current = next as Record<string, unknown>;
}
delete current[lastKey];
}
function getNestedProperty(
obj: Record<string, unknown>,
path: string,
@ -329,9 +504,26 @@ const REVERSE_MIGRATION_MAP: Record<string, string> = Object.fromEntries(
Object.entries(MIGRATION_MAP).map(([key, value]) => [value, key]),
);
// Dynamically determine the top-level keys from the V2 settings structure.
const KNOWN_V2_CONTAINERS = new Set(
Object.values(MIGRATION_MAP).map((path) => path.split('.')[0]),
// Reverse map for old V2 paths (before rename) to V1 keys.
// Used when migrating settings that still have old V2 naming (e.g., general.disableAutoUpdate).
const OLD_V2_TO_V1_MAP: Record<string, string> = {};
for (const [oldV2Path, newV3Path] of Object.entries(INVERTED_V2_PATHS)) {
// Find the V1 key that maps to this V3 path
for (const [v1Key, v3Path] of Object.entries(INVERTED_BOOLEAN_MIGRATIONS)) {
if (v3Path === newV3Path) {
OLD_V2_TO_V1_MAP[oldV2Path] = v1Key;
break;
}
}
}
// Reverse map for new V3 paths to V1 keys (with boolean inversion).
// Used when migrating settings that have new V3 naming (e.g., general.enableAutoUpdate).
const V3_TO_V1_INVERTED_MAP: Record<string, string> = Object.fromEntries(
Object.entries(INVERTED_BOOLEAN_MIGRATIONS).map(([v1Key, v3Path]) => [
v3Path,
v1Key,
]),
);
function getSettingsFileKeyWarnings(
@ -370,7 +562,7 @@ function getSettingsFileKeyWarnings(
ignoredLegacyKeys.add(oldKey);
warnings.push(
`⚠️ Legacy setting '${oldKey}' will be ignored in ${settingsFilePath}. Please use '${newPath}' instead.`,
`Warning: Legacy setting '${oldKey}' will be ignored in ${settingsFilePath}. Please use '${newPath}' instead.`,
);
}
@ -388,7 +580,7 @@ function getSettingsFileKeyWarnings(
}
warnings.push(
`⚠️ Unknown setting '${key}' will be ignored in ${settingsFilePath}.`,
`Warning: Unknown setting '${key}' will be ignored in ${settingsFilePath}.`,
);
}
@ -407,7 +599,8 @@ export function getSettingsWarnings(loadedSettings: LoadedSettings): string[] {
for (const scope of [SettingScope.User, SettingScope.Workspace]) {
const settingsFile = loadedSettings.forScope(scope);
if (settingsFile.rawJson === undefined) {
continue; // File not present / not loaded.
continue;
// File not present / not loaded.
}
const settingsObject = settingsFile.originalSettings as unknown as Record<
string,
@ -439,6 +632,26 @@ export function migrateSettingsToV1(
}
}
// Handle old V2 inverted paths (no value inversion needed)
// e.g., general.disableAutoUpdate -> disableAutoUpdate
for (const [oldV2Path, v1Key] of Object.entries(OLD_V2_TO_V1_MAP)) {
const value = getNestedProperty(v2Settings, oldV2Path);
if (value !== undefined) {
v1Settings[v1Key] = value;
v2Keys.delete(oldV2Path.split('.')[0]);
}
}
// Handle new V3 inverted paths (WITH value inversion)
// e.g., general.enableAutoUpdate -> disableAutoUpdate (inverted)
for (const [v3Path, v1Key] of Object.entries(V3_TO_V1_INVERTED_MAP)) {
const value = getNestedProperty(v2Settings, v3Path);
if (value !== undefined && typeof value === 'boolean') {
v1Settings[v1Key] = !value;
v2Keys.delete(v3Path.split('.')[0]);
}
}
// Preserve mcpServers at the top level
if (v2Settings['mcpServers']) {
v1Settings['mcpServers'] = v2Settings['mcpServers'];
@ -585,26 +798,48 @@ export function createMinimalSettings(): LoadedSettings {
);
}
function findEnvFile(startDir: string): string | null {
/**
* Finds the .env file to load, respecting workspace trust settings.
*
* When workspace is untrusted, only allow user-level .env files at:
* - ~/.qwen/.env
* - ~/.env
*/
function findEnvFile(settings: Settings, startDir: string): string | null {
const homeDir = homedir();
const isTrusted = isWorkspaceTrusted(settings).isTrusted;
// Pre-compute user-level .env paths for fast comparison
const userLevelPaths = new Set([
path.normalize(path.join(homeDir, '.env')),
path.normalize(path.join(homeDir, QWEN_DIR, '.env')),
]);
// Determine if we can use this .env file based on trust settings
const canUseEnvFile = (filePath: string): boolean =>
isTrusted !== false || userLevelPaths.has(path.normalize(filePath));
let currentDir = path.resolve(startDir);
while (true) {
// prefer gemini-specific .env under QWEN_DIR
// Prefer gemini-specific .env under QWEN_DIR
const geminiEnvPath = path.join(currentDir, QWEN_DIR, '.env');
if (fs.existsSync(geminiEnvPath)) {
if (fs.existsSync(geminiEnvPath) && canUseEnvFile(geminiEnvPath)) {
return geminiEnvPath;
}
const envPath = path.join(currentDir, '.env');
if (fs.existsSync(envPath)) {
if (fs.existsSync(envPath) && canUseEnvFile(envPath)) {
return envPath;
}
const parentDir = path.dirname(currentDir);
if (parentDir === currentDir || !parentDir) {
// check .env under home as fallback, again preferring gemini-specific .env
const homeGeminiEnvPath = path.join(homedir(), QWEN_DIR, '.env');
// At home directory - check fallback .env files
const homeGeminiEnvPath = path.join(homeDir, QWEN_DIR, '.env');
if (fs.existsSync(homeGeminiEnvPath)) {
return homeGeminiEnvPath;
}
const homeEnvPath = path.join(homedir(), '.env');
const homeEnvPath = path.join(homeDir, '.env');
if (fs.existsSync(homeEnvPath)) {
return homeEnvPath;
}
@ -635,22 +870,27 @@ export function setUpCloudShellEnvironment(envFilePath: string | null): void {
process.env['GOOGLE_CLOUD_PROJECT'] = 'cloudshell-gca';
}
}
/**
* Loads environment variables from .env files and settings.env.
*
* Priority order (highest to lowest):
* 1. CLI flags
* 2. process.env (system/export/inline environment variables)
* 3. .env files (no-override mode)
* 4. settings.env (no-override mode)
* 5. defaults
*/
export function loadEnvironment(settings: Settings): void {
const envFilePath = findEnvFile(process.cwd());
if (!isWorkspaceTrusted(settings).isTrusted) {
return;
}
const envFilePath = findEnvFile(settings, process.cwd());
// Cloud Shell environment variable handling
if (process.env['CLOUD_SHELL'] === 'true') {
setUpCloudShellEnvironment(envFilePath);
}
// Step 1: Load from .env files (higher priority than settings.env)
// Only set if not already present in process.env (no-override mode)
if (envFilePath) {
// Manually parse and load environment variables to handle exclusions correctly.
// This avoids modifying environment variables that were already set from the shell.
try {
const envFileContent = fs.readFileSync(envFilePath, 'utf-8');
const parsedEnv = dotenv.parse(envFileContent);
@ -666,7 +906,7 @@ export function loadEnvironment(settings: Settings): void {
continue;
}
// Load variable only if it's not already set in the environment.
// Only set if not already present in process.env (no-override)
if (!Object.hasOwn(process.env, key)) {
process.env[key] = parsedEnv[key];
}
@ -676,6 +916,16 @@ export function loadEnvironment(settings: Settings): void {
// Errors are ignored to match the behavior of `dotenv.config({ quiet: true })`.
}
}
// Step 2: Load environment variables from settings.env as fallback (lowest priority)
// Only set if not already present (no-override, after .env is loaded)
if (settings.env) {
for (const [key, value] of Object.entries(settings.env)) {
if (!Object.hasOwn(process.env, key) && typeof value === 'string') {
process.env[key] = value;
}
}
}
}
/**
@ -736,7 +986,7 @@ export function loadSettings(
let settingsObject = rawSettings as Record<string, unknown>;
if (needsMigration(settingsObject)) {
const migratedSettings = migrateSettingsToV2(settingsObject);
const migratedSettings = migrateV1ToV3(settingsObject);
if (migratedSettings) {
if (MIGRATE_V2_OVERWRITE) {
try {
@ -747,7 +997,7 @@ export function loadSettings(
'utf-8',
);
} catch (e) {
console.error(
writeStderrLine(
`Error migrating settings file on disk: ${getErrorMessage(
e,
)}`,
@ -769,12 +1019,39 @@ export function loadSettings(
'utf-8',
);
} catch (e) {
console.error(
writeStderrLine(
`Error adding version to settings file: ${getErrorMessage(e)}`,
);
}
}
}
// V2 to V3 migration (invert disable* -> enable* booleans)
const v3Migrated = migrateV2ToV3(settingsObject);
if (v3Migrated) {
if (MIGRATE_V2_OVERWRITE) {
try {
// Only backup if not already backed up by V1->V2 migration
const backupPath = `${filePath}.orig`;
if (!fs.existsSync(backupPath)) {
fs.renameSync(filePath, backupPath);
}
fs.writeFileSync(
filePath,
JSON.stringify(v3Migrated, null, 2),
'utf-8',
);
} catch (e) {
writeStderrLine(
`Error migrating settings file to V3: ${getErrorMessage(e)}`,
);
}
} else {
migratedInMemorScopes.add(scope);
}
settingsObject = v3Migrated;
}
return { settings: settingsObject as Settings, rawJson: content };
}
} catch (error: unknown) {
@ -893,31 +1170,6 @@ export function loadSettings(
);
}
export function migrateDeprecatedSettings(
loadedSettings: LoadedSettings,
): void {
const processScope = (scope: SettingScope) => {
const settings = loadedSettings.forScope(scope).settings;
const legacySkills = (
settings as Settings & {
tools?: { experimental?: { skills?: boolean } };
}
).tools?.experimental?.skills;
if (
legacySkills !== undefined &&
settings.experimental?.skills === undefined
) {
console.log(
`Migrating deprecated tools.experimental.skills setting from ${scope} settings...`,
);
loadedSettings.setValue(scope, 'experimental.skills', legacySkills);
}
};
processScope(SettingScope.User);
processScope(SettingScope.Workspace);
}
export function saveSettings(settingsFile: SettingsFile): void {
try {
// Ensure the directory exists
@ -939,7 +1191,8 @@ export function saveSettings(settingsFile: SettingsFile): void {
settingsToSave as Record<string, unknown>,
);
} catch (error) {
console.error('Error saving user settings file:', error);
writeStderrLine('Error saving user settings file.');
writeStderrLine(error instanceof Error ? error.message : String(error));
throw error;
}
}

View file

@ -80,7 +80,7 @@ describe('SettingsSchema', () => {
).toBeDefined();
expect(
getSettingsSchema().ui?.properties?.accessibility.properties
?.disableLoadingPhrases.type,
?.enableLoadingPhrases.type,
).toBe('boolean');
});
@ -164,7 +164,7 @@ describe('SettingsSchema', () => {
true,
);
expect(
getSettingsSchema().general.properties.disableAutoUpdate.showInDialog,
getSettingsSchema().general.properties.enableAutoUpdate.showInDialog,
).toBe(true);
expect(
getSettingsSchema().ui.properties.hideWindowTitle.showInDialog,

View file

@ -116,6 +116,19 @@ const SETTINGS_SCHEMA = {
mergeStrategy: MergeStrategy.REPLACE,
},
// Environment variables fallback
env: {
type: 'object',
label: 'Environment Variables',
category: 'Advanced',
requiresRestart: true,
default: {} as Record<string, string>,
description:
'Environment variables to set as fallback defaults. These are loaded with the lowest priority: system environment variables > .env files > settings.env.',
showInDialog: false,
mergeStrategy: MergeStrategy.SHALLOW_MERGE,
},
general: {
type: 'object',
label: 'General',
@ -143,24 +156,16 @@ const SETTINGS_SCHEMA = {
description: 'Enable Vim keybindings',
showInDialog: true,
},
disableAutoUpdate: {
enableAutoUpdate: {
type: 'boolean',
label: 'Disable Auto Update',
label: 'Enable Auto Update',
category: 'General',
requiresRestart: false,
default: false,
description: 'Disable automatic updates',
default: true,
description:
'Enable automatic update checks and installations on startup.',
showInDialog: true,
},
disableUpdateNag: {
type: 'boolean',
label: 'Disable Update Nag',
category: 'General',
requiresRestart: false,
default: false,
description: 'Disable update notification prompts.',
showInDialog: false,
},
gitCoAuthor: {
type: 'boolean',
label: 'Attribution: commit',
@ -396,14 +401,14 @@ const SETTINGS_SCHEMA = {
description: 'Accessibility settings.',
showInDialog: false,
properties: {
disableLoadingPhrases: {
enableLoadingPhrases: {
type: 'boolean',
label: 'Disable Loading Phrases',
label: 'Enable Loading Phrases',
category: 'UI',
requiresRestart: true,
default: false,
description: 'Disable loading phrases for accessibility',
showInDialog: false,
default: true,
description: 'Enable loading phrases (disable for accessibility)',
showInDialog: true,
},
screenReader: {
type: 'boolean',
@ -623,14 +628,13 @@ const SETTINGS_SCHEMA = {
parentKey: 'generationConfig',
showInDialog: false,
},
disableCacheControl: {
enableCacheControl: {
type: 'boolean',
label: 'Disable Cache Control',
label: 'Enable Cache Control',
category: 'Generation Configuration',
requiresRestart: false,
default: false,
description:
'Disable cache control for Anthropic and DashScope providers.',
default: true,
description: 'Enable cache control for DashScope providers.',
parentKey: 'generationConfig',
showInDialog: false,
},
@ -748,14 +752,14 @@ const SETTINGS_SCHEMA = {
description: 'Enable recursive file search functionality',
showInDialog: false,
},
disableFuzzySearch: {
enableFuzzySearch: {
type: 'boolean',
label: 'Disable Fuzzy Search',
label: 'Enable Fuzzy Search',
category: 'Context',
requiresRestart: true,
default: false,
description: 'Disable fuzzy search when searching for files.',
showInDialog: false,
default: true,
description: 'Enable fuzzy search when searching for files.',
showInDialog: true,
},
},
},
@ -983,15 +987,6 @@ const SETTINGS_SCHEMA = {
},
},
},
useSmartEdit: {
type: 'boolean',
label: 'Use Smart Edit',
category: 'Advanced',
requiresRestart: false,
default: false,
description: 'Enable the smart-edit tool instead of the replace tool.',
showInDialog: false,
},
security: {
type: 'object',
label: 'Security',
@ -1168,16 +1163,6 @@ const SETTINGS_SCHEMA = {
description: 'Setting to enable experimental features',
showInDialog: false,
properties: {
skills: {
type: 'boolean',
label: 'Experimental: Skills',
category: 'Experimental',
requiresRestart: true,
default: false,
description:
'Enable experimental Agent Skills feature. When enabled, Qwen Code can use Skills from .qwen/skills/ and ~/.qwen/skills/.',
showInDialog: true,
},
visionModelPreview: {
type: 'boolean',
label: 'Vision Model Preview',

View file

@ -15,6 +15,7 @@ import {
} from '@qwen-code/qwen-code-core';
import type { Settings } from './settings.js';
import stripJsonComments from 'strip-json-comments';
import { writeStderrLine } from '../utils/stdioHelpers.js';
export const TRUSTED_FOLDERS_FILENAME = 'trustedFolders.json';
export const SETTINGS_DIRECTORY_NAME = '.qwen';
@ -184,7 +185,8 @@ export function saveTrustedFolders(
{ encoding: 'utf-8', mode: 0o600 },
);
} catch (error) {
console.error('Error saving trusted folders file:', error);
writeStderrLine('Error saving trusted folders file.');
writeStderrLine(error instanceof Error ? error.message : String(error));
}
}