Merge tag 'v0.1.15' into feature/yiheng/sync-gemini-cli-0.1.15

This commit is contained in:
奕桁
2025-08-01 23:06:11 +08:00
340 changed files with 36528 additions and 22931 deletions

View File

@@ -6,15 +6,10 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as os from 'os';
import { loadCliConfig, parseArguments, CliArgs } from './config.js';
import { loadCliConfig, parseArguments } from './config.js';
import { Settings } from './settings.js';
import { Extension } from './extension.js';
import * as ServerConfig from '@qwen-code/qwen-code-core';
import {
TelemetryTarget,
ConfigParameters,
DEFAULT_TELEMETRY_TARGET,
} from '@qwen-code/qwen-code-core';
vi.mock('os', async (importOriginal) => {
const actualOs = await importOriginal<typeof os>();
@@ -42,63 +37,19 @@ vi.mock('@qwen-code/qwen-code-core', async () => {
...actualServer,
loadEnvironment: vi.fn(),
loadServerHierarchicalMemory: vi.fn(
(cwd, debug, fileService, extensionPaths) =>
(cwd, debug, fileService, extensionPaths, _maxDirs) =>
Promise.resolve({
memoryContent: extensionPaths?.join(',') || '',
fileCount: extensionPaths?.length || 0,
}),
),
Config: class MockConfig extends actualServer.Config {
private enableOpenAILogging: boolean;
constructor(params: ConfigParameters) {
super(params);
this.enableOpenAILogging = params.enableOpenAILogging ?? false;
}
getEnableOpenAILogging(): boolean {
return this.enableOpenAILogging;
}
// Override other methods to ensure they work correctly
getShowMemoryUsage(): boolean {
return (
(this as unknown as { showMemoryUsage?: boolean }).showMemoryUsage ??
false
);
}
getTelemetryEnabled(): boolean {
return (
(this as unknown as { telemetrySettings?: { enabled?: boolean } })
.telemetrySettings?.enabled ?? false
);
}
getTelemetryLogPromptsEnabled(): boolean {
return (
(this as unknown as { telemetrySettings?: { logPrompts?: boolean } })
.telemetrySettings?.logPrompts ?? false
);
}
getTelemetryOtlpEndpoint(): string {
return (
(this as unknown as { telemetrySettings?: { otlpEndpoint?: string } })
.telemetrySettings?.otlpEndpoint ??
'http://tracing-analysis-dc-hz.aliyuncs.com:8090'
);
}
getTelemetryTarget(): TelemetryTarget {
return (
(
this as unknown as {
telemetrySettings?: { target?: TelemetryTarget };
}
).telemetrySettings?.target ?? DEFAULT_TELEMETRY_TARGET
);
}
DEFAULT_MEMORY_FILE_FILTERING_OPTIONS: {
respectGitIgnore: false,
respectGeminiIgnore: true,
},
DEFAULT_FILE_FILTERING_OPTIONS: {
respectGitIgnore: true,
respectGeminiIgnore: true,
},
};
});
@@ -244,6 +195,85 @@ describe('loadCliConfig', () => {
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getShowMemoryUsage()).toBe(true);
});
it(`should leave proxy to empty by default`, async () => {
// Clear all proxy environment variables to ensure clean test
delete process.env.https_proxy;
delete process.env.http_proxy;
delete process.env.HTTPS_PROXY;
delete process.env.HTTP_PROXY;
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
const settings: Settings = {};
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getProxy()).toBeFalsy();
});
const proxy_url = 'http://localhost:7890';
const testCases = [
{
input: {
env_name: 'https_proxy',
proxy_url,
},
expected: proxy_url,
},
{
input: {
env_name: 'http_proxy',
proxy_url,
},
expected: proxy_url,
},
{
input: {
env_name: 'HTTPS_PROXY',
proxy_url,
},
expected: proxy_url,
},
{
input: {
env_name: 'HTTP_PROXY',
proxy_url,
},
expected: proxy_url,
},
];
testCases.forEach(({ input, expected }) => {
it(`should set proxy to ${expected} according to environment variable [${input.env_name}]`, async () => {
// Clear all proxy environment variables first
delete process.env.https_proxy;
delete process.env.http_proxy;
delete process.env.HTTPS_PROXY;
delete process.env.HTTP_PROXY;
process.env[input.env_name] = input.proxy_url;
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
const settings: Settings = {};
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getProxy()).toBe(expected);
});
});
it('should set proxy when --proxy flag is present', async () => {
process.argv = ['node', 'script.js', '--proxy', 'http://localhost:7890'];
const argv = await parseArguments();
const settings: Settings = {};
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getProxy()).toBe('http://localhost:7890');
});
it('should prioritize CLI flag over environment variable for proxy (CLI http://localhost:7890, environment variable http://localhost:7891)', async () => {
process.env['http_proxy'] = 'http://localhost:7891';
process.argv = ['node', 'script.js', '--proxy', 'http://localhost:7890'];
const argv = await parseArguments();
const settings: Settings = {};
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getProxy()).toBe('http://localhost:7890');
});
});
describe('loadCliConfig telemetry', () => {
@@ -350,9 +380,7 @@ describe('loadCliConfig telemetry', () => {
const argv = await parseArguments();
const settings: Settings = { telemetry: { enabled: true } };
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getTelemetryOtlpEndpoint()).toBe(
'http://tracing-analysis-dc-hz.aliyuncs.com:8090',
);
expect(config.getTelemetryOtlpEndpoint()).toBe('http://localhost:4317');
});
it('should use telemetry target from settings if CLI flag is not present', async () => {
@@ -411,81 +439,12 @@ describe('loadCliConfig telemetry', () => {
expect(config.getTelemetryLogPromptsEnabled()).toBe(false);
});
it('should use default log prompts (false) if no value is provided via CLI or settings', async () => {
it('should use default log prompts (true) if no value is provided via CLI or settings', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
const settings: Settings = { telemetry: { enabled: true } };
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getTelemetryLogPromptsEnabled()).toBe(false);
});
it('should set enableOpenAILogging to true when --openai-logging flag is present', async () => {
const settings: Settings = {};
const argv = await parseArguments();
argv.openaiLogging = true;
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(
(
config as unknown as { getEnableOpenAILogging(): boolean }
).getEnableOpenAILogging(),
).toBe(true);
});
it('should set enableOpenAILogging to false when --openai-logging flag is not present', async () => {
const settings: Settings = {};
const argv = await parseArguments();
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(
(
config as unknown as { getEnableOpenAILogging(): boolean }
).getEnableOpenAILogging(),
).toBe(false);
});
it('should use enableOpenAILogging value from settings if CLI flag is not present (settings true)', async () => {
const settings: Settings = { enableOpenAILogging: true };
const argv = await parseArguments();
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(
(
config as unknown as { getEnableOpenAILogging(): boolean }
).getEnableOpenAILogging(),
).toBe(true);
});
it('should use enableOpenAILogging value from settings if CLI flag is not present (settings false)', async () => {
const settings: Settings = { enableOpenAILogging: false };
const argv = await parseArguments();
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(
(
config as unknown as { getEnableOpenAILogging(): boolean }
).getEnableOpenAILogging(),
).toBe(false);
});
it('should prioritize --openai-logging CLI flag (true) over settings (false)', async () => {
const settings: Settings = { enableOpenAILogging: false };
const argv = await parseArguments();
argv.openaiLogging = true;
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(
(
config as unknown as { getEnableOpenAILogging(): boolean }
).getEnableOpenAILogging(),
).toBe(true);
});
it('should prioritize --openai-logging CLI flag (false) over settings (true)', async () => {
const settings: Settings = { enableOpenAILogging: true };
const argv = await parseArguments();
argv.openaiLogging = false;
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(
(
config as unknown as { getEnableOpenAILogging(): boolean }
).getEnableOpenAILogging(),
).toBe(false);
expect(config.getTelemetryLogPromptsEnabled()).toBe(true);
});
});
@@ -540,6 +499,11 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
'/path/to/ext3/context1.md',
'/path/to/ext3/context2.md',
],
{
respectGitIgnore: false,
respectGeminiIgnore: true,
},
undefined, // maxDirs
);
});
@@ -853,6 +817,66 @@ describe('loadCliConfig with allowed-mcp-server-names', () => {
const config = await loadCliConfig(baseSettings, [], 'test-session', argv);
expect(config.getMcpServers()).toEqual({});
});
it('should read allowMCPServers from settings', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
const settings: Settings = {
...baseSettings,
allowMCPServers: ['server1', 'server2'],
};
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getMcpServers()).toEqual({
server1: { url: 'http://localhost:8080' },
server2: { url: 'http://localhost:8081' },
});
});
it('should read excludeMCPServers from settings', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
const settings: Settings = {
...baseSettings,
excludeMCPServers: ['server1', 'server2'],
};
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getMcpServers()).toEqual({
server3: { url: 'http://localhost:8082' },
});
});
it('should override allowMCPServers with excludeMCPServers if overlapping ', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
const settings: Settings = {
...baseSettings,
excludeMCPServers: ['server1'],
allowMCPServers: ['server1', 'server2'],
};
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getMcpServers()).toEqual({
server2: { url: 'http://localhost:8081' },
});
});
it('should prioritize mcp server flag if set ', async () => {
process.argv = [
'node',
'script.js',
'--allowed-mcp-server-names',
'server1',
];
const argv = await parseArguments();
const settings: Settings = {
...baseSettings,
excludeMCPServers: ['server1'],
allowMCPServers: ['server2'],
};
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getMcpServers()).toEqual({
server1: { url: 'http://localhost:8080' },
});
});
});
describe('loadCliConfig extensions', () => {
@@ -908,6 +932,7 @@ describe('loadCliConfig ideMode', () => {
// Explicitly delete TERM_PROGRAM and SANDBOX before each test
delete process.env.TERM_PROGRAM;
delete process.env.SANDBOX;
delete process.env.GEMINI_CLI_IDE_SERVER_PORT;
});
afterEach(() => {
@@ -944,6 +969,7 @@ describe('loadCliConfig ideMode', () => {
process.argv = ['node', 'script.js', '--ide-mode'];
const argv = await parseArguments();
process.env.TERM_PROGRAM = 'vscode';
process.env.GEMINI_CLI_IDE_SERVER_PORT = '3000';
const settings: Settings = {};
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getIdeMode()).toBe(true);
@@ -953,6 +979,7 @@ describe('loadCliConfig ideMode', () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
process.env.TERM_PROGRAM = 'vscode';
process.env.GEMINI_CLI_IDE_SERVER_PORT = '3000';
const settings: Settings = { ideMode: true };
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getIdeMode()).toBe(true);
@@ -962,6 +989,7 @@ describe('loadCliConfig ideMode', () => {
process.argv = ['node', 'script.js', '--ide-mode'];
const argv = await parseArguments();
process.env.TERM_PROGRAM = 'vscode';
process.env.GEMINI_CLI_IDE_SERVER_PORT = '3000';
const settings: Settings = { ideMode: false };
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getIdeMode()).toBe(true);
@@ -995,82 +1023,4 @@ describe('loadCliConfig ideMode', () => {
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getIdeMode()).toBe(false);
});
it('should add __ide_server when ideMode is true', async () => {
process.argv = ['node', 'script.js', '--ide-mode'];
const argv = await parseArguments();
process.env.TERM_PROGRAM = 'vscode';
const settings: Settings = {};
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getIdeMode()).toBe(true);
const mcpServers = config.getMcpServers();
expect(mcpServers?.['_ide_server']).toBeDefined();
expect(mcpServers?.['_ide_server']?.httpUrl).toBe(
'http://localhost:3000/mcp',
);
expect(mcpServers?.['_ide_server']?.description).toBe('IDE connection');
expect(mcpServers?.['_ide_server']?.trust).toBe(false);
});
});
describe('loadCliConfig systemPromptMappings', () => {
it('should use default systemPromptMappings when not provided in settings', async () => {
const mockSettings: Settings = {
theme: 'dark',
};
const mockExtensions: Extension[] = [];
const mockSessionId = 'test-session';
const mockArgv: CliArgs = {
model: 'test-model',
} as CliArgs;
const config = await loadCliConfig(
mockSettings,
mockExtensions,
mockSessionId,
mockArgv,
);
expect(config.getSystemPromptMappings()).toEqual([
{
baseUrls: [
'https://dashscope.aliyuncs.com/compatible-mode/v1/',
'https://dashscope-intl.aliyuncs.com/compatible-mode/v1/',
],
modelNames: ['qwen3-coder-plus'],
template:
'SYSTEM_TEMPLATE:{"name":"qwen3_coder","params":{"is_git_repository":{RUNTIME_VARS_IS_GIT_REPO},"sandbox":"{RUNTIME_VARS_SANDBOX}"}}',
},
]);
});
it('should use custom systemPromptMappings when provided in settings', async () => {
const customSystemPromptMappings = [
{
baseUrls: ['https://custom-api.com'],
modelNames: ['custom-model'],
template: 'Custom template',
},
];
const mockSettings: Settings = {
theme: 'dark',
systemPromptMappings: customSystemPromptMappings,
};
const mockExtensions: Extension[] = [];
const mockSessionId = 'test-session';
const mockArgv: CliArgs = {
model: 'test-model',
} as CliArgs;
const config = await loadCliConfig(
mockSettings,
mockExtensions,
mockSessionId,
mockArgv,
);
expect(config.getSystemPromptMappings()).toEqual(
customSystemPromptMappings,
);
});
});

View File

@@ -15,13 +15,15 @@ import {
ApprovalMode,
DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_EMBEDDING_MODEL,
DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
FileDiscoveryService,
TelemetryTarget,
MCPServerConfig,
FileFilteringOptions,
IdeClient,
} from '@qwen-code/qwen-code-core';
import { Settings } from './settings.js';
import { Extension, filterActiveExtensions } from './extension.js';
import { Extension, annotateActiveExtensions } from './extension.js';
import { getCliVersion } from '../utils/version.js';
import { loadSandboxConfig } from './sandboxConfig.js';
@@ -52,13 +54,16 @@ export interface CliArgs {
telemetryTarget: string | undefined;
telemetryOtlpEndpoint: string | undefined;
telemetryLogPrompts: boolean | undefined;
telemetryOutfile: string | undefined;
allowedMcpServerNames: string[] | undefined;
experimentalAcp: boolean | undefined;
extensions: string[] | undefined;
listExtensions: boolean | undefined;
ideMode: boolean | undefined;
openaiLogging: boolean | undefined;
openaiApiKey: string | undefined;
openaiBaseUrl: string | undefined;
proxy: string | undefined;
}
export async function parseArguments(): Promise<CliArgs> {
@@ -157,12 +162,20 @@ export async function parseArguments(): Promise<CliArgs> {
description:
'Enable or disable logging of user prompts for telemetry. Overrides settings files.',
})
.option('telemetry-outfile', {
type: 'string',
description: 'Redirect all telemetry output to the specified file.',
})
.option('checkpointing', {
alias: 'c',
type: 'boolean',
description: 'Enables checkpointing of file edits',
default: false,
})
.option('experimental-acp', {
type: 'boolean',
description: 'Starts the agent in ACP mode',
})
.option('allowed-mcp-server-names', {
type: 'array',
string: true,
@@ -197,7 +210,11 @@ export async function parseArguments(): Promise<CliArgs> {
type: 'string',
description: 'OpenAI base URL (for custom endpoints)',
})
.option('proxy', {
type: 'string',
description:
'Proxy for gemini client, like schema://user:password@host:port',
})
.version(await getCliVersion()) // This will enable the --version flag based on package.json
.alias('v', 'version')
.help()
@@ -223,13 +240,16 @@ export async function loadHierarchicalGeminiMemory(
currentWorkingDirectory: string,
debugMode: boolean,
fileService: FileDiscoveryService,
settings: Settings,
extensionContextFilePaths: string[] = [],
fileFilteringOptions?: FileFilteringOptions,
): Promise<{ memoryContent: string; fileCount: number }> {
if (debugMode) {
logger.debug(
`CLI: Delegating hierarchical memory load to server for CWD: ${currentWorkingDirectory}`,
);
}
// Directly call the server function.
// The server function will use its own homedir() for the global path.
return loadServerHierarchicalMemory(
@@ -237,6 +257,8 @@ export async function loadHierarchicalGeminiMemory(
debugMode,
fileService,
extensionContextFilePaths,
fileFilteringOptions,
settings.memoryDiscoveryMaxDirs,
);
}
@@ -257,11 +279,19 @@ export async function loadCliConfig(
process.env.TERM_PROGRAM === 'vscode' &&
!process.env.SANDBOX;
const activeExtensions = filterActiveExtensions(
let ideClient: IdeClient | undefined;
if (ideMode) {
ideClient = new IdeClient();
}
const allExtensions = annotateActiveExtensions(
extensions,
argv.extensions || [],
);
const activeExtensions = extensions.filter(
(_, i) => allExtensions[i].isActive,
);
// Handle OpenAI API key from command line
if (argv.openaiApiKey) {
process.env.OPENAI_API_KEY = argv.openaiApiKey;
@@ -288,46 +318,72 @@ export async function loadCliConfig(
);
const fileService = new FileDiscoveryService(process.cwd());
const fileFiltering = {
...DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
...settings.fileFiltering,
};
// Call the (now wrapper) loadHierarchicalGeminiMemory which calls the server's version
const { memoryContent, fileCount } = await loadHierarchicalGeminiMemory(
process.cwd(),
debugMode,
fileService,
settings,
extensionContextFilePaths,
fileFiltering,
);
let mcpServers = mergeMcpServers(settings, activeExtensions);
const excludeTools = mergeExcludeTools(settings, activeExtensions);
const blockedMcpServers: Array<{ name: string; extensionName: string }> = [];
if (!argv.allowedMcpServerNames) {
if (settings.allowMCPServers) {
const allowedNames = new Set(settings.allowMCPServers.filter(Boolean));
if (allowedNames.size > 0) {
mcpServers = Object.fromEntries(
Object.entries(mcpServers).filter(([key]) => allowedNames.has(key)),
);
}
}
if (settings.excludeMCPServers) {
const excludedNames = new Set(settings.excludeMCPServers.filter(Boolean));
if (excludedNames.size > 0) {
mcpServers = Object.fromEntries(
Object.entries(mcpServers).filter(([key]) => !excludedNames.has(key)),
);
}
}
}
if (argv.allowedMcpServerNames) {
const allowedNames = new Set(argv.allowedMcpServerNames.filter(Boolean));
if (allowedNames.size > 0) {
mcpServers = Object.fromEntries(
Object.entries(mcpServers).filter(([key]) => allowedNames.has(key)),
Object.entries(mcpServers).filter(([key, server]) => {
const isAllowed = allowedNames.has(key);
if (!isAllowed) {
blockedMcpServers.push({
name: key,
extensionName: server.extensionName || '',
});
}
return isAllowed;
}),
);
} else {
blockedMcpServers.push(
...Object.entries(mcpServers).map(([key, server]) => ({
name: key,
extensionName: server.extensionName || '',
})),
);
mcpServers = {};
}
}
if (ideMode) {
mcpServers['_ide_server'] = new MCPServerConfig(
undefined, // command
undefined, // args
undefined, // env
undefined, // cwd
undefined, // url
'http://localhost:3000/mcp', // httpUrl
undefined, // headers
undefined, // tcp
undefined, // timeout
false, // trust
'IDE connection', // description
undefined, // includeTools
undefined, // excludeTools
);
}
const sandboxConfig = await loadSandboxConfig(settings, argv);
return new Config({
@@ -362,16 +418,19 @@ export async function loadCliConfig(
process.env.OTEL_EXPORTER_OTLP_ENDPOINT ??
settings.telemetry?.otlpEndpoint,
logPrompts: argv.telemetryLogPrompts ?? settings.telemetry?.logPrompts,
outfile: argv.telemetryOutfile ?? settings.telemetry?.outfile,
},
usageStatisticsEnabled: settings.usageStatisticsEnabled ?? true,
// Git-aware file filtering settings
fileFiltering: {
respectGitIgnore: settings.fileFiltering?.respectGitIgnore,
respectGeminiIgnore: settings.fileFiltering?.respectGeminiIgnore,
enableRecursiveFileSearch:
settings.fileFiltering?.enableRecursiveFileSearch,
},
checkpointing: argv.checkpointing || settings.checkpointing?.enabled,
proxy:
argv.proxy ||
process.env.HTTPS_PROXY ||
process.env.https_proxy ||
process.env.HTTP_PROXY ||
@@ -382,15 +441,14 @@ export async function loadCliConfig(
model: argv.model!,
extensionContextFilePaths,
maxSessionTurns: settings.maxSessionTurns ?? -1,
sessionTokenLimit: settings.sessionTokenLimit ?? 32000,
maxFolderItems: settings.maxFolderItems ?? 20,
experimentalAcp: argv.experimentalAcp || false,
listExtensions: argv.listExtensions || false,
activeExtensions: activeExtensions.map((e) => ({
name: e.config.name,
version: e.config.version,
})),
extensions: allExtensions,
blockedMcpServers,
noBrowser: !!process.env.NO_BROWSER,
summarizeToolOutput: settings.summarizeToolOutput,
ideMode,
ideClient,
enableOpenAILogging:
(typeof argv.openaiLogging === 'undefined'
? settings.enableOpenAILogging
@@ -421,7 +479,10 @@ function mergeMcpServers(settings: Settings, extensions: Extension[]) {
);
return;
}
mcpServers[key] = server;
mcpServers[key] = {
...server,
extensionName: extension.config.name,
};
},
);
}

View File

@@ -11,7 +11,7 @@ import * as path from 'path';
import {
EXTENSIONS_CONFIG_FILENAME,
EXTENSIONS_DIRECTORY_NAME,
filterActiveExtensions,
annotateActiveExtensions,
loadExtensions,
} from './extension.js';
@@ -42,7 +42,7 @@ describe('loadExtensions', () => {
fs.rmSync(tempHomeDir, { recursive: true, force: true });
});
it('should load context file path when GEMINI.md is present', () => {
it('should load context file path when QWEN.md is present', () => {
const workspaceExtensionsDir = path.join(
tempWorkspaceDir,
EXTENSIONS_DIRECTORY_NAME,
@@ -86,42 +86,52 @@ describe('loadExtensions', () => {
});
});
describe('filterActiveExtensions', () => {
describe('annotateActiveExtensions', () => {
const extensions = [
{ config: { name: 'ext1', version: '1.0.0' }, contextFiles: [] },
{ config: { name: 'ext2', version: '1.0.0' }, contextFiles: [] },
{ config: { name: 'ext3', version: '1.0.0' }, contextFiles: [] },
];
it('should return all extensions if no enabled extensions are provided', () => {
const activeExtensions = filterActiveExtensions(extensions, []);
it('should mark all extensions as active if no enabled extensions are provided', () => {
const activeExtensions = annotateActiveExtensions(extensions, []);
expect(activeExtensions).toHaveLength(3);
expect(activeExtensions.every((e) => e.isActive)).toBe(true);
});
it('should return only the enabled extensions', () => {
const activeExtensions = filterActiveExtensions(extensions, [
it('should mark only the enabled extensions as active', () => {
const activeExtensions = annotateActiveExtensions(extensions, [
'ext1',
'ext3',
]);
expect(activeExtensions).toHaveLength(2);
expect(activeExtensions.some((e) => e.config.name === 'ext1')).toBe(true);
expect(activeExtensions.some((e) => e.config.name === 'ext3')).toBe(true);
expect(activeExtensions).toHaveLength(3);
expect(activeExtensions.find((e) => e.name === 'ext1')?.isActive).toBe(
true,
);
expect(activeExtensions.find((e) => e.name === 'ext2')?.isActive).toBe(
false,
);
expect(activeExtensions.find((e) => e.name === 'ext3')?.isActive).toBe(
true,
);
});
it('should return no extensions when "none" is provided', () => {
const activeExtensions = filterActiveExtensions(extensions, ['none']);
expect(activeExtensions).toHaveLength(0);
it('should mark all extensions as inactive when "none" is provided', () => {
const activeExtensions = annotateActiveExtensions(extensions, ['none']);
expect(activeExtensions).toHaveLength(3);
expect(activeExtensions.every((e) => !e.isActive)).toBe(true);
});
it('should handle case-insensitivity', () => {
const activeExtensions = filterActiveExtensions(extensions, ['EXT1']);
expect(activeExtensions).toHaveLength(1);
expect(activeExtensions[0].config.name).toBe('ext1');
const activeExtensions = annotateActiveExtensions(extensions, ['EXT1']);
expect(activeExtensions.find((e) => e.name === 'ext1')?.isActive).toBe(
true,
);
});
it('should log an error for unknown extensions', () => {
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
filterActiveExtensions(extensions, ['ext4']);
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
annotateActiveExtensions(extensions, ['ext4']);
expect(consoleSpy).toHaveBeenCalledWith('Extension not found: ext4');
consoleSpy.mockRestore();
});

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { MCPServerConfig } from '@qwen-code/qwen-code-core';
import { MCPServerConfig, GeminiCLIExtension } from '@qwen-code/qwen-code-core';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
@@ -34,9 +34,6 @@ export function loadExtensions(workspaceDir: string): Extension[] {
const uniqueExtensions = new Map<string, Extension>();
for (const extension of allExtensions) {
if (!uniqueExtensions.has(extension.config.name)) {
console.log(
`Loading extension: ${extension.config.name} (version: ${extension.config.version})`,
);
uniqueExtensions.set(extension.config.name, extension);
}
}
@@ -113,12 +110,18 @@ function getContextFileNames(config: ExtensionConfig): string[] {
return config.contextFileName;
}
export function filterActiveExtensions(
export function annotateActiveExtensions(
extensions: Extension[],
enabledExtensionNames: string[],
): Extension[] {
): GeminiCLIExtension[] {
const annotatedExtensions: GeminiCLIExtension[] = [];
if (enabledExtensionNames.length === 0) {
return extensions;
return extensions.map((extension) => ({
name: extension.config.name,
version: extension.config.version,
isActive: true,
}));
}
const lowerCaseEnabledExtensions = new Set(
@@ -129,31 +132,33 @@ export function filterActiveExtensions(
lowerCaseEnabledExtensions.size === 1 &&
lowerCaseEnabledExtensions.has('none')
) {
if (extensions.length > 0) {
console.log('All extensions are disabled.');
}
return [];
return extensions.map((extension) => ({
name: extension.config.name,
version: extension.config.version,
isActive: false,
}));
}
const activeExtensions: Extension[] = [];
const notFoundNames = new Set(lowerCaseEnabledExtensions);
for (const extension of extensions) {
const lowerCaseName = extension.config.name.toLowerCase();
if (lowerCaseEnabledExtensions.has(lowerCaseName)) {
console.log(
`Activated extension: ${extension.config.name} (version: ${extension.config.version})`,
);
activeExtensions.push(extension);
const isActive = lowerCaseEnabledExtensions.has(lowerCaseName);
if (isActive) {
notFoundNames.delete(lowerCaseName);
} else {
console.log(`Disabled extension: ${extension.config.name}`);
}
annotatedExtensions.push({
name: extension.config.name,
version: extension.config.version,
isActive,
});
}
for (const requestedName of notFoundNames) {
console.log(`Extension not found: ${requestedName}`);
console.error(`Extension not found: ${requestedName}`);
}
return activeExtensions;
return annotatedExtensions;
}

View File

@@ -46,7 +46,7 @@ import stripJsonComments from 'strip-json-comments'; // Will be mocked separatel
import {
loadSettings,
USER_SETTINGS_PATH, // This IS the mocked path.
SYSTEM_SETTINGS_PATH,
getSystemSettingsPath,
SETTINGS_DIRECTORY_NAME, // This is from the original module, but used by the mock.
SettingScope,
} from './settings.js';
@@ -95,13 +95,16 @@ describe('Settings Loading and Merging', () => {
expect(settings.system.settings).toEqual({});
expect(settings.user.settings).toEqual({});
expect(settings.workspace.settings).toEqual({});
expect(settings.merged).toEqual({});
expect(settings.merged).toEqual({
customThemes: {},
mcpServers: {},
});
expect(settings.errors.length).toBe(0);
});
it('should load system settings if only system file exists', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === SYSTEM_SETTINGS_PATH,
(p: fs.PathLike) => p === getSystemSettingsPath(),
);
const systemSettingsContent = {
theme: 'system-default',
@@ -109,7 +112,7 @@ describe('Settings Loading and Merging', () => {
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === SYSTEM_SETTINGS_PATH)
if (p === getSystemSettingsPath())
return JSON.stringify(systemSettingsContent);
return '{}';
},
@@ -118,13 +121,17 @@ describe('Settings Loading and Merging', () => {
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(fs.readFileSync).toHaveBeenCalledWith(
SYSTEM_SETTINGS_PATH,
getSystemSettingsPath(),
'utf-8',
);
expect(settings.system.settings).toEqual(systemSettingsContent);
expect(settings.user.settings).toEqual({});
expect(settings.workspace.settings).toEqual({});
expect(settings.merged).toEqual(systemSettingsContent);
expect(settings.merged).toEqual({
...systemSettingsContent,
customThemes: {},
mcpServers: {},
});
});
it('should load user settings if only user file exists', () => {
@@ -153,7 +160,11 @@ describe('Settings Loading and Merging', () => {
);
expect(settings.user.settings).toEqual(userSettingsContent);
expect(settings.workspace.settings).toEqual({});
expect(settings.merged).toEqual(userSettingsContent);
expect(settings.merged).toEqual({
...userSettingsContent,
customThemes: {},
mcpServers: {},
});
});
it('should load workspace settings if only workspace file exists', () => {
@@ -180,7 +191,11 @@ describe('Settings Loading and Merging', () => {
);
expect(settings.user.settings).toEqual({});
expect(settings.workspace.settings).toEqual(workspaceSettingsContent);
expect(settings.merged).toEqual(workspaceSettingsContent);
expect(settings.merged).toEqual({
...workspaceSettingsContent,
customThemes: {},
mcpServers: {},
});
});
it('should merge user and workspace settings, with workspace taking precedence', () => {
@@ -215,6 +230,8 @@ describe('Settings Loading and Merging', () => {
sandbox: true,
coreTools: ['tool1'],
contextFileName: 'WORKSPACE_CONTEXT.md',
customThemes: {},
mcpServers: {},
});
});
@@ -223,6 +240,7 @@ describe('Settings Loading and Merging', () => {
const systemSettingsContent = {
theme: 'system-theme',
sandbox: false,
allowMCPServers: ['server1', 'server2'],
telemetry: { enabled: false },
};
const userSettingsContent = {
@@ -234,11 +252,12 @@ describe('Settings Loading and Merging', () => {
sandbox: false,
coreTools: ['tool1'],
contextFileName: 'WORKSPACE_CONTEXT.md',
allowMCPServers: ['server1', 'server2', 'server3'],
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === SYSTEM_SETTINGS_PATH)
if (p === getSystemSettingsPath())
return JSON.stringify(systemSettingsContent);
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
@@ -259,6 +278,9 @@ describe('Settings Loading and Merging', () => {
telemetry: { enabled: false },
coreTools: ['tool1'],
contextFileName: 'WORKSPACE_CONTEXT.md',
allowMCPServers: ['server1', 'server2'],
customThemes: {},
mcpServers: {},
});
});
@@ -370,6 +392,134 @@ describe('Settings Loading and Merging', () => {
(fs.readFileSync as Mock).mockReturnValue('{}');
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.telemetry).toBeUndefined();
expect(settings.merged.customThemes).toEqual({});
expect(settings.merged.mcpServers).toEqual({});
});
it('should merge MCP servers correctly, with workspace taking precedence', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
const userSettingsContent = {
mcpServers: {
'user-server': {
command: 'user-command',
args: ['--user-arg'],
description: 'User MCP server',
},
'shared-server': {
command: 'user-shared-command',
description: 'User shared server config',
},
},
};
const workspaceSettingsContent = {
mcpServers: {
'workspace-server': {
command: 'workspace-command',
args: ['--workspace-arg'],
description: 'Workspace MCP server',
},
'shared-server': {
command: 'workspace-shared-command',
description: 'Workspace shared server config',
},
},
};
(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 '';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.user.settings).toEqual(userSettingsContent);
expect(settings.workspace.settings).toEqual(workspaceSettingsContent);
expect(settings.merged.mcpServers).toEqual({
'user-server': {
command: 'user-command',
args: ['--user-arg'],
description: 'User MCP server',
},
'workspace-server': {
command: 'workspace-command',
args: ['--workspace-arg'],
description: 'Workspace MCP server',
},
'shared-server': {
command: 'workspace-shared-command',
description: 'Workspace shared server config',
},
});
});
it('should handle MCP servers when only in user settings', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
const userSettingsContent = {
mcpServers: {
'user-only-server': {
command: 'user-only-command',
description: 'User only server',
},
},
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
return '';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.mcpServers).toEqual({
'user-only-server': {
command: 'user-only-command',
description: 'User only server',
},
});
});
it('should handle MCP servers when only in workspace settings', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === MOCK_WORKSPACE_SETTINGS_PATH,
);
const workspaceSettingsContent = {
mcpServers: {
'workspace-only-server': {
command: 'workspace-only-command',
description: 'Workspace only server',
},
},
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(workspaceSettingsContent);
return '';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.mcpServers).toEqual({
'workspace-only-server': {
command: 'workspace-only-command',
description: 'Workspace only server',
},
});
});
it('should have mcpServers as empty object if not in any settings file', () => {
(mockFsExistsSync as Mock).mockReturnValue(false); // No settings files exist
(fs.readFileSync as Mock).mockReturnValue('{}');
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.mcpServers).toEqual({});
});
it('should handle JSON parsing errors gracefully', () => {
@@ -407,7 +557,10 @@ describe('Settings Loading and Merging', () => {
// Check that settings are empty due to parsing errors
expect(settings.user.settings).toEqual({});
expect(settings.workspace.settings).toEqual({});
expect(settings.merged).toEqual({});
expect(settings.merged).toEqual({
customThemes: {},
mcpServers: {},
});
// Check that error objects are populated in settings.errors
expect(settings.errors).toBeDefined();
@@ -448,10 +601,13 @@ describe('Settings Loading and Merging', () => {
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
// @ts-expect-error: dynamic property for test
expect(settings.user.settings.apiKey).toBe('user_api_key_from_env');
// @ts-expect-error: dynamic property for test
expect(settings.user.settings.someUrl).toBe(
'https://test.com/user_api_key_from_env',
);
// @ts-expect-error: dynamic property for test
expect(settings.merged.apiKey).toBe('user_api_key_from_env');
delete process.env.TEST_API_KEY;
});
@@ -480,6 +636,7 @@ describe('Settings Loading and Merging', () => {
expect(settings.workspace.settings.nested.value).toBe(
'workspace_endpoint_from_env',
);
// @ts-expect-error: dynamic property for test
expect(settings.merged.endpoint).toBe('workspace_endpoint_from_env/api');
delete process.env.WORKSPACE_ENDPOINT;
});
@@ -509,13 +666,16 @@ describe('Settings Loading and Merging', () => {
const settings = loadSettings(MOCK_WORKSPACE_DIR);
// @ts-expect-error: dynamic property for test
expect(settings.user.settings.configValue).toBe(
'user_value_for_user_read',
);
// @ts-expect-error: dynamic property for test
expect(settings.workspace.settings.configValue).toBe(
'workspace_value_for_workspace_read',
);
// Merged should take workspace's resolved value
// @ts-expect-error: dynamic property for test
expect(settings.merged.configValue).toBe(
'workspace_value_for_workspace_read',
);
@@ -583,7 +743,7 @@ describe('Settings Loading and Merging', () => {
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === SYSTEM_SETTINGS_PATH) {
if (p === getSystemSettingsPath()) {
process.env.SHARED_VAR = 'system_value_for_system_read'; // Set for system settings read
return JSON.stringify(systemSettingsContent);
}
@@ -597,13 +757,16 @@ describe('Settings Loading and Merging', () => {
const settings = loadSettings(MOCK_WORKSPACE_DIR);
// @ts-expect-error: dynamic property for test
expect(settings.system.settings.configValue).toBe(
'system_value_for_system_read',
);
// @ts-expect-error: dynamic property for test
expect(settings.workspace.settings.configValue).toBe(
'workspace_value_for_workspace_read',
);
// Merged should take workspace's resolved value
// Merged should take system's resolved value
// @ts-expect-error: dynamic property for test
expect(settings.merged.configValue).toBe('system_value_for_system_read');
// Restore original environment variable state
@@ -750,6 +913,50 @@ describe('Settings Loading and Merging', () => {
delete process.env.TEST_HOST;
delete process.env.TEST_PORT;
});
describe('when GEMINI_CLI_SYSTEM_SETTINGS_PATH is set', () => {
const MOCK_ENV_SYSTEM_SETTINGS_PATH = '/mock/env/system/settings.json';
beforeEach(() => {
process.env.GEMINI_CLI_SYSTEM_SETTINGS_PATH =
MOCK_ENV_SYSTEM_SETTINGS_PATH;
});
afterEach(() => {
delete process.env.GEMINI_CLI_SYSTEM_SETTINGS_PATH;
});
it('should load system settings from the path specified in the environment variable', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === MOCK_ENV_SYSTEM_SETTINGS_PATH,
);
const systemSettingsContent = {
theme: 'env-var-theme',
sandbox: true,
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === MOCK_ENV_SYSTEM_SETTINGS_PATH)
return JSON.stringify(systemSettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(fs.readFileSync).toHaveBeenCalledWith(
MOCK_ENV_SYSTEM_SETTINGS_PATH,
'utf-8',
);
expect(settings.system.path).toBe(MOCK_ENV_SYSTEM_SETTINGS_PATH);
expect(settings.system.settings).toEqual(systemSettingsContent);
expect(settings.merged).toEqual({
...systemSettingsContent,
customThemes: {},
mcpServers: {},
});
});
});
});
describe('LoadedSettings class', () => {

View File

@@ -19,12 +19,16 @@ import {
import stripJsonComments from 'strip-json-comments';
import { DefaultLight } from '../ui/themes/default-light.js';
import { DefaultDark } from '../ui/themes/default.js';
import { CustomTheme } from '../ui/themes/theme.js';
export const SETTINGS_DIRECTORY_NAME = '.qwen';
export const USER_SETTINGS_DIR = path.join(homedir(), SETTINGS_DIRECTORY_NAME);
export const USER_SETTINGS_PATH = path.join(USER_SETTINGS_DIR, 'settings.json');
function getSystemSettingsPath(): string {
export function getSystemSettingsPath(): string {
if (process.env.GEMINI_CLI_SYSTEM_SETTINGS_PATH) {
return process.env.GEMINI_CLI_SYSTEM_SETTINGS_PATH;
}
if (platform() === 'darwin') {
return '/Library/Application Support/QwenCode/settings.json';
} else if (platform() === 'win32') {
@@ -34,8 +38,6 @@ function getSystemSettingsPath(): string {
}
}
export const SYSTEM_SETTINGS_PATH = getSystemSettingsPath();
export enum SettingScope {
User = 'User',
Workspace = 'Workspace',
@@ -46,12 +48,17 @@ export interface CheckpointingSettings {
enabled?: boolean;
}
export interface SummarizeToolOutputSettings {
tokenBudget?: number;
}
export interface AccessibilitySettings {
disableLoadingPhrases?: boolean;
}
export interface Settings {
theme?: string;
customThemes?: Record<string, CustomTheme>;
selectedAuthType?: AuthType;
sandbox?: boolean | string;
coreTools?: string[];
@@ -60,6 +67,8 @@ export interface Settings {
toolCallCommand?: string;
mcpServerCommand?: string;
mcpServers?: Record<string, MCPServerConfig>;
allowMCPServers?: string[];
excludeMCPServers?: string[];
showMemoryUsage?: boolean;
contextFileName?: string | string[];
accessibility?: AccessibilitySettings;
@@ -74,43 +83,32 @@ export interface Settings {
// Git-aware file filtering settings
fileFiltering?: {
respectGitIgnore?: boolean;
respectGeminiIgnore?: boolean;
enableRecursiveFileSearch?: boolean;
};
// UI setting. Does not display the ANSI-controlled terminal title.
hideWindowTitle?: boolean;
hideTips?: boolean;
hideBanner?: boolean;
// Setting for setting maximum number of user/model/tool turns in a session.
maxSessionTurns?: number;
// Setting for maximum token limit for conversation history before blocking requests
sessionTokenLimit?: number;
// A map of tool names to their summarization settings.
summarizeToolOutput?: Record<string, SummarizeToolOutputSettings>;
// Setting for maximum number of files and folders to show in folder structure
maxFolderItems?: number;
// Sampling parameters for content generation
sampling_params?: {
top_p?: number;
top_k?: number;
repetition_penalty?: number;
presence_penalty?: number;
frequency_penalty?: number;
temperature?: number;
max_tokens?: number;
};
// System prompt mappings for different base URLs and model names
systemPromptMappings?: Array<{
baseUrls?: string[];
modelNames?: string[];
template?: string;
}>;
vimMode?: boolean;
// Add other settings here.
ideMode?: boolean;
memoryDiscoveryMaxDirs?: number;
sampling_params?: Record<string, unknown>;
systemPromptMappings?: Array<{
baseUrls: string[];
modelNames: string[];
template: string;
}>;
}
export interface SettingsError {
@@ -148,10 +146,24 @@ export class LoadedSettings {
}
private computeMergedSettings(): Settings {
const system = this.system.settings;
const user = this.user.settings;
const workspace = this.workspace.settings;
return {
...this.user.settings,
...this.workspace.settings,
...this.system.settings,
...user,
...workspace,
...system,
customThemes: {
...(user.customThemes || {}),
...(workspace.customThemes || {}),
...(system.customThemes || {}),
},
mcpServers: {
...(user.mcpServers || {}),
...(workspace.mcpServers || {}),
...(system.mcpServers || {}),
},
};
}
@@ -168,13 +180,12 @@ export class LoadedSettings {
}
}
setValue(
setValue<K extends keyof Settings>(
scope: SettingScope,
key: keyof Settings,
value: string | Record<string, MCPServerConfig> | undefined,
key: K,
value: Settings[K],
): void {
const settingsFile = this.forScope(scope);
// @ts-expect-error - value can be string | Record<string, MCPServerConfig>
settingsFile.settings[key] = value;
this._merged = this.computeMergedSettings();
saveSettings(settingsFile);
@@ -296,11 +307,11 @@ export function loadSettings(workspaceDir: string): LoadedSettings {
let userSettings: Settings = {};
let workspaceSettings: Settings = {};
const settingsErrors: SettingsError[] = [];
const systemSettingsPath = getSystemSettingsPath();
// Load system settings
try {
if (fs.existsSync(SYSTEM_SETTINGS_PATH)) {
const systemContent = fs.readFileSync(SYSTEM_SETTINGS_PATH, 'utf-8');
if (fs.existsSync(systemSettingsPath)) {
const systemContent = fs.readFileSync(systemSettingsPath, 'utf-8');
const parsedSystemSettings = JSON.parse(
stripJsonComments(systemContent),
) as Settings;
@@ -309,7 +320,7 @@ export function loadSettings(workspaceDir: string): LoadedSettings {
} catch (error: unknown) {
settingsErrors.push({
message: getErrorMessage(error),
path: SYSTEM_SETTINGS_PATH,
path: systemSettingsPath,
});
}
@@ -367,7 +378,7 @@ export function loadSettings(workspaceDir: string): LoadedSettings {
return new LoadedSettings(
{
path: SYSTEM_SETTINGS_PATH,
path: systemSettingsPath,
settings: systemSettings,
},
{