Merge tag 'v0.3.0' into chore/sync-gemini-cli-v0.3.0

This commit is contained in:
mingholy.lmh
2025-09-10 21:01:40 +08:00
583 changed files with 30160 additions and 10770 deletions

View File

@@ -0,0 +1,32 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { CommandModule } from 'yargs';
import { installCommand } from './extensions/install.js';
import { uninstallCommand } from './extensions/uninstall.js';
import { listCommand } from './extensions/list.js';
import { updateCommand } from './extensions/update.js';
import { disableCommand } from './extensions/disable.js';
import { enableCommand } from './extensions/enable.js';
export const extensionsCommand: CommandModule = {
command: 'extensions <command>',
describe: 'Manage Gemini CLI extensions.',
builder: (yargs) =>
yargs
.command(installCommand)
.command(uninstallCommand)
.command(listCommand)
.command(updateCommand)
.command(disableCommand)
.command(enableCommand)
.demandCommand(1, 'You need at least one command before continuing.')
.version(false),
handler: () => {
// This handler is not called when a subcommand is provided.
// Yargs will show the help menu.
},
};

View File

@@ -0,0 +1,51 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { type CommandModule } from 'yargs';
import { disableExtension } from '../../config/extension.js';
import { SettingScope } from '../../config/settings.js';
import { getErrorMessage } from '../../utils/errors.js';
interface DisableArgs {
name: string;
scope: SettingScope;
}
export async function handleDisable(args: DisableArgs) {
try {
disableExtension(args.name, args.scope);
console.log(
`Extension "${args.name}" successfully disabled for scope "${args.scope}".`,
);
} catch (error) {
console.error(getErrorMessage(error));
process.exit(1);
}
}
export const disableCommand: CommandModule = {
command: 'disable [--scope] <name>',
describe: 'Disables an extension.',
builder: (yargs) =>
yargs
.positional('name', {
describe: 'The name of the extension to disable.',
type: 'string',
})
.option('scope', {
describe: 'The scope to disable the extenison in.',
type: 'string',
default: SettingScope.User,
choices: [SettingScope.User, SettingScope.Workspace],
})
.check((_argv) => true),
handler: async (argv) => {
await handleDisable({
name: argv['name'] as string,
scope: argv['scope'] as SettingScope,
});
},
};

View File

@@ -0,0 +1,59 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { type CommandModule } from 'yargs';
import { FatalConfigError, getErrorMessage } from '@qwen-code/qwen-code-core';
import { enableExtension } from '../../config/extension.js';
import { SettingScope } from '../../config/settings.js';
interface EnableArgs {
name: string;
scope?: SettingScope;
}
export async function handleEnable(args: EnableArgs) {
try {
const scopes = args.scope
? [args.scope]
: [SettingScope.User, SettingScope.Workspace];
enableExtension(args.name, scopes);
if (args.scope) {
console.log(
`Extension "${args.name}" successfully enabled for scope "${args.scope}".`,
);
} else {
console.log(
`Extension "${args.name}" successfully enabled in all scopes.`,
);
}
} catch (error) {
throw new FatalConfigError(getErrorMessage(error));
}
}
export const enableCommand: CommandModule = {
command: 'disable [--scope] <name>',
describe: 'Enables an extension.',
builder: (yargs) =>
yargs
.positional('name', {
describe: 'The name of the extension to enable.',
type: 'string',
})
.option('scope', {
describe:
'The scope to enable the extenison in. If not set, will be enabled in all scopes.',
type: 'string',
choices: [SettingScope.User, SettingScope.Workspace],
})
.check((_argv) => true),
handler: async (argv) => {
await handleEnable({
name: argv['name'] as string,
scope: argv['scope'] as SettingScope,
});
},
};

View File

@@ -0,0 +1,31 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { installCommand } from './install.js';
import yargs from 'yargs';
describe('extensions install command', () => {
it('should fail if no source is provided', () => {
const validationParser = yargs([])
.locale('en')
.command(installCommand)
.fail(false);
expect(() => validationParser.parse('install')).toThrow(
'Either a git URL --source or a --path must be provided.',
);
});
it('should fail if both git source and local path are provided', () => {
const validationParser = yargs([])
.locale('en')
.command(installCommand)
.fail(false);
expect(() =>
validationParser.parse('install --source some-url --path /some/path'),
).toThrow('Arguments source and path are mutually exclusive');
});
});

View File

@@ -0,0 +1,64 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { CommandModule } from 'yargs';
import {
installExtension,
type ExtensionInstallMetadata,
} from '../../config/extension.js';
import { getErrorMessage } from '../../utils/errors.js';
interface InstallArgs {
source?: string;
path?: string;
}
export async function handleInstall(args: InstallArgs) {
try {
const installMetadata: ExtensionInstallMetadata = {
source: (args.source || args.path) as string,
type: args.source ? 'git' : 'local',
};
const extensionName = await installExtension(installMetadata);
console.log(
`Extension "${extensionName}" installed successfully and enabled.`,
);
} catch (error) {
console.error(getErrorMessage(error));
process.exit(1);
}
}
export const installCommand: CommandModule = {
command: 'install [--source | --path ]',
describe: 'Installs an extension from a git repository or a local path.',
builder: (yargs) =>
yargs
.option('source', {
describe: 'The git URL of the extension to install.',
type: 'string',
})
.option('path', {
describe: 'Path to a local extension directory.',
type: 'string',
})
.conflicts('source', 'path')
.check((argv) => {
if (!argv.source && !argv.path) {
throw new Error(
'Either a git URL --source or a --path must be provided.',
);
}
return true;
}),
handler: async (argv) => {
await handleInstall({
source: argv['source'] as string | undefined,
path: argv['path'] as string | undefined,
});
},
};

View File

@@ -0,0 +1,36 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { CommandModule } from 'yargs';
import { loadUserExtensions, toOutputString } from '../../config/extension.js';
import { getErrorMessage } from '../../utils/errors.js';
export async function handleList() {
try {
const extensions = loadUserExtensions();
if (extensions.length === 0) {
console.log('No extensions installed.');
return;
}
console.log(
extensions
.map((extension, _): string => toOutputString(extension))
.join('\n\n'),
);
} catch (error) {
console.error(getErrorMessage(error));
process.exit(1);
}
}
export const listCommand: CommandModule = {
command: 'list',
describe: 'Lists installed extensions.',
builder: (yargs) => yargs,
handler: async () => {
await handleList();
},
};

View File

@@ -0,0 +1,21 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { uninstallCommand } from './uninstall.js';
import yargs from 'yargs';
describe('extensions uninstall command', () => {
it('should fail if no source is provided', () => {
const validationParser = yargs([])
.locale('en')
.command(uninstallCommand)
.fail(false);
expect(() => validationParser.parse('uninstall')).toThrow(
'Not enough non-option arguments: got 0, need at least 1',
);
});
});

View File

@@ -0,0 +1,47 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { CommandModule } from 'yargs';
import { uninstallExtension } from '../../config/extension.js';
import { getErrorMessage } from '../../utils/errors.js';
interface UninstallArgs {
name: string;
}
export async function handleUninstall(args: UninstallArgs) {
try {
await uninstallExtension(args.name);
console.log(`Extension "${args.name}" successfully uninstalled.`);
} catch (error) {
console.error(getErrorMessage(error));
process.exit(1);
}
}
export const uninstallCommand: CommandModule = {
command: 'uninstall <name>',
describe: 'Uninstalls an extension.',
builder: (yargs) =>
yargs
.positional('name', {
describe: 'The name of the extension to uninstall.',
type: 'string',
})
.check((argv) => {
if (!argv.name) {
throw new Error(
'Please include the name of the extension to uninstall as a positional argument.',
);
}
return true;
}),
handler: async (argv) => {
await handleUninstall({
name: argv['name'] as string,
});
},
};

View File

@@ -0,0 +1,47 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { CommandModule } from 'yargs';
import { updateExtension } from '../../config/extension.js';
import { getErrorMessage } from '../../utils/errors.js';
interface UpdateArgs {
name: string;
}
export async function handleUpdate(args: UpdateArgs) {
try {
// TODO(chrstnb): we should list extensions if the requested extension is not installed.
const updatedExtensionInfo = await updateExtension(args.name);
if (!updatedExtensionInfo) {
console.log(`Extension "${args.name}" failed to update.`);
return;
}
console.log(
`Extension "${args.name}" successfully updated: ${updatedExtensionInfo.originalVersion}${updatedExtensionInfo.updatedVersion}.`,
);
} catch (error) {
console.error(getErrorMessage(error));
process.exit(1);
}
}
export const updateCommand: CommandModule = {
command: 'update <name>',
describe: 'Updates an extension.',
builder: (yargs) =>
yargs
.positional('name', {
describe: 'The name of the extension to update.',
type: 'string',
})
.check((_argv) => true),
handler: async (argv) => {
await handleUpdate({
name: argv['name'] as string,
});
},
};

View File

@@ -7,7 +7,7 @@
// File for 'gemini mcp add' command
import type { CommandModule } from 'yargs';
import { loadSettings, SettingScope } from '../../config/settings.js';
import { MCPServerConfig } from '@qwen-code/qwen-code-core';
import type { MCPServerConfig } from '@qwen-code/qwen-code-core';
async function addMcpServer(
name: string,

View File

@@ -11,9 +11,27 @@ import { loadExtensions } from '../../config/extension.js';
import { createTransport } from '@qwen-code/qwen-code-core';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
vi.mock('../../config/settings.js');
vi.mock('../../config/extension.js');
vi.mock('@qwen-code/qwen-code-core');
vi.mock('../../config/settings.js', () => ({
loadSettings: vi.fn(),
}));
vi.mock('../../config/extension.js', () => ({
loadExtensions: vi.fn(),
}));
vi.mock('@qwen-code/qwen-code-core', () => ({
createTransport: vi.fn(),
MCPServerStatus: {
CONNECTED: 'CONNECTED',
CONNECTING: 'CONNECTING',
DISCONNECTED: 'DISCONNECTED',
},
Storage: vi.fn().mockImplementation((_cwd: string) => ({
getGlobalSettingsPath: () => '/tmp/qwen/settings.json',
getWorkspaceSettingsPath: () => '/tmp/qwen/workspace-settings.json',
getProjectTempDir: () => '/test/home/.qwen/tmp/mocked_hash',
})),
GEMINI_CONFIG_DIR: '.qwen',
getErrorMessage: (e: unknown) => (e instanceof Error ? e.message : String(e)),
}));
vi.mock('@modelcontextprotocol/sdk/client/index.js');
const mockedLoadSettings = loadSettings as vi.Mock;

View File

@@ -7,11 +7,8 @@
// File for 'gemini mcp list' command
import type { CommandModule } from 'yargs';
import { loadSettings } from '../../config/settings.js';
import {
MCPServerConfig,
MCPServerStatus,
createTransport,
} from '@qwen-code/qwen-code-core';
import type { MCPServerConfig } from '@qwen-code/qwen-code-core';
import { MCPServerStatus, createTransport } from '@qwen-code/qwen-code-core';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { loadExtensions } from '../../config/extension.js';

View File

@@ -5,14 +5,14 @@
*/
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import { tmpdir } from 'os';
import {
Config,
import * as fs from 'node:fs';
import * as path from 'node:path';
import { tmpdir } from 'node:os';
import type {
ConfigParameters,
ContentGeneratorConfig,
} from '@qwen-code/qwen-code-core';
import { Config } from '@qwen-code/qwen-code-core';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
@@ -282,7 +282,7 @@ describe('Configuration Integration Tests', () => {
'test',
];
const argv = await parseArguments();
const argv = await parseArguments({} as Settings);
// Verify that the argument was parsed correctly
expect(argv.approvalMode).toBe('auto_edit');
@@ -306,7 +306,7 @@ describe('Configuration Integration Tests', () => {
'test',
];
const argv = await parseArguments();
const argv = await parseArguments({} as Settings);
expect(argv.approvalMode).toBe('yolo');
expect(argv.prompt).toBe('test');
@@ -329,7 +329,7 @@ describe('Configuration Integration Tests', () => {
'test',
];
const argv = await parseArguments();
const argv = await parseArguments({} as Settings);
expect(argv.approvalMode).toBe('default');
expect(argv.prompt).toBe('test');
@@ -345,7 +345,7 @@ describe('Configuration Integration Tests', () => {
try {
process.argv = ['node', 'script.js', '--yolo', '-p', 'test'];
const argv = await parseArguments();
const argv = await parseArguments({} as Settings);
expect(argv.yolo).toBe(true);
expect(argv.approvalMode).toBeUndefined(); // Should NOT be set when using --yolo
@@ -362,7 +362,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()).rejects.toThrow();
await expect(parseArguments({} as Settings)).rejects.toThrow();
} finally {
process.argv = originalArgv;
}
@@ -381,7 +381,7 @@ describe('Configuration Integration Tests', () => {
];
// Should throw during argument parsing due to conflict validation
await expect(parseArguments()).rejects.toThrow();
await expect(parseArguments({} as Settings)).rejects.toThrow();
} finally {
process.argv = originalArgv;
}
@@ -394,7 +394,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();
const argv = await parseArguments({} as Settings);
expect(argv.approvalMode).toBeUndefined();
expect(argv.yolo).toBe(false);

File diff suppressed because it is too large Load Diff

197
packages/cli/src/config/config.ts Normal file → Executable file
View File

@@ -4,37 +4,41 @@
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'fs';
import * as path from 'path';
import { homedir } from 'node:os';
import yargs from 'yargs/yargs';
import { hideBin } from 'yargs/helpers';
import process from 'node:process';
import { mcpCommand } from '../commands/mcp.js';
import type {
ConfigParameters,
FileFilteringOptions,
MCPServerConfig,
TelemetryTarget,
} from '@qwen-code/qwen-code-core';
import {
ApprovalMode,
Config,
DEFAULT_GEMINI_EMBEDDING_MODEL,
DEFAULT_GEMINI_MODEL,
DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
EditTool,
FileDiscoveryService,
getCurrentGeminiMdFilename,
loadServerHierarchicalMemory,
setGeminiMdFilename as setServerGeminiMdFilename,
getCurrentGeminiMdFilename,
ApprovalMode,
DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_EMBEDDING_MODEL,
DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
FileDiscoveryService,
TelemetryTarget,
FileFilteringOptions,
ShellTool,
EditTool,
WriteFileTool,
MCPServerConfig,
ConfigParameters,
} from '@qwen-code/qwen-code-core';
import { Settings } from './settings.js';
import * as fs from 'node:fs';
import { homedir } from 'node:os';
import * as path from 'node:path';
import process from 'node:process';
import { hideBin } from 'yargs/helpers';
import yargs from 'yargs/yargs';
import { extensionsCommand } from '../commands/extensions.js';
import { mcpCommand } from '../commands/mcp.js';
import type { Settings } from './settings.js';
import { Extension, annotateActiveExtensions } from './extension.js';
import { getCliVersion } from '../utils/version.js';
import { loadSandboxConfig } from './sandboxConfig.js';
import { resolvePath } from '../utils/resolvePath.js';
import { getCliVersion } from '../utils/version.js';
import type { Extension } from './extension.js';
import { annotateActiveExtensions } from './extension.js';
import { loadSandboxConfig } from './sandboxConfig.js';
import { isWorkspaceTrusted } from './trustedFolders.js';
@@ -56,9 +60,7 @@ export interface CliArgs {
prompt: string | undefined;
promptInteractive: string | undefined;
allFiles: boolean | undefined;
all_files: boolean | undefined;
showMemoryUsage: boolean | undefined;
show_memory_usage: boolean | undefined;
yolo: boolean | undefined;
approvalMode: string | undefined;
telemetry: boolean | undefined;
@@ -69,6 +71,7 @@ export interface CliArgs {
telemetryLogPrompts: boolean | undefined;
telemetryOutfile: string | undefined;
allowedMcpServerNames: string[] | undefined;
allowedTools: string[] | undefined;
experimentalAcp: boolean | undefined;
extensions: string[] | undefined;
listExtensions: boolean | undefined;
@@ -78,9 +81,10 @@ export interface CliArgs {
proxy: string | undefined;
includeDirectories: string[] | undefined;
tavilyApiKey: string | undefined;
screenReader: boolean | undefined;
}
export async function parseArguments(): Promise<CliArgs> {
export async function parseArguments(settings: Settings): Promise<CliArgs> {
const yargsInstance = yargs(hideBin(process.argv))
// Set locale to English for consistent output, especially in tests
.locale('en')
@@ -128,29 +132,11 @@ export async function parseArguments(): Promise<CliArgs> {
description: 'Include ALL files in context?',
default: false,
})
.option('all_files', {
type: 'boolean',
description: 'Include ALL files in context?',
default: false,
})
.deprecateOption(
'all_files',
'Use --all-files instead. We will be removing --all_files in the coming weeks.',
)
.option('show-memory-usage', {
type: 'boolean',
description: 'Show memory usage in status bar',
default: false,
})
.option('show_memory_usage', {
type: 'boolean',
description: 'Show memory usage in status bar',
default: false,
})
.deprecateOption(
'show_memory_usage',
'Use --show-memory-usage instead. We will be removing --show_memory_usage in the coming weeks.',
)
.option('yolo', {
alias: 'y',
type: 'boolean',
@@ -210,6 +196,11 @@ export async function parseArguments(): Promise<CliArgs> {
string: true,
description: 'Allowed MCP server names',
})
.option('allowed-tools', {
type: 'array',
string: true,
description: 'Tools that are allowed to run without confirmation',
})
.option('extensions', {
alias: 'e',
type: 'array',
@@ -253,7 +244,11 @@ export async function parseArguments(): Promise<CliArgs> {
type: 'string',
description: 'Tavily API key for web search functionality',
})
.option('screen-reader', {
type: 'boolean',
description: 'Enable screen reader mode for accessibility.',
default: false,
})
.check((argv) => {
if (argv.prompt && argv['promptInteractive']) {
throw new Error(
@@ -269,7 +264,13 @@ export async function parseArguments(): Promise<CliArgs> {
}),
)
// Register MCP subcommands
.command(mcpCommand)
.command(mcpCommand);
if (settings?.experimental?.extensionManagement ?? false) {
yargsInstance.command(extensionsCommand);
}
yargsInstance
.version(await getCliVersion()) // This will enable the --version flag based on package.json
.alias('v', 'version')
.help()
@@ -282,7 +283,10 @@ export async function parseArguments(): Promise<CliArgs> {
// Handle case where MCP subcommands are executed - they should exit the process
// and not return to main CLI logic
if (result._.length > 0 && result._[0] === 'mcp') {
if (
result._.length > 0 &&
(result._[0] === 'mcp' || result._[0] === 'extensions')
) {
// MCP commands handle their own execution and process exit
process.exit(0);
}
@@ -329,7 +333,7 @@ export async function loadHierarchicalGeminiMemory(
extensionContextFilePaths,
memoryImportFormat,
fileFilteringOptions,
settings.memoryDiscoveryMaxDirs,
settings.context?.discoveryMaxDirs,
);
}
@@ -346,18 +350,20 @@ export async function loadCliConfig(
(v) => v === 'true' || v === '1',
) ||
false;
const memoryImportFormat = settings.memoryImportFormat || 'tree';
const memoryImportFormat = settings.context?.importFormat || 'tree';
const ideMode = settings.ideMode ?? false;
const ideMode = settings.ide?.enabled ?? false;
const folderTrustFeature = settings.folderTrustFeature ?? false;
const folderTrustSetting = settings.folderTrust ?? true;
const folderTrustFeature =
settings.security?.folderTrust?.featureEnabled ?? false;
const folderTrustSetting = settings.security?.folderTrust?.enabled ?? true;
const folderTrust = folderTrustFeature && folderTrustSetting;
const trustedFolder = isWorkspaceTrusted(settings);
const allExtensions = annotateActiveExtensions(
extensions,
argv.extensions || [],
cwd,
);
const activeExtensions = extensions.filter(
@@ -382,8 +388,8 @@ export async function loadCliConfig(
// TODO(b/343434939): This is a bit of a hack. The contextFileName should ideally be passed
// directly to the Config constructor in core, and have core handle setGeminiMdFilename.
// However, loadHierarchicalGeminiMemory is called *before* createServerConfig.
if (settings.contextFileName) {
setServerGeminiMdFilename(settings.contextFileName);
if (settings.context?.fileName) {
setServerGeminiMdFilename(settings.context.fileName);
} else {
// Reset to default if not provided in settings.
setServerGeminiMdFilename(getCurrentGeminiMdFilename());
@@ -397,17 +403,19 @@ export async function loadCliConfig(
const fileFiltering = {
...DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
...settings.fileFiltering,
...settings.context?.fileFiltering,
};
const includeDirectories = (settings.includeDirectories || [])
const includeDirectories = (settings.context?.includeDirectories || [])
.map(resolvePath)
.concat((argv.includeDirectories || []).map(resolvePath));
// Call the (now wrapper) loadHierarchicalGeminiMemory which calls the server's version
const { memoryContent, fileCount } = await loadHierarchicalGeminiMemory(
cwd,
settings.loadMemoryFromIncludeDirectories ? includeDirectories : [],
settings.context?.loadMemoryFromIncludeDirectories
? includeDirectories
: [],
debugMode,
fileService,
settings,
@@ -444,6 +452,14 @@ export async function loadCliConfig(
argv.yolo || false ? ApprovalMode.YOLO : ApprovalMode.DEFAULT;
}
// Force approval mode to default if the folder is not trusted.
if (!trustedFolder && approvalMode !== ApprovalMode.DEFAULT) {
logger.warn(
`Approval mode overridden to "default" because the current folder is not trusted.`,
);
approvalMode = ApprovalMode.DEFAULT;
}
const interactive =
!!argv.promptInteractive || (process.stdin.isTTY && question.length === 0);
// In non-interactive mode, exclude tools that require a prompt.
@@ -475,16 +491,16 @@ export async function loadCliConfig(
const blockedMcpServers: Array<{ name: string; extensionName: string }> = [];
if (!argv.allowedMcpServerNames) {
if (settings.allowMCPServers) {
if (settings.mcp?.allowed) {
mcpServers = allowedMcpServers(
mcpServers,
settings.allowMCPServers,
settings.mcp.allowed,
blockedMcpServers,
);
}
if (settings.excludeMCPServers) {
const excludedNames = new Set(settings.excludeMCPServers.filter(Boolean));
if (settings.mcp?.excluded) {
const excludedNames = new Set(settings.mcp.excluded.filter(Boolean));
if (excludedNames.size > 0) {
mcpServers = Object.fromEntries(
Object.entries(mcpServers).filter(([key]) => !excludedNames.has(key)),
@@ -504,6 +520,10 @@ export async function loadCliConfig(
const sandboxConfig = await loadSandboxConfig(settings, argv);
const cliVersion = await getCliVersion();
const screenReader =
argv.screenReader !== undefined
? argv.screenReader
: (settings.ui?.accessibility?.screenReader ?? false);
return new Config({
sessionId,
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
@@ -511,25 +531,26 @@ export async function loadCliConfig(
targetDir: cwd,
includeDirectories,
loadMemoryFromIncludeDirectories:
settings.loadMemoryFromIncludeDirectories || false,
settings.context?.loadMemoryFromIncludeDirectories || false,
debugMode,
question,
fullContext: argv.allFiles || argv.all_files || false,
coreTools: settings.coreTools || undefined,
fullContext: argv.allFiles || false,
coreTools: settings.tools?.core || undefined,
allowedTools: argv.allowedTools || settings.tools?.allowed || undefined,
excludeTools,
toolDiscoveryCommand: settings.toolDiscoveryCommand,
toolCallCommand: settings.toolCallCommand,
mcpServerCommand: settings.mcpServerCommand,
toolDiscoveryCommand: settings.tools?.discoveryCommand,
toolCallCommand: settings.tools?.callCommand,
mcpServerCommand: settings.mcp?.serverCommand,
mcpServers,
userMemory: memoryContent,
geminiMdFileCount: fileCount,
approvalMode,
showMemoryUsage:
argv.showMemoryUsage ||
argv.show_memory_usage ||
settings.showMemoryUsage ||
false,
accessibility: settings.accessibility,
argv.showMemoryUsage || settings.ui?.showMemoryUsage || false,
accessibility: {
...settings.ui?.accessibility,
screenReader,
},
telemetry: {
enabled: argv.telemetry ?? settings.telemetry?.enabled,
target: (argv.telemetryTarget ??
@@ -546,15 +567,17 @@ export async function loadCliConfig(
logPrompts: argv.telemetryLogPrompts ?? settings.telemetry?.logPrompts,
outfile: argv.telemetryOutfile ?? settings.telemetry?.outfile,
},
usageStatisticsEnabled: settings.usageStatisticsEnabled ?? true,
usageStatisticsEnabled: settings.privacy?.usageStatisticsEnabled ?? true,
// Git-aware file filtering settings
fileFiltering: {
respectGitIgnore: settings.fileFiltering?.respectGitIgnore,
respectGeminiIgnore: settings.fileFiltering?.respectGeminiIgnore,
respectGitIgnore: settings.context?.fileFiltering?.respectGitIgnore,
respectGeminiIgnore: settings.context?.fileFiltering?.respectGeminiIgnore,
enableRecursiveFileSearch:
settings.fileFiltering?.enableRecursiveFileSearch,
settings.context?.fileFiltering?.enableRecursiveFileSearch,
disableFuzzySearch: settings.context?.fileFiltering?.disableFuzzySearch,
},
checkpointing: argv.checkpointing || settings.checkpointing?.enabled,
checkpointing:
argv.checkpointing || settings.general?.checkpointing?.enabled,
proxy:
argv.proxy ||
process.env['HTTPS_PROXY'] ||
@@ -563,18 +586,16 @@ export async function loadCliConfig(
process.env['http_proxy'],
cwd,
fileDiscoveryService: fileService,
bugCommand: settings.bugCommand,
model: argv.model || settings.model || DEFAULT_GEMINI_MODEL,
bugCommand: settings.advanced?.bugCommand,
model: argv.model || settings.model?.name || DEFAULT_GEMINI_MODEL,
extensionContextFilePaths,
maxSessionTurns: settings.maxSessionTurns ?? -1,
sessionTokenLimit: settings.sessionTokenLimit ?? -1,
maxSessionTurns: settings.model?.maxSessionTurns ?? -1,
experimentalZedIntegration: argv.experimentalAcp || false,
listExtensions: argv.listExtensions || false,
extensions: allExtensions,
blockedMcpServers,
noBrowser: !!process.env['NO_BROWSER'],
summarizeToolOutput: settings.summarizeToolOutput,
ideMode,
enableOpenAILogging:
(typeof argv.openaiLogging === 'undefined'
? settings.enableOpenAILogging
@@ -590,20 +611,24 @@ export async function loadCliConfig(
'SYSTEM_TEMPLATE:{"name":"qwen3_coder","params":{"is_git_repository":{RUNTIME_VARS_IS_GIT_REPO},"sandbox":"{RUNTIME_VARS_SANDBOX}"}}',
},
]) as ConfigParameters['systemPromptMappings'],
authType: settings.selectedAuthType,
authType: settings.security?.auth?.selectedType,
contentGenerator: settings.contentGenerator,
cliVersion,
tavilyApiKey:
argv.tavilyApiKey ||
settings.tavilyApiKey ||
process.env['TAVILY_API_KEY'],
chatCompression: settings.chatCompression,
summarizeToolOutput: settings.model?.summarizeToolOutput,
ideMode,
chatCompression: settings.model?.chatCompression,
folderTrustFeature,
folderTrust,
interactive,
trustedFolder,
shouldUseNodePtyShell: settings.shouldUseNodePtyShell,
skipNextSpeakerCheck: settings.skipNextSpeakerCheck,
useRipgrep: settings.tools?.useRipgrep,
shouldUseNodePtyShell: settings.tools?.usePty,
skipNextSpeakerCheck: settings.model?.skipNextSpeakerCheck,
enablePromptCompletion: settings.general?.enablePromptCompletion ?? false,
});
}
@@ -665,7 +690,7 @@ function mergeExcludeTools(
extraExcludes?: string[] | undefined,
): string[] {
const allExcludeTools = new Set([
...(settings.excludeTools || []),
...(settings.tools?.exclude || []),
...(extraExcludes || []),
]);
for (const extension of extensions) {

View File

@@ -5,24 +5,52 @@
*/
import { vi } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import {
EXTENSIONS_CONFIG_FILENAME,
EXTENSIONS_DIRECTORY_NAME,
INSTALL_METADATA_FILENAME,
annotateActiveExtensions,
disableExtension,
enableExtension,
installExtension,
loadExtension,
loadExtensions,
performWorkspaceExtensionMigration,
uninstallExtension,
updateExtension,
} from './extension.js';
import {
type GeminiCLIExtension,
type MCPServerConfig,
} from '@qwen-code/qwen-code-core';
import { execSync } from 'node:child_process';
import { SettingScope, loadSettings } from './settings.js';
import { type SimpleGit, simpleGit } from 'simple-git';
vi.mock('simple-git', () => ({
simpleGit: vi.fn(),
}));
vi.mock('os', async (importOriginal) => {
const os = await importOriginal<typeof import('os')>();
const os = await importOriginal<typeof os>();
return {
...os,
homedir: vi.fn(),
};
});
vi.mock('child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('child_process')>();
return {
...actual,
execSync: vi.fn(),
};
});
const EXTENSIONS_DIRECTORY_NAME = path.join('.qwen', 'extensions');
describe('loadExtensions', () => {
let tempWorkspaceDir: string;
let tempHomeDir: string;
@@ -40,56 +68,7 @@ describe('loadExtensions', () => {
afterEach(() => {
fs.rmSync(tempWorkspaceDir, { recursive: true, force: true });
fs.rmSync(tempHomeDir, { recursive: true, force: true });
});
it('should include extension path in loaded extension', () => {
const workspaceExtensionsDir = path.join(
tempWorkspaceDir,
EXTENSIONS_DIRECTORY_NAME,
);
fs.mkdirSync(workspaceExtensionsDir, { recursive: true });
const extensionDir = path.join(workspaceExtensionsDir, 'test-extension');
fs.mkdirSync(extensionDir, { recursive: true });
const config = {
name: 'test-extension',
version: '1.0.0',
};
fs.writeFileSync(
path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME),
JSON.stringify(config),
);
const extensions = loadExtensions(tempWorkspaceDir);
expect(extensions).toHaveLength(1);
expect(extensions[0].path).toBe(extensionDir);
expect(extensions[0].config.name).toBe('test-extension');
});
it('should include extension path in loaded extension', () => {
const workspaceExtensionsDir = path.join(
tempWorkspaceDir,
EXTENSIONS_DIRECTORY_NAME,
);
fs.mkdirSync(workspaceExtensionsDir, { recursive: true });
const extensionDir = path.join(workspaceExtensionsDir, 'test-extension');
fs.mkdirSync(extensionDir, { recursive: true });
const config = {
name: 'test-extension',
version: '1.0.0',
};
fs.writeFileSync(
path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME),
JSON.stringify(config),
);
const extensions = loadExtensions(tempWorkspaceDir);
expect(extensions).toHaveLength(1);
expect(extensions[0].path).toBe(extensionDir);
expect(extensions[0].config.name).toBe('test-extension');
vi.restoreAllMocks();
});
it('should include extension path in loaded extension', () => {
@@ -159,26 +138,101 @@ describe('loadExtensions', () => {
path.join(workspaceExtensionsDir, 'ext1', 'my-context-file.md'),
]);
});
it('should filter out disabled extensions', () => {
const workspaceExtensionsDir = path.join(
tempWorkspaceDir,
EXTENSIONS_DIRECTORY_NAME,
);
fs.mkdirSync(workspaceExtensionsDir, { recursive: true });
createExtension(workspaceExtensionsDir, 'ext1', '1.0.0');
createExtension(workspaceExtensionsDir, 'ext2', '2.0.0');
const settingsDir = path.join(tempWorkspaceDir, '.qwen');
fs.mkdirSync(settingsDir, { recursive: true });
fs.writeFileSync(
path.join(settingsDir, 'settings.json'),
JSON.stringify({ extensions: { disabled: ['ext1'] } }),
);
const extensions = loadExtensions(tempWorkspaceDir);
const activeExtensions = annotateActiveExtensions(
extensions,
[],
tempWorkspaceDir,
).filter((e) => e.isActive);
expect(activeExtensions).toHaveLength(1);
expect(activeExtensions[0].name).toBe('ext2');
});
it('should hydrate variables', () => {
const workspaceExtensionsDir = path.join(
tempWorkspaceDir,
EXTENSIONS_DIRECTORY_NAME,
);
fs.mkdirSync(workspaceExtensionsDir, { recursive: true });
createExtension(
workspaceExtensionsDir,
'test-extension',
'1.0.0',
false,
undefined,
{
'test-server': {
cwd: '${extensionPath}${/}server',
},
},
);
const extensions = loadExtensions(tempWorkspaceDir);
expect(extensions).toHaveLength(1);
const loadedConfig = extensions[0].config;
const expectedCwd = path.join(
workspaceExtensionsDir,
'test-extension',
'server',
);
expect(loadedConfig.mcpServers?.['test-server'].cwd).toBe(expectedCwd);
});
});
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: [] },
{
path: '/path/to/ext1',
config: { name: 'ext1', version: '1.0.0' },
contextFiles: [],
},
{
path: '/path/to/ext2',
config: { name: 'ext2', version: '1.0.0' },
contextFiles: [],
},
{
path: '/path/to/ext3',
config: { name: 'ext3', version: '1.0.0' },
contextFiles: [],
},
];
it('should mark all extensions as active if no enabled extensions are provided', () => {
const activeExtensions = annotateActiveExtensions(extensions, []);
const activeExtensions = annotateActiveExtensions(
extensions,
[],
'/path/to/workspace',
);
expect(activeExtensions).toHaveLength(3);
expect(activeExtensions.every((e) => e.isActive)).toBe(true);
});
it('should mark only the enabled extensions as active', () => {
const activeExtensions = annotateActiveExtensions(extensions, [
'ext1',
'ext3',
]);
const activeExtensions = annotateActiveExtensions(
extensions,
['ext1', 'ext3'],
'/path/to/workspace',
);
expect(activeExtensions).toHaveLength(3);
expect(activeExtensions.find((e) => e.name === 'ext1')?.isActive).toBe(
true,
@@ -192,13 +246,21 @@ describe('annotateActiveExtensions', () => {
});
it('should mark all extensions as inactive when "none" is provided', () => {
const activeExtensions = annotateActiveExtensions(extensions, ['none']);
const activeExtensions = annotateActiveExtensions(
extensions,
['none'],
'/path/to/workspace',
);
expect(activeExtensions).toHaveLength(3);
expect(activeExtensions.every((e) => !e.isActive)).toBe(true);
});
it('should handle case-insensitivity', () => {
const activeExtensions = annotateActiveExtensions(extensions, ['EXT1']);
const activeExtensions = annotateActiveExtensions(
extensions,
['EXT1'],
'/path/to/workspace',
);
expect(activeExtensions.find((e) => e.name === 'ext1')?.isActive).toBe(
true,
);
@@ -206,24 +268,258 @@ describe('annotateActiveExtensions', () => {
it('should log an error for unknown extensions', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
annotateActiveExtensions(extensions, ['ext4']);
annotateActiveExtensions(extensions, ['ext4'], '/path/to/workspace');
expect(consoleSpy).toHaveBeenCalledWith('Extension not found: ext4');
consoleSpy.mockRestore();
});
});
describe('installExtension', () => {
let tempHomeDir: string;
let userExtensionsDir: string;
beforeEach(() => {
tempHomeDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'qwen-code-test-home-'),
);
vi.mocked(os.homedir).mockReturnValue(tempHomeDir);
userExtensionsDir = path.join(tempHomeDir, '.qwen', 'extensions');
// Clean up before each test
fs.rmSync(userExtensionsDir, { recursive: true, force: true });
fs.mkdirSync(userExtensionsDir, { recursive: true });
vi.mocked(execSync).mockClear();
});
afterEach(() => {
fs.rmSync(tempHomeDir, { recursive: true, force: true });
});
it('should install an extension from a local path', async () => {
const sourceExtDir = createExtension(
tempHomeDir,
'my-local-extension',
'1.0.0',
);
const targetExtDir = path.join(userExtensionsDir, 'my-local-extension');
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
await installExtension({ source: sourceExtDir, type: 'local' });
expect(fs.existsSync(targetExtDir)).toBe(true);
expect(fs.existsSync(metadataPath)).toBe(true);
const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
expect(metadata).toEqual({
source: sourceExtDir,
type: 'local',
});
fs.rmSync(targetExtDir, { recursive: true, force: true });
});
it('should throw an error if the extension already exists', async () => {
const sourceExtDir = createExtension(
tempHomeDir,
'my-local-extension',
'1.0.0',
);
await installExtension({ source: sourceExtDir, type: 'local' });
await expect(
installExtension({ source: sourceExtDir, type: 'local' }),
).rejects.toThrow(
'Extension "my-local-extension" is already installed. Please uninstall it first.',
);
});
it('should throw an error and cleanup if gemini-extension.json is missing', async () => {
const sourceExtDir = path.join(tempHomeDir, 'bad-extension');
fs.mkdirSync(sourceExtDir, { recursive: true });
await expect(
installExtension({ source: sourceExtDir, type: 'local' }),
).rejects.toThrow(
`Invalid extension at ${sourceExtDir}. Please make sure it has a valid gemini-extension.json file.`,
);
const targetExtDir = path.join(userExtensionsDir, 'bad-extension');
expect(fs.existsSync(targetExtDir)).toBe(false);
});
it('should install an extension from a git URL', async () => {
const gitUrl = 'https://github.com/google/gemini-extensions.git';
const extensionName = 'gemini-extensions';
const targetExtDir = path.join(userExtensionsDir, extensionName);
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
const clone = vi.fn().mockImplementation(async (_, destination) => {
fs.mkdirSync(destination, { recursive: true });
fs.writeFileSync(
path.join(destination, EXTENSIONS_CONFIG_FILENAME),
JSON.stringify({ name: extensionName, version: '1.0.0' }),
);
});
const mockedSimpleGit = simpleGit as vi.MockedFunction<typeof simpleGit>;
mockedSimpleGit.mockReturnValue({ clone } as unknown as SimpleGit);
await installExtension({ source: gitUrl, type: 'git' });
expect(fs.existsSync(targetExtDir)).toBe(true);
expect(fs.existsSync(metadataPath)).toBe(true);
const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
expect(metadata).toEqual({
source: gitUrl,
type: 'git',
});
fs.rmSync(targetExtDir, { recursive: true, force: true });
});
});
describe('uninstallExtension', () => {
let tempHomeDir: string;
let userExtensionsDir: string;
beforeEach(() => {
tempHomeDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'qwen-code-test-home-'),
);
vi.mocked(os.homedir).mockReturnValue(tempHomeDir);
userExtensionsDir = path.join(tempHomeDir, '.qwen', 'extensions');
// Clean up before each test
fs.rmSync(userExtensionsDir, { recursive: true, force: true });
fs.mkdirSync(userExtensionsDir, { recursive: true });
vi.mocked(execSync).mockClear();
});
afterEach(() => {
fs.rmSync(tempHomeDir, { recursive: true, force: true });
});
it('should uninstall an extension by name', async () => {
const sourceExtDir = createExtension(
userExtensionsDir,
'my-local-extension',
'1.0.0',
);
await uninstallExtension('my-local-extension');
expect(fs.existsSync(sourceExtDir)).toBe(false);
});
it('should uninstall an extension by name and retain existing extensions', async () => {
const sourceExtDir = createExtension(
userExtensionsDir,
'my-local-extension',
'1.0.0',
);
const otherExtDir = createExtension(
userExtensionsDir,
'other-extension',
'1.0.0',
);
await uninstallExtension('my-local-extension');
expect(fs.existsSync(sourceExtDir)).toBe(false);
expect(loadExtensions(tempHomeDir)).toHaveLength(1);
expect(fs.existsSync(otherExtDir)).toBe(true);
});
it('should throw an error if the extension does not exist', async () => {
await expect(uninstallExtension('nonexistent-extension')).rejects.toThrow(
'Extension "nonexistent-extension" not found.',
);
});
});
describe('performWorkspaceExtensionMigration', () => {
let tempWorkspaceDir: string;
let tempHomeDir: string;
beforeEach(() => {
tempWorkspaceDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'qwen-code-test-workspace-'),
);
tempHomeDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'qwen-code-test-home-'),
);
vi.mocked(os.homedir).mockReturnValue(tempHomeDir);
});
afterEach(() => {
fs.rmSync(tempWorkspaceDir, { recursive: true, force: true });
fs.rmSync(tempHomeDir, { recursive: true, force: true });
vi.restoreAllMocks();
});
it('should install the extensions in the user directory', async () => {
const workspaceExtensionsDir = path.join(
tempWorkspaceDir,
EXTENSIONS_DIRECTORY_NAME,
);
fs.mkdirSync(workspaceExtensionsDir, { recursive: true });
const ext1Path = createExtension(workspaceExtensionsDir, 'ext1', '1.0.0');
const ext2Path = createExtension(workspaceExtensionsDir, 'ext2', '1.0.0');
const extensionsToMigrate = [
loadExtension(ext1Path)!,
loadExtension(ext2Path)!,
];
const failed =
await performWorkspaceExtensionMigration(extensionsToMigrate);
expect(failed).toEqual([]);
const userExtensionsDir = path.join(tempHomeDir, '.qwen', 'extensions');
const userExt1Path = path.join(userExtensionsDir, 'ext1');
const extensions = loadExtensions(tempWorkspaceDir);
expect(extensions).toHaveLength(2);
const metadataPath = path.join(userExt1Path, INSTALL_METADATA_FILENAME);
expect(fs.existsSync(metadataPath)).toBe(true);
const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
expect(metadata).toEqual({
source: ext1Path,
type: 'local',
});
});
it('should return the names of failed installations', async () => {
const workspaceExtensionsDir = path.join(
tempWorkspaceDir,
EXTENSIONS_DIRECTORY_NAME,
);
fs.mkdirSync(workspaceExtensionsDir, { recursive: true });
const ext1Path = createExtension(workspaceExtensionsDir, 'ext1', '1.0.0');
const extensions = [
loadExtension(ext1Path)!,
{
path: '/ext/path/1',
config: { name: 'ext2', version: '1.0.0' },
contextFiles: [],
},
];
const failed = await performWorkspaceExtensionMigration(extensions);
expect(failed).toEqual(['ext2']);
});
});
function createExtension(
extensionsDir: string,
name: string,
version: string,
addContextFile = false,
contextFileName?: string,
): void {
mcpServers?: Record<string, MCPServerConfig>,
): string {
const extDir = path.join(extensionsDir, name);
fs.mkdirSync(extDir);
fs.mkdirSync(extDir, { recursive: true });
fs.writeFileSync(
path.join(extDir, EXTENSIONS_CONFIG_FILENAME),
JSON.stringify({ name, version, contextFileName }),
JSON.stringify({ name, version, contextFileName, mcpServers }),
);
if (addContextFile) {
@@ -233,4 +529,193 @@ function createExtension(
if (contextFileName) {
fs.writeFileSync(path.join(extDir, contextFileName), 'context');
}
return extDir;
}
describe('updateExtension', () => {
let tempHomeDir: string;
let userExtensionsDir: string;
beforeEach(() => {
tempHomeDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'qwen-code-test-home-'),
);
vi.mocked(os.homedir).mockReturnValue(tempHomeDir);
userExtensionsDir = path.join(tempHomeDir, '.qwen', 'extensions');
// Clean up before each test
fs.rmSync(userExtensionsDir, { recursive: true, force: true });
fs.mkdirSync(userExtensionsDir, { recursive: true });
vi.mocked(execSync).mockClear();
});
afterEach(() => {
fs.rmSync(tempHomeDir, { recursive: true, force: true });
});
it('should update a git-installed extension', async () => {
// 1. "Install" an extension
const gitUrl = 'https://github.com/google/gemini-extensions.git';
const extensionName = 'gemini-extensions';
const targetExtDir = path.join(userExtensionsDir, extensionName);
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
// Create the "installed" extension directory and files
fs.mkdirSync(targetExtDir, { recursive: true });
fs.writeFileSync(
path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME),
JSON.stringify({ name: extensionName, version: '1.0.0' }),
);
fs.writeFileSync(
metadataPath,
JSON.stringify({ source: gitUrl, type: 'git' }),
);
// 2. Mock the git clone for the update
const clone = vi.fn().mockImplementation(async (_, destination) => {
fs.mkdirSync(destination, { recursive: true });
// This is the "updated" version
fs.writeFileSync(
path.join(destination, EXTENSIONS_CONFIG_FILENAME),
JSON.stringify({ name: extensionName, version: '1.1.0' }),
);
});
const mockedSimpleGit = simpleGit as vi.MockedFunction<typeof simpleGit>;
mockedSimpleGit.mockReturnValue({
clone,
} as unknown as SimpleGit);
// 3. Call updateExtension
const updateInfo = await updateExtension(extensionName);
// 4. Assertions
expect(updateInfo).toEqual({
originalVersion: '1.0.0',
updatedVersion: '1.1.0',
});
// Check that the config file reflects the new version
const updatedConfig = JSON.parse(
fs.readFileSync(
path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME),
'utf-8',
),
);
expect(updatedConfig.version).toBe('1.1.0');
});
});
describe('disableExtension', () => {
let tempWorkspaceDir: string;
let tempHomeDir: string;
beforeEach(() => {
tempWorkspaceDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'qwen-code-test-workspace-'),
);
tempHomeDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'qwen-code-test-home-'),
);
vi.mocked(os.homedir).mockReturnValue(tempHomeDir);
vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
});
afterEach(() => {
fs.rmSync(tempWorkspaceDir, { recursive: true, force: true });
fs.rmSync(tempHomeDir, { recursive: true, force: true });
});
it('should disable an extension at the user scope', () => {
disableExtension('my-extension', SettingScope.User);
const settings = loadSettings(tempWorkspaceDir);
expect(
settings.forScope(SettingScope.User).settings.extensions?.disabled,
).toEqual(['my-extension']);
});
it('should disable an extension at the workspace scope', () => {
disableExtension('my-extension', SettingScope.Workspace);
const settings = loadSettings(tempWorkspaceDir);
expect(
settings.forScope(SettingScope.Workspace).settings.extensions?.disabled,
).toEqual(['my-extension']);
});
it('should handle disabling the same extension twice', () => {
disableExtension('my-extension', SettingScope.User);
disableExtension('my-extension', SettingScope.User);
const settings = loadSettings(tempWorkspaceDir);
expect(
settings.forScope(SettingScope.User).settings.extensions?.disabled,
).toEqual(['my-extension']);
});
it('should throw an error if you request system scope', () => {
expect(() => disableExtension('my-extension', SettingScope.System)).toThrow(
'System and SystemDefaults scopes are not supported.',
);
});
});
describe('enableExtension', () => {
let tempWorkspaceDir: string;
let tempHomeDir: string;
let userExtensionsDir: string;
beforeEach(() => {
tempWorkspaceDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'qwen-code-test-workspace-'),
);
tempHomeDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'qwen-code-test-home-'),
);
userExtensionsDir = path.join(tempHomeDir, '.qwen', 'extensions');
vi.mocked(os.homedir).mockReturnValue(tempHomeDir);
vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
});
afterEach(() => {
fs.rmSync(tempWorkspaceDir, { recursive: true, force: true });
fs.rmSync(tempHomeDir, { recursive: true, force: true });
fs.rmSync(userExtensionsDir, { recursive: true, force: true });
});
afterAll(() => {
vi.restoreAllMocks();
});
const getActiveExtensions = (): GeminiCLIExtension[] => {
const extensions = loadExtensions(tempWorkspaceDir);
const activeExtensions = annotateActiveExtensions(
extensions,
[],
tempWorkspaceDir,
);
return activeExtensions.filter((e) => e.isActive);
};
it('should enable an extension at the user scope', () => {
createExtension(userExtensionsDir, 'ext1', '1.0.0');
disableExtension('ext1', SettingScope.User);
let activeExtensions = getActiveExtensions();
expect(activeExtensions).toHaveLength(0);
enableExtension('ext1', [SettingScope.User]);
activeExtensions = getActiveExtensions();
expect(activeExtensions).toHaveLength(1);
expect(activeExtensions[0].name).toBe('ext1');
});
it('should enable an extension at the workspace scope', () => {
createExtension(userExtensionsDir, 'ext1', '1.0.0');
disableExtension('ext1', SettingScope.Workspace);
let activeExtensions = getActiveExtensions();
expect(activeExtensions).toHaveLength(0);
enableExtension('ext1', [SettingScope.Workspace]);
activeExtensions = getActiveExtensions();
expect(activeExtensions).toHaveLength(1);
expect(activeExtensions[0].name).toBe('ext1');
});
});

View File

@@ -4,19 +4,29 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { MCPServerConfig, GeminiCLIExtension } from '@qwen-code/qwen-code-core';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import type {
MCPServerConfig,
GeminiCLIExtension,
} from '@qwen-code/qwen-code-core';
import { Storage } from '@qwen-code/qwen-code-core';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { simpleGit } from 'simple-git';
import { SettingScope, loadSettings } from '../config/settings.js';
import { getErrorMessage } from '../utils/errors.js';
import { recursivelyHydrateStrings } from './extensions/variables.js';
export const EXTENSIONS_DIRECTORY_NAME = path.join('.qwen', 'extensions');
export const EXTENSIONS_CONFIG_FILENAME = 'qwen-extension.json';
export const EXTENSIONS_CONFIG_FILENAME_OLD = 'gemini-extension.json';
export const INSTALL_METADATA_FILENAME = '.qwen-extension-install.json';
export interface Extension {
path: string;
config: ExtensionConfig;
contextFiles: string[];
installMetadata?: ExtensionInstallMetadata | undefined;
}
export interface ExtensionConfig {
@@ -27,14 +37,103 @@ export interface ExtensionConfig {
excludeTools?: string[];
}
export interface ExtensionInstallMetadata {
source: string;
type: 'git' | 'local';
}
export interface ExtensionUpdateInfo {
originalVersion: string;
updatedVersion: string;
}
export class ExtensionStorage {
private readonly extensionName: string;
constructor(extensionName: string) {
this.extensionName = extensionName;
}
getExtensionDir(): string {
return path.join(
ExtensionStorage.getUserExtensionsDir(),
this.extensionName,
);
}
getConfigPath(): string {
return path.join(this.getExtensionDir(), EXTENSIONS_CONFIG_FILENAME);
}
static getUserExtensionsDir(): string {
const storage = new Storage(os.homedir());
return storage.getExtensionsDir();
}
static async createTmpDir(): Promise<string> {
return await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'gemini-extension'),
);
}
}
export function getWorkspaceExtensions(workspaceDir: string): Extension[] {
return loadExtensionsFromDir(workspaceDir);
}
async function copyExtension(
source: string,
destination: string,
): Promise<void> {
await fs.promises.cp(source, destination, { recursive: true });
}
export async function performWorkspaceExtensionMigration(
extensions: Extension[],
): Promise<string[]> {
const failedInstallNames: string[] = [];
for (const extension of extensions) {
try {
const installMetadata: ExtensionInstallMetadata = {
source: extension.path,
type: 'local',
};
await installExtension(installMetadata);
} catch (_) {
failedInstallNames.push(extension.config.name);
}
}
return failedInstallNames;
}
export function loadExtensions(workspaceDir: string): Extension[] {
const allExtensions = [
...loadExtensionsFromDir(workspaceDir),
...loadExtensionsFromDir(os.homedir()),
];
const settings = loadSettings(workspaceDir).merged;
const disabledExtensions = settings.extensions?.disabled ?? [];
const allExtensions = [...loadUserExtensions()];
if (!settings.experimental?.extensionManagement) {
allExtensions.push(...getWorkspaceExtensions(workspaceDir));
}
const uniqueExtensions = new Map<string, Extension>();
for (const extension of allExtensions) {
if (
!uniqueExtensions.has(extension.config.name) &&
!disabledExtensions.includes(extension.config.name)
) {
uniqueExtensions.set(extension.config.name, extension);
}
}
return Array.from(uniqueExtensions.values());
}
export function loadUserExtensions(): Extension[] {
const userExtensions = loadExtensionsFromDir(os.homedir());
const uniqueExtensions = new Map<string, Extension>();
for (const extension of userExtensions) {
if (!uniqueExtensions.has(extension.config.name)) {
uniqueExtensions.set(extension.config.name, extension);
}
@@ -43,8 +142,9 @@ export function loadExtensions(workspaceDir: string): Extension[] {
return Array.from(uniqueExtensions.values());
}
function loadExtensionsFromDir(dir: string): Extension[] {
const extensionsDir = path.join(dir, EXTENSIONS_DIRECTORY_NAME);
export function loadExtensionsFromDir(dir: string): Extension[] {
const storage = new Storage(dir);
const extensionsDir = storage.getExtensionsDir();
if (!fs.existsSync(extensionsDir)) {
return [];
}
@@ -61,7 +161,7 @@ function loadExtensionsFromDir(dir: string): Extension[] {
return extensions;
}
function loadExtension(extensionDir: string): Extension | null {
export function loadExtension(extensionDir: string): Extension | null {
if (!fs.statSync(extensionDir).isDirectory()) {
console.error(
`Warning: unexpected file ${extensionDir} in extensions directory.`,
@@ -86,7 +186,11 @@ function loadExtension(extensionDir: string): Extension | null {
try {
const configContent = fs.readFileSync(configFilePath, 'utf-8');
const config = JSON.parse(configContent) as ExtensionConfig;
const config = recursivelyHydrateStrings(JSON.parse(configContent), {
extensionPath: extensionDir,
'/': path.sep,
pathSeparator: path.sep,
}) as unknown as ExtensionConfig;
if (!config.name || !config.version) {
console.error(
`Invalid extension config in ${configFilePath}: missing name or version.`,
@@ -102,15 +206,31 @@ function loadExtension(extensionDir: string): Extension | null {
path: extensionDir,
config,
contextFiles,
installMetadata: loadInstallMetadata(extensionDir),
};
} catch (e) {
console.error(
`Warning: error parsing extension config in ${configFilePath}: ${e}`,
`Warning: error parsing extension config in ${configFilePath}: ${getErrorMessage(
e,
)}`,
);
return null;
}
}
function loadInstallMetadata(
extensionDir: string,
): ExtensionInstallMetadata | undefined {
const metadataFilePath = path.join(extensionDir, INSTALL_METADATA_FILENAME);
try {
const configContent = fs.readFileSync(metadataFilePath, 'utf-8');
const metadata = JSON.parse(configContent) as ExtensionInstallMetadata;
return metadata;
} catch (_e) {
return undefined;
}
}
function getContextFileNames(config: ExtensionConfig): string[] {
if (!config.contextFileName) {
return ['QWEN.md'];
@@ -120,17 +240,28 @@ function getContextFileNames(config: ExtensionConfig): string[] {
return config.contextFileName;
}
/**
* Returns an annotated list of extensions. If an extension is listed in enabledExtensionNames, it will be active.
* If enabledExtensionNames is empty, an extension is active unless it is in list of disabled extensions in settings.
* @param extensions The base list of extensions.
* @param enabledExtensionNames The names of explicitly enabled extensions.
* @param workspaceDir The current workspace directory.
*/
export function annotateActiveExtensions(
extensions: Extension[],
enabledExtensionNames: string[],
workspaceDir: string,
): GeminiCLIExtension[] {
const settings = loadSettings(workspaceDir).merged;
const disabledExtensions = settings.extensions?.disabled ?? [];
const annotatedExtensions: GeminiCLIExtension[] = [];
if (enabledExtensionNames.length === 0) {
return extensions.map((extension) => ({
name: extension.config.name,
version: extension.config.version,
isActive: true,
isActive: !disabledExtensions.includes(extension.config.name),
path: extension.path,
}));
}
@@ -175,3 +306,230 @@ export function annotateActiveExtensions(
return annotatedExtensions;
}
/**
* Clones a Git repository to a specified local path.
* @param gitUrl The Git URL to clone.
* @param destination The destination path to clone the repository to.
*/
async function cloneFromGit(
gitUrl: string,
destination: string,
): Promise<void> {
try {
// TODO(chrstnb): Download the archive instead to avoid unnecessary .git info.
await simpleGit().clone(gitUrl, destination, ['--depth', '1']);
} catch (error) {
throw new Error(`Failed to clone Git repository from ${gitUrl}`, {
cause: error,
});
}
}
export async function installExtension(
installMetadata: ExtensionInstallMetadata,
cwd: string = process.cwd(),
): Promise<string> {
const extensionsDir = ExtensionStorage.getUserExtensionsDir();
await fs.promises.mkdir(extensionsDir, { recursive: true });
// Convert relative paths to absolute paths for the metadata file.
if (
installMetadata.type === 'local' &&
!path.isAbsolute(installMetadata.source)
) {
installMetadata.source = path.resolve(cwd, installMetadata.source);
}
let localSourcePath: string;
let tempDir: string | undefined;
if (installMetadata.type === 'git') {
tempDir = await ExtensionStorage.createTmpDir();
await cloneFromGit(installMetadata.source, tempDir);
localSourcePath = tempDir;
} else {
localSourcePath = installMetadata.source;
}
let newExtensionName: string | undefined;
try {
const newExtension = loadExtension(localSourcePath);
if (!newExtension) {
throw new Error(
`Invalid extension at ${installMetadata.source}. Please make sure it has a valid gemini-extension.json file.`,
);
}
// ~/.gemini/extensions/{ExtensionConfig.name}.
newExtensionName = newExtension.config.name;
const extensionStorage = new ExtensionStorage(newExtensionName);
const destinationPath = extensionStorage.getExtensionDir();
const installedExtensions = loadUserExtensions();
if (
installedExtensions.some(
(installed) => installed.config.name === newExtensionName,
)
) {
throw new Error(
`Extension "${newExtensionName}" is already installed. Please uninstall it first.`,
);
}
await copyExtension(localSourcePath, destinationPath);
const metadataString = JSON.stringify(installMetadata, null, 2);
const metadataPath = path.join(destinationPath, INSTALL_METADATA_FILENAME);
await fs.promises.writeFile(metadataPath, metadataString);
} finally {
if (tempDir) {
await fs.promises.rm(tempDir, { recursive: true, force: true });
}
}
return newExtensionName;
}
export async function uninstallExtension(
extensionName: string,
cwd: string = process.cwd(),
): Promise<void> {
const installedExtensions = loadUserExtensions();
if (
!installedExtensions.some(
(installed) => installed.config.name === extensionName,
)
) {
throw new Error(`Extension "${extensionName}" not found.`);
}
removeFromDisabledExtensions(
extensionName,
[SettingScope.User, SettingScope.Workspace],
cwd,
);
const storage = new ExtensionStorage(extensionName);
return await fs.promises.rm(storage.getExtensionDir(), {
recursive: true,
force: true,
});
}
export function toOutputString(extension: Extension): string {
let output = `${extension.config.name} (${extension.config.version})`;
output += `\n Path: ${extension.path}`;
if (extension.installMetadata) {
output += `\n Source: ${extension.installMetadata.source}`;
}
if (extension.contextFiles.length > 0) {
output += `\n Context files:`;
extension.contextFiles.forEach((contextFile) => {
output += `\n ${contextFile}`;
});
}
if (extension.config.mcpServers) {
output += `\n MCP servers:`;
Object.keys(extension.config.mcpServers).forEach((key) => {
output += `\n ${key}`;
});
}
if (extension.config.excludeTools) {
output += `\n Excluded tools:`;
extension.config.excludeTools.forEach((tool) => {
output += `\n ${tool}`;
});
}
return output;
}
export async function updateExtension(
extensionName: string,
cwd: string = process.cwd(),
): Promise<ExtensionUpdateInfo | undefined> {
const installedExtensions = loadUserExtensions();
const extension = installedExtensions.find(
(installed) => installed.config.name === extensionName,
);
if (!extension) {
throw new Error(
`Extension "${extensionName}" not found. Run gemini extensions list to see available extensions.`,
);
}
if (!extension.installMetadata) {
throw new Error(
`Extension cannot be updated because it is missing the .gemini-extension.install.json file. To update manually, uninstall and then reinstall the updated version.`,
);
}
const originalVersion = extension.config.version;
const tempDir = await ExtensionStorage.createTmpDir();
try {
await copyExtension(extension.path, tempDir);
await uninstallExtension(extensionName, cwd);
await installExtension(extension.installMetadata, cwd);
const updatedExtension = loadExtension(extension.path);
if (!updatedExtension) {
throw new Error('Updated extension not found after installation.');
}
const updatedVersion = updatedExtension.config.version;
return {
originalVersion,
updatedVersion,
};
} catch (e) {
console.error(
`Error updating extension, rolling back. ${getErrorMessage(e)}`,
);
await copyExtension(tempDir, extension.path);
throw e;
} finally {
await fs.promises.rm(tempDir, { recursive: true, force: true });
}
}
export function disableExtension(
name: string,
scope: SettingScope,
cwd: string = process.cwd(),
) {
if (scope === SettingScope.System || scope === SettingScope.SystemDefaults) {
throw new Error('System and SystemDefaults scopes are not supported.');
}
const settings = loadSettings(cwd);
const settingsFile = settings.forScope(scope);
const extensionSettings = settingsFile.settings.extensions || {
disabled: [],
};
const disabledExtensions = extensionSettings.disabled || [];
if (!disabledExtensions.includes(name)) {
disabledExtensions.push(name);
extensionSettings.disabled = disabledExtensions;
settings.setValue(scope, 'extensions', extensionSettings);
}
}
export function enableExtension(name: string, scopes: SettingScope[]) {
removeFromDisabledExtensions(name, scopes);
}
/**
* Removes an extension from the list of disabled extensions.
* @param name The name of the extension to remove.
* @param scope The scopes to remove the name from.
*/
function removeFromDisabledExtensions(
name: string,
scopes: SettingScope[],
cwd: string = process.cwd(),
) {
const settings = loadSettings(cwd);
for (const scope of scopes) {
const settingsFile = settings.forScope(scope);
const extensionSettings = settingsFile.settings.extensions || {
disabled: [],
};
const disabledExtensions = extensionSettings.disabled || [];
extensionSettings.disabled = disabledExtensions.filter(
(extension) => extension !== name,
);
settings.setValue(scope, 'extensions', extensionSettings);
}
}

View File

@@ -0,0 +1,30 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
export interface VariableDefinition {
type: 'string';
description: string;
default?: string;
required?: boolean;
}
export interface VariableSchema {
[key: string]: VariableDefinition;
}
const PATH_SEPARATOR_DEFINITION = {
type: 'string',
description: 'The path separator.',
} as const;
export const VARIABLE_SCHEMA = {
extensionPath: {
type: 'string',
description: 'The path of the extension in the filesystem.',
},
'/': PATH_SEPARATOR_DEFINITION,
pathSeparator: PATH_SEPARATOR_DEFINITION,
} as const;

View File

@@ -0,0 +1,18 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { expect, describe, it } from 'vitest';
import { hydrateString } from './variables.js';
describe('hydrateString', () => {
it('should replace a single variable', () => {
const context = {
extensionPath: 'path/my-extension',
};
const result = hydrateString('Hello, ${extensionPath}!', context);
expect(result).toBe('Hello, path/my-extension!');
});
});

View File

@@ -0,0 +1,65 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { type VariableSchema, VARIABLE_SCHEMA } from './variableSchema.js';
export type JsonObject = { [key: string]: JsonValue };
export type JsonArray = JsonValue[];
export type JsonValue =
| string
| number
| boolean
| null
| JsonObject
| JsonArray;
export type VariableContext = {
[key in keyof typeof VARIABLE_SCHEMA]?: string;
};
export function validateVariables(
variables: VariableContext,
schema: VariableSchema,
) {
for (const key in schema) {
const definition = schema[key];
if (definition.required && !variables[key as keyof VariableContext]) {
throw new Error(`Missing required variable: ${key}`);
}
}
}
export function hydrateString(str: string, context: VariableContext): string {
validateVariables(context, VARIABLE_SCHEMA);
const regex = /\${(.*?)}/g;
return str.replace(regex, (match, key) =>
context[key as keyof VariableContext] == null
? match
: (context[key as keyof VariableContext] as string),
);
}
export function recursivelyHydrateStrings(
obj: JsonValue,
values: VariableContext,
): JsonValue {
if (typeof obj === 'string') {
return hydrateString(obj, values);
}
if (Array.isArray(obj)) {
return obj.map((item) => recursivelyHydrateStrings(item, values));
}
if (typeof obj === 'object' && obj !== null) {
const newObj: JsonObject = {};
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
newObj[key] = recursivelyHydrateStrings(obj[key], values);
}
}
return newObj;
}
return obj;
}

View File

@@ -5,11 +5,8 @@
*/
import { describe, it, expect } from 'vitest';
import {
Command,
KeyBindingConfig,
defaultKeyBindings,
} from './keyBindings.js';
import type { KeyBindingConfig } from './keyBindings.js';
import { Command, defaultKeyBindings } from './keyBindings.js';
describe('keyBindings config', () => {
describe('defaultKeyBindings', () => {

View File

@@ -4,11 +4,12 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { SandboxConfig } from '@qwen-code/qwen-code-core';
import type { SandboxConfig } from '@qwen-code/qwen-code-core';
import { FatalSandboxError } from '@qwen-code/qwen-code-core';
import commandExists from 'command-exists';
import * as os from 'node:os';
import { getPackageJson } from '../utils/package.js';
import { Settings } from './settings.js';
import type { Settings } from './settings.js';
// This is a stripped-down version of the CliArgs interface from config.ts
// to avoid circular dependencies.
@@ -51,21 +52,19 @@ function getSandboxCommand(
if (typeof sandbox === 'string' && sandbox) {
if (!isSandboxCommand(sandbox)) {
console.error(
`ERROR: invalid sandbox command '${sandbox}'. Must be one of ${VALID_SANDBOX_COMMANDS.join(
throw new FatalSandboxError(
`Invalid sandbox command '${sandbox}'. Must be one of ${VALID_SANDBOX_COMMANDS.join(
', ',
)}`,
);
process.exit(1);
}
// confirm that specified command exists
if (commandExists.sync(sandbox)) {
return sandbox;
}
console.error(
`ERROR: missing sandbox command '${sandbox}' (from GEMINI_SANDBOX)`,
throw new FatalSandboxError(
`Missing sandbox command '${sandbox}' (from GEMINI_SANDBOX)`,
);
process.exit(1);
}
// look for seatbelt, docker, or podman, in that order
@@ -80,11 +79,10 @@ function getSandboxCommand(
// throw an error if user requested sandbox but no command was found
if (sandbox === true) {
console.error(
'ERROR: GEMINI_SANDBOX is true but failed to determine command for sandbox; ' +
throw new FatalSandboxError(
'GEMINI_SANDBOX is true but failed to determine command for sandbox; ' +
'install docker or podman or specify command in GEMINI_SANDBOX',
);
process.exit(1);
}
return '';
@@ -94,7 +92,7 @@ export async function loadSandboxConfig(
settings: Settings,
argv: SandboxCliArgs,
): Promise<SandboxConfig | undefined> {
const sandboxOption = argv.sandbox ?? settings.sandbox;
const sandboxOption = argv.sandbox ?? settings.tools?.sandbox;
const command = getSandboxCommand(sandboxOption);
const packageJson = await getPackageJson();

File diff suppressed because it is too large Load Diff

View File

@@ -4,29 +4,84 @@
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'fs';
import * as path from 'path';
import { homedir, platform } from 'os';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { homedir, platform } from 'node:os';
import * as dotenv from 'dotenv';
import {
GEMINI_CONFIG_DIR as GEMINI_DIR,
getErrorMessage,
Storage,
} from '@qwen-code/qwen-code-core';
import stripJsonComments from 'strip-json-comments';
import { DefaultLight } from '../ui/themes/default-light.js';
import { DefaultDark } from '../ui/themes/default.js';
import { Settings, MemoryImportFormat } from './settingsSchema.js';
import { isWorkspaceTrusted } from './trustedFolders.js';
import type { Settings, MemoryImportFormat } from './settingsSchema.js';
import { mergeWith } from 'lodash-es';
export type { Settings, MemoryImportFormat };
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');
export const USER_SETTINGS_PATH = Storage.getGlobalSettingsPath();
export const USER_SETTINGS_DIR = path.dirname(USER_SETTINGS_PATH);
export const DEFAULT_EXCLUDED_ENV_VARS = ['DEBUG', 'DEBUG_MODE'];
const MIGRATE_V2_OVERWRITE = false;
const MIGRATION_MAP: Record<string, string> = {
preferredEditor: 'general.preferredEditor',
vimMode: 'general.vimMode',
disableAutoUpdate: 'general.disableAutoUpdate',
disableUpdateNag: 'general.disableUpdateNag',
checkpointing: 'general.checkpointing',
theme: 'ui.theme',
customThemes: 'ui.customThemes',
hideWindowTitle: 'ui.hideWindowTitle',
hideTips: 'ui.hideTips',
hideBanner: 'ui.hideBanner',
hideFooter: 'ui.hideFooter',
showMemoryUsage: 'ui.showMemoryUsage',
showLineNumbers: 'ui.showLineNumbers',
accessibility: 'ui.accessibility',
ideMode: 'ide.enabled',
hasSeenIdeIntegrationNudge: 'ide.hasSeenNudge',
usageStatisticsEnabled: 'privacy.usageStatisticsEnabled',
telemetry: 'telemetry',
model: 'model.name',
maxSessionTurns: 'model.maxSessionTurns',
summarizeToolOutput: 'model.summarizeToolOutput',
chatCompression: 'model.chatCompression',
skipNextSpeakerCheck: 'model.skipNextSpeakerCheck',
contextFileName: 'context.fileName',
memoryImportFormat: 'context.importFormat',
memoryDiscoveryMaxDirs: 'context.discoveryMaxDirs',
includeDirectories: 'context.includeDirectories',
loadMemoryFromIncludeDirectories: 'context.loadFromIncludeDirectories',
fileFiltering: 'context.fileFiltering',
sandbox: 'tools.sandbox',
shouldUseNodePtyShell: 'tools.usePty',
allowedTools: 'tools.allowed',
coreTools: 'tools.core',
excludeTools: 'tools.exclude',
toolDiscoveryCommand: 'tools.discoveryCommand',
toolCallCommand: 'tools.callCommand',
mcpServerCommand: 'mcp.serverCommand',
allowMCPServers: 'mcp.allowed',
excludeMCPServers: 'mcp.excluded',
folderTrustFeature: 'security.folderTrust.featureEnabled',
folderTrust: 'security.folderTrust.enabled',
selectedAuthType: 'security.auth.selectedType',
useExternalAuth: 'security.auth.useExternal',
autoConfigureMaxOldSpaceSize: 'advanced.autoConfigureMemory',
dnsResolutionOrder: 'advanced.dnsResolutionOrder',
excludedProjectEnvVars: 'advanced.excludedEnvVars',
bugCommand: 'advanced.bugCommand',
};
export function getSystemSettingsPath(): string {
if (process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH']) {
return process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH'];
if (process.env['QWEN_CODE_SYSTEM_SETTINGS_PATH']) {
return process.env['QWEN_CODE_SYSTEM_SETTINGS_PATH'];
}
if (platform() === 'darwin') {
return '/Library/Application Support/QwenCode/settings.json';
@@ -37,8 +92,14 @@ export function getSystemSettingsPath(): string {
}
}
export function getWorkspaceSettingsPath(workspaceDir: string): string {
return path.join(workspaceDir, SETTINGS_DIRECTORY_NAME, 'settings.json');
export function getSystemDefaultsPath(): string {
if (process.env['QWEN_CODE_SYSTEM_DEFAULTS_PATH']) {
return process.env['QWEN_CODE_SYSTEM_DEFAULTS_PATH'];
}
return path.join(
path.dirname(getSystemSettingsPath()),
'system-defaults.json',
);
}
export type { DnsResolutionOrder } from './settingsSchema.js';
@@ -47,6 +108,7 @@ export enum SettingScope {
User = 'User',
Workspace = 'Workspace',
System = 'System',
SystemDefaults = 'SystemDefaults',
}
export interface CheckpointingSettings {
@@ -59,6 +121,7 @@ export interface SummarizeToolOutputSettings {
export interface AccessibilitySettings {
disableLoadingPhrases?: boolean;
screenReader?: boolean;
}
export interface SettingsError {
@@ -71,38 +134,290 @@ export interface SettingsFile {
path: string;
}
function setNestedProperty(
obj: Record<string, unknown>,
path: string,
value: unknown,
) {
const keys = path.split('.');
const lastKey = keys.pop();
if (!lastKey) return;
let current: Record<string, unknown> = obj;
for (const key of keys) {
if (current[key] === undefined) {
current[key] = {};
}
const next = current[key];
if (typeof next === 'object' && next !== null) {
current = next as Record<string, unknown>;
} else {
// This path is invalid, so we stop.
return;
}
}
current[lastKey] = value;
}
function needsMigration(settings: Record<string, unknown>): boolean {
return !('general' in settings);
}
function migrateSettingsToV2(
flatSettings: Record<string, unknown>,
): Record<string, unknown> | null {
if (!needsMigration(flatSettings)) {
return null;
}
const v2Settings: Record<string, unknown> = {};
const flatKeys = new Set(Object.keys(flatSettings));
for (const [oldKey, newPath] of Object.entries(MIGRATION_MAP)) {
if (flatKeys.has(oldKey)) {
setNestedProperty(v2Settings, newPath, flatSettings[oldKey]);
flatKeys.delete(oldKey);
}
}
// Preserve mcpServers at the top level
if (flatSettings['mcpServers']) {
v2Settings['mcpServers'] = flatSettings['mcpServers'];
flatKeys.delete('mcpServers');
}
// Carry over any unrecognized keys
for (const remainingKey of flatKeys) {
v2Settings[remainingKey] = flatSettings[remainingKey];
}
return v2Settings;
}
function getNestedProperty(
obj: Record<string, unknown>,
path: string,
): unknown {
const keys = path.split('.');
let current: unknown = obj;
for (const key of keys) {
if (typeof current !== 'object' || current === null || !(key in current)) {
return undefined;
}
current = (current as Record<string, unknown>)[key];
}
return current;
}
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]),
);
export function migrateSettingsToV1(
v2Settings: Record<string, unknown>,
): Record<string, unknown> {
const v1Settings: Record<string, unknown> = {};
const v2Keys = new Set(Object.keys(v2Settings));
for (const [newPath, oldKey] of Object.entries(REVERSE_MIGRATION_MAP)) {
const value = getNestedProperty(v2Settings, newPath);
if (value !== undefined) {
v1Settings[oldKey] = value;
v2Keys.delete(newPath.split('.')[0]);
}
}
// Preserve mcpServers at the top level
if (v2Settings['mcpServers']) {
v1Settings['mcpServers'] = v2Settings['mcpServers'];
v2Keys.delete('mcpServers');
}
// Carry over any unrecognized keys
for (const remainingKey of v2Keys) {
const value = v2Settings[remainingKey];
if (value === undefined) {
continue;
}
// Don't carry over empty objects that were just containers for migrated settings.
if (
KNOWN_V2_CONTAINERS.has(remainingKey) &&
typeof value === 'object' &&
value !== null &&
!Array.isArray(value) &&
Object.keys(value).length === 0
) {
continue;
}
v1Settings[remainingKey] = value;
}
return v1Settings;
}
function mergeSettings(
system: Settings,
systemDefaults: Settings,
user: Settings,
workspace: Settings,
isTrusted: boolean,
): Settings {
// folderTrust is not supported at workspace level.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { folderTrust, ...workspaceWithoutFolderTrust } = workspace;
const safeWorkspace = isTrusted ? workspace : ({} as Settings);
// folderTrust is not supported at workspace level.
const { security, ...restOfWorkspace } = safeWorkspace;
const safeWorkspaceWithoutFolderTrust = security
? {
...restOfWorkspace,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
security: (({ folderTrust, ...rest }) => rest)(security),
}
: {
...restOfWorkspace,
security: {},
};
// Settings are merged with the following precedence (last one wins for
// single values):
// 1. System Defaults
// 2. User Settings
// 3. Workspace Settings
// 4. System Settings (as overrides)
//
// For properties that are arrays (e.g., includeDirectories), the arrays
// are concatenated. For objects (e.g., customThemes), they are merged.
return {
...systemDefaults,
...user,
...workspaceWithoutFolderTrust,
...safeWorkspaceWithoutFolderTrust,
...system,
customThemes: {
...(user.customThemes || {}),
...(workspace.customThemes || {}),
...(system.customThemes || {}),
general: {
...(systemDefaults.general || {}),
...(user.general || {}),
...(safeWorkspaceWithoutFolderTrust.general || {}),
...(system.general || {}),
},
ui: {
...(systemDefaults.ui || {}),
...(user.ui || {}),
...(safeWorkspaceWithoutFolderTrust.ui || {}),
...(system.ui || {}),
customThemes: {
...(systemDefaults.ui?.customThemes || {}),
...(user.ui?.customThemes || {}),
...(safeWorkspaceWithoutFolderTrust.ui?.customThemes || {}),
...(system.ui?.customThemes || {}),
},
},
ide: {
...(systemDefaults.ide || {}),
...(user.ide || {}),
...(safeWorkspaceWithoutFolderTrust.ide || {}),
...(system.ide || {}),
},
privacy: {
...(systemDefaults.privacy || {}),
...(user.privacy || {}),
...(safeWorkspaceWithoutFolderTrust.privacy || {}),
...(system.privacy || {}),
},
telemetry: {
...(systemDefaults.telemetry || {}),
...(user.telemetry || {}),
...(safeWorkspaceWithoutFolderTrust.telemetry || {}),
...(system.telemetry || {}),
},
security: {
...(systemDefaults.security || {}),
...(user.security || {}),
...(safeWorkspaceWithoutFolderTrust.security || {}),
...(system.security || {}),
},
mcp: {
...(systemDefaults.mcp || {}),
...(user.mcp || {}),
...(safeWorkspaceWithoutFolderTrust.mcp || {}),
...(system.mcp || {}),
},
mcpServers: {
...(systemDefaults.mcpServers || {}),
...(user.mcpServers || {}),
...(workspace.mcpServers || {}),
...(safeWorkspaceWithoutFolderTrust.mcpServers || {}),
...(system.mcpServers || {}),
},
includeDirectories: [
...(system.includeDirectories || []),
...(user.includeDirectories || []),
...(workspace.includeDirectories || []),
],
chatCompression: {
...(system.chatCompression || {}),
...(user.chatCompression || {}),
...(workspace.chatCompression || {}),
tools: {
...(systemDefaults.tools || {}),
...(user.tools || {}),
...(safeWorkspaceWithoutFolderTrust.tools || {}),
...(system.tools || {}),
},
context: {
...(systemDefaults.context || {}),
...(user.context || {}),
...(safeWorkspaceWithoutFolderTrust.context || {}),
...(system.context || {}),
includeDirectories: [
...(systemDefaults.context?.includeDirectories || []),
...(user.context?.includeDirectories || []),
...(safeWorkspaceWithoutFolderTrust.context?.includeDirectories || []),
...(system.context?.includeDirectories || []),
],
},
model: {
...(systemDefaults.model || {}),
...(user.model || {}),
...(safeWorkspaceWithoutFolderTrust.model || {}),
...(system.model || {}),
chatCompression: {
...(systemDefaults.model?.chatCompression || {}),
...(user.model?.chatCompression || {}),
...(safeWorkspaceWithoutFolderTrust.model?.chatCompression || {}),
...(system.model?.chatCompression || {}),
},
},
advanced: {
...(systemDefaults.advanced || {}),
...(user.advanced || {}),
...(safeWorkspaceWithoutFolderTrust.advanced || {}),
...(system.advanced || {}),
excludedEnvVars: [
...new Set([
...(systemDefaults.advanced?.excludedEnvVars || []),
...(user.advanced?.excludedEnvVars || []),
...(safeWorkspaceWithoutFolderTrust.advanced?.excludedEnvVars || []),
...(system.advanced?.excludedEnvVars || []),
]),
],
},
extensions: {
...(systemDefaults.extensions || {}),
...(user.extensions || {}),
...(safeWorkspaceWithoutFolderTrust.extensions || {}),
...(system.extensions || {}),
disabled: [
...new Set([
...(systemDefaults.extensions?.disabled || []),
...(user.extensions?.disabled || []),
...(safeWorkspaceWithoutFolderTrust.extensions?.disabled || []),
...(system.extensions?.disabled || []),
]),
],
workspacesWithMigrationNudge: [
...new Set([
...(systemDefaults.extensions?.workspacesWithMigrationNudge || []),
...(user.extensions?.workspacesWithMigrationNudge || []),
...(safeWorkspaceWithoutFolderTrust.extensions
?.workspacesWithMigrationNudge || []),
...(system.extensions?.workspacesWithMigrationNudge || []),
]),
],
},
};
}
@@ -110,21 +425,30 @@ function mergeSettings(
export class LoadedSettings {
constructor(
system: SettingsFile,
systemDefaults: SettingsFile,
user: SettingsFile,
workspace: SettingsFile,
errors: SettingsError[],
isTrusted: boolean,
migratedInMemorScopes: Set<SettingScope>,
) {
this.system = system;
this.systemDefaults = systemDefaults;
this.user = user;
this.workspace = workspace;
this.errors = errors;
this.isTrusted = isTrusted;
this.migratedInMemorScopes = migratedInMemorScopes;
this._merged = this.computeMergedSettings();
}
readonly system: SettingsFile;
readonly systemDefaults: SettingsFile;
readonly user: SettingsFile;
readonly workspace: SettingsFile;
readonly errors: SettingsError[];
readonly isTrusted: boolean;
readonly migratedInMemorScopes: Set<SettingScope>;
private _merged: Settings;
@@ -135,8 +459,10 @@ export class LoadedSettings {
private computeMergedSettings(): Settings {
return mergeSettings(
this.system.settings,
this.systemDefaults.settings,
this.user.settings,
this.workspace.settings,
this.isTrusted,
);
}
@@ -148,18 +474,16 @@ export class LoadedSettings {
return this.workspace;
case SettingScope.System:
return this.system;
case SettingScope.SystemDefaults:
return this.systemDefaults;
default:
throw new Error(`Invalid scope: ${scope}`);
}
}
setValue<K extends keyof Settings>(
scope: SettingScope,
key: K,
value: Settings[K],
): void {
setValue(scope: SettingScope, key: string, value: unknown): void {
const settingsFile = this.forScope(scope);
settingsFile.settings[key] = value;
setNestedProperty(settingsFile.settings, key, value);
this._merged = this.computeMergedSettings();
saveSettings(settingsFile);
}
@@ -269,7 +593,9 @@ export function loadEnvironment(settings?: Settings): void {
// If no settings provided, try to load workspace settings for exclusions
let resolvedSettings = settings;
if (!resolvedSettings) {
const workspaceSettingsPath = getWorkspaceSettingsPath(process.cwd());
const workspaceSettingsPath = new Storage(
process.cwd(),
).getWorkspaceSettingsPath();
try {
if (fs.existsSync(workspaceSettingsPath)) {
const workspaceContent = fs.readFileSync(
@@ -294,7 +620,8 @@ export function loadEnvironment(settings?: Settings): void {
const parsedEnv = dotenv.parse(envFileContent);
const excludedVars =
resolvedSettings?.excludedProjectEnvVars || DEFAULT_EXCLUDED_ENV_VARS;
resolvedSettings?.advanced?.excludedEnvVars ||
DEFAULT_EXCLUDED_ENV_VARS;
const isProjectEnvFile = !envFilePath.includes(GEMINI_DIR);
for (const key in parsedEnv) {
@@ -322,10 +649,13 @@ export function loadEnvironment(settings?: Settings): void {
*/
export function loadSettings(workspaceDir: string): LoadedSettings {
let systemSettings: Settings = {};
let systemDefaultSettings: Settings = {};
let userSettings: Settings = {};
let workspaceSettings: Settings = {};
const settingsErrors: SettingsError[] = [];
const systemSettingsPath = getSystemSettingsPath();
const systemDefaultsPath = getSystemDefaultsPath();
const migratedInMemorScopes = new Set<SettingScope>();
// Resolve paths to their canonical representation to handle symlinks
const resolvedWorkspaceDir = path.resolve(workspaceDir);
@@ -342,70 +672,102 @@ export function loadSettings(workspaceDir: string): LoadedSettings {
// We expect homedir to always exist and be resolvable.
const realHomeDir = fs.realpathSync(resolvedHomeDir);
const workspaceSettingsPath = getWorkspaceSettingsPath(workspaceDir);
const workspaceSettingsPath = new Storage(
workspaceDir,
).getWorkspaceSettingsPath();
// Load system settings
try {
if (fs.existsSync(systemSettingsPath)) {
const systemContent = fs.readFileSync(systemSettingsPath, 'utf-8');
systemSettings = JSON.parse(stripJsonComments(systemContent)) as Settings;
}
} catch (error: unknown) {
settingsErrors.push({
message: getErrorMessage(error),
path: systemSettingsPath,
});
}
// Load user settings
try {
if (fs.existsSync(USER_SETTINGS_PATH)) {
const userContent = fs.readFileSync(USER_SETTINGS_PATH, 'utf-8');
userSettings = JSON.parse(stripJsonComments(userContent)) as Settings;
// Support legacy theme names
if (userSettings.theme && userSettings.theme === 'VS') {
userSettings.theme = DefaultLight.name;
} else if (userSettings.theme && userSettings.theme === 'VS2015') {
userSettings.theme = DefaultDark.name;
}
}
} catch (error: unknown) {
settingsErrors.push({
message: getErrorMessage(error),
path: USER_SETTINGS_PATH,
});
}
if (realWorkspaceDir !== realHomeDir) {
// Load workspace settings
const loadAndMigrate = (filePath: string, scope: SettingScope): Settings => {
try {
if (fs.existsSync(workspaceSettingsPath)) {
const projectContent = fs.readFileSync(workspaceSettingsPath, 'utf-8');
workspaceSettings = JSON.parse(
stripJsonComments(projectContent),
) as Settings;
if (workspaceSettings.theme && workspaceSettings.theme === 'VS') {
workspaceSettings.theme = DefaultLight.name;
} else if (
workspaceSettings.theme &&
workspaceSettings.theme === 'VS2015'
if (fs.existsSync(filePath)) {
const content = fs.readFileSync(filePath, 'utf-8');
const rawSettings: unknown = JSON.parse(stripJsonComments(content));
if (
typeof rawSettings !== 'object' ||
rawSettings === null ||
Array.isArray(rawSettings)
) {
workspaceSettings.theme = DefaultDark.name;
settingsErrors.push({
message: 'Settings file is not a valid JSON object.',
path: filePath,
});
return {};
}
let settingsObject = rawSettings as Record<string, unknown>;
if (needsMigration(settingsObject)) {
const migratedSettings = migrateSettingsToV2(settingsObject);
if (migratedSettings) {
if (MIGRATE_V2_OVERWRITE) {
try {
fs.renameSync(filePath, `${filePath}.orig`);
fs.writeFileSync(
filePath,
JSON.stringify(migratedSettings, null, 2),
'utf-8',
);
} catch (e) {
console.error(
`Error migrating settings file on disk: ${getErrorMessage(
e,
)}`,
);
}
} else {
migratedInMemorScopes.add(scope);
}
settingsObject = migratedSettings;
}
}
return settingsObject as Settings;
}
} catch (error: unknown) {
settingsErrors.push({
message: getErrorMessage(error),
path: workspaceSettingsPath,
path: filePath,
});
}
return {};
};
systemSettings = loadAndMigrate(systemSettingsPath, SettingScope.System);
systemDefaultSettings = loadAndMigrate(
systemDefaultsPath,
SettingScope.SystemDefaults,
);
userSettings = loadAndMigrate(USER_SETTINGS_PATH, SettingScope.User);
if (realWorkspaceDir !== realHomeDir) {
workspaceSettings = loadAndMigrate(
workspaceSettingsPath,
SettingScope.Workspace,
);
}
// Support legacy theme names
if (userSettings.ui?.theme === 'VS') {
userSettings.ui.theme = DefaultLight.name;
} else if (userSettings.ui?.theme === 'VS2015') {
userSettings.ui.theme = DefaultDark.name;
}
if (workspaceSettings.ui?.theme === 'VS') {
workspaceSettings.ui.theme = DefaultLight.name;
} else if (workspaceSettings.ui?.theme === 'VS2015') {
workspaceSettings.ui.theme = DefaultDark.name;
}
// For the initial trust check, we can only use user and system settings.
const initialTrustCheckSettings = mergeWith({}, systemSettings, userSettings);
const isTrusted =
isWorkspaceTrusted(initialTrustCheckSettings as Settings) ?? true;
// Create a temporary merged settings object to pass to loadEnvironment.
const tempMergedSettings = mergeSettings(
systemSettings,
systemDefaultSettings,
userSettings,
workspaceSettings,
isTrusted,
);
// loadEnviroment depends on settings so we have to create a temp version of
@@ -423,6 +785,10 @@ export function loadSettings(workspaceDir: string): LoadedSettings {
path: systemSettingsPath,
settings: systemSettings,
},
{
path: systemDefaultsPath,
settings: systemDefaultSettings,
},
{
path: USER_SETTINGS_PATH,
settings: userSettings,
@@ -432,21 +798,10 @@ export function loadSettings(workspaceDir: string): LoadedSettings {
settings: workspaceSettings,
},
settingsErrors,
isTrusted,
migratedInMemorScopes,
);
// Validate chatCompression settings
const chatCompression = loadedSettings.merged.chatCompression;
const threshold = chatCompression?.contextPercentageThreshold;
if (
threshold != null &&
(typeof threshold !== 'number' || threshold < 0 || threshold > 1)
) {
console.warn(
`Invalid value for chatCompression.contextPercentageThreshold: "${threshold}". Please use a value between 0 and 1. Using default compression settings.`,
);
delete loadedSettings.merged.chatCompression;
}
return loadedSettings;
}
@@ -458,9 +813,16 @@ export function saveSettings(settingsFile: SettingsFile): void {
fs.mkdirSync(dirPath, { recursive: true });
}
let settingsToSave = settingsFile.settings;
if (!MIGRATE_V2_OVERWRITE) {
settingsToSave = migrateSettingsToV1(
settingsToSave as Record<string, unknown>,
) as Settings;
}
fs.writeFileSync(
settingsFile.path,
JSON.stringify(settingsFile.settings, null, 2),
JSON.stringify(settingsToSave, null, 2),
'utf-8',
);
} catch (error) {

View File

@@ -5,53 +5,25 @@
*/
import { describe, it, expect } from 'vitest';
import { SETTINGS_SCHEMA, Settings } from './settingsSchema.js';
import type { Settings } from './settingsSchema.js';
import { SETTINGS_SCHEMA } from './settingsSchema.js';
describe('SettingsSchema', () => {
describe('SETTINGS_SCHEMA', () => {
it('should contain all expected top-level settings', () => {
const expectedSettings = [
'theme',
'customThemes',
'showMemoryUsage',
'usageStatisticsEnabled',
'autoConfigureMaxOldSpaceSize',
'preferredEditor',
'maxSessionTurns',
'memoryImportFormat',
'memoryDiscoveryMaxDirs',
'contextFileName',
'vimMode',
'ideMode',
'accessibility',
'checkpointing',
'fileFiltering',
'disableAutoUpdate',
'hideWindowTitle',
'hideTips',
'hideBanner',
'selectedAuthType',
'useExternalAuth',
'sandbox',
'coreTools',
'excludeTools',
'toolDiscoveryCommand',
'toolCallCommand',
'mcpServerCommand',
'mcpServers',
'allowMCPServers',
'excludeMCPServers',
'general',
'ui',
'ide',
'privacy',
'telemetry',
'bugCommand',
'summarizeToolOutput',
'dnsResolutionOrder',
'excludedProjectEnvVars',
'disableUpdateNag',
'includeDirectories',
'loadMemoryFromIncludeDirectories',
'model',
'hasSeenIdeIntegrationNudge',
'folderTrustFeature',
'context',
'tools',
'mcp',
'security',
'advanced',
];
expectedSettings.forEach((setting) => {
@@ -77,9 +49,16 @@ describe('SettingsSchema', () => {
it('should have correct nested setting structure', () => {
const nestedSettings = [
'accessibility',
'checkpointing',
'fileFiltering',
'general',
'ui',
'ide',
'privacy',
'model',
'context',
'tools',
'mcp',
'security',
'advanced',
];
nestedSettings.forEach((setting) => {
@@ -96,29 +75,36 @@ describe('SettingsSchema', () => {
it('should have accessibility nested properties', () => {
expect(
SETTINGS_SCHEMA.accessibility.properties?.disableLoadingPhrases,
SETTINGS_SCHEMA.ui?.properties?.accessibility?.properties,
).toBeDefined();
expect(
SETTINGS_SCHEMA.accessibility.properties?.disableLoadingPhrases.type,
SETTINGS_SCHEMA.ui?.properties?.accessibility.properties
?.disableLoadingPhrases.type,
).toBe('boolean');
});
it('should have checkpointing nested properties', () => {
expect(SETTINGS_SCHEMA.checkpointing.properties?.enabled).toBeDefined();
expect(SETTINGS_SCHEMA.checkpointing.properties?.enabled.type).toBe(
'boolean',
);
expect(
SETTINGS_SCHEMA.general?.properties?.checkpointing.properties?.enabled,
).toBeDefined();
expect(
SETTINGS_SCHEMA.general?.properties?.checkpointing.properties?.enabled
.type,
).toBe('boolean');
});
it('should have fileFiltering nested properties', () => {
expect(
SETTINGS_SCHEMA.fileFiltering.properties?.respectGitIgnore,
SETTINGS_SCHEMA.context.properties.fileFiltering.properties
?.respectGitIgnore,
).toBeDefined();
expect(
SETTINGS_SCHEMA.fileFiltering.properties?.respectGeminiIgnore,
SETTINGS_SCHEMA.context.properties.fileFiltering.properties
?.respectGeminiIgnore,
).toBeDefined();
expect(
SETTINGS_SCHEMA.fileFiltering.properties?.enableRecursiveFileSearch,
SETTINGS_SCHEMA.context.properties.fileFiltering.properties
?.enableRecursiveFileSearch,
).toBeDefined();
});
@@ -147,11 +133,6 @@ describe('SettingsSchema', () => {
expect(categories.size).toBeGreaterThan(0);
expect(categories).toContain('General');
expect(categories).toContain('UI');
expect(categories).toContain('Mode');
expect(categories).toContain('Updates');
expect(categories).toContain('Accessibility');
expect(categories).toContain('Checkpointing');
expect(categories).toContain('File Filtering');
expect(categories).toContain('Advanced');
});
@@ -180,73 +161,148 @@ describe('SettingsSchema', () => {
it('should have showInDialog property configured', () => {
// Check that user-facing settings are marked for dialog display
expect(SETTINGS_SCHEMA.showMemoryUsage.showInDialog).toBe(true);
expect(SETTINGS_SCHEMA.vimMode.showInDialog).toBe(true);
expect(SETTINGS_SCHEMA.ideMode.showInDialog).toBe(true);
expect(SETTINGS_SCHEMA.disableAutoUpdate.showInDialog).toBe(true);
expect(SETTINGS_SCHEMA.hideWindowTitle.showInDialog).toBe(true);
expect(SETTINGS_SCHEMA.hideTips.showInDialog).toBe(true);
expect(SETTINGS_SCHEMA.hideBanner.showInDialog).toBe(true);
expect(SETTINGS_SCHEMA.usageStatisticsEnabled.showInDialog).toBe(false);
expect(SETTINGS_SCHEMA.ui.properties.showMemoryUsage.showInDialog).toBe(
true,
);
expect(SETTINGS_SCHEMA.general.properties.vimMode.showInDialog).toBe(
true,
);
expect(SETTINGS_SCHEMA.ide.properties.enabled.showInDialog).toBe(true);
expect(
SETTINGS_SCHEMA.general.properties.disableAutoUpdate.showInDialog,
).toBe(true);
expect(SETTINGS_SCHEMA.ui.properties.hideWindowTitle.showInDialog).toBe(
true,
);
expect(SETTINGS_SCHEMA.ui.properties.hideTips.showInDialog).toBe(true);
expect(SETTINGS_SCHEMA.ui.properties.hideBanner.showInDialog).toBe(true);
expect(
SETTINGS_SCHEMA.privacy.properties.usageStatisticsEnabled.showInDialog,
).toBe(false);
// Check that advanced settings are hidden from dialog
expect(SETTINGS_SCHEMA.selectedAuthType.showInDialog).toBe(false);
expect(SETTINGS_SCHEMA.coreTools.showInDialog).toBe(false);
expect(SETTINGS_SCHEMA.security.properties.auth.showInDialog).toBe(false);
expect(SETTINGS_SCHEMA.tools.properties.core.showInDialog).toBe(false);
expect(SETTINGS_SCHEMA.mcpServers.showInDialog).toBe(false);
expect(SETTINGS_SCHEMA.telemetry.showInDialog).toBe(false);
// Check that some settings are appropriately hidden
expect(SETTINGS_SCHEMA.theme.showInDialog).toBe(false); // Changed to false
expect(SETTINGS_SCHEMA.customThemes.showInDialog).toBe(false); // Managed via theme editor
expect(SETTINGS_SCHEMA.checkpointing.showInDialog).toBe(false); // Experimental feature
expect(SETTINGS_SCHEMA.accessibility.showInDialog).toBe(false); // Changed to false
expect(SETTINGS_SCHEMA.fileFiltering.showInDialog).toBe(false); // Changed to false
expect(SETTINGS_SCHEMA.preferredEditor.showInDialog).toBe(false); // Changed to false
expect(SETTINGS_SCHEMA.autoConfigureMaxOldSpaceSize.showInDialog).toBe(
true,
);
expect(SETTINGS_SCHEMA.ui.properties.theme.showInDialog).toBe(false); // Changed to false
expect(SETTINGS_SCHEMA.ui.properties.customThemes.showInDialog).toBe(
false,
); // Managed via theme editor
expect(
SETTINGS_SCHEMA.general.properties.checkpointing.showInDialog,
).toBe(false); // Experimental feature
expect(SETTINGS_SCHEMA.ui.properties.accessibility.showInDialog).toBe(
false,
); // Changed to false
expect(
SETTINGS_SCHEMA.context.properties.fileFiltering.showInDialog,
).toBe(false); // Changed to false
expect(
SETTINGS_SCHEMA.general.properties.preferredEditor.showInDialog,
).toBe(false); // Changed to false
expect(
SETTINGS_SCHEMA.advanced.properties.autoConfigureMemory.showInDialog,
).toBe(false);
});
it('should infer Settings type correctly', () => {
// This test ensures that the Settings type is properly inferred from the schema
const settings: Settings = {
theme: 'dark',
includeDirectories: ['/path/to/dir'],
loadMemoryFromIncludeDirectories: true,
ui: {
theme: 'dark',
},
context: {
includeDirectories: ['/path/to/dir'],
loadMemoryFromIncludeDirectories: true,
},
};
// TypeScript should not complain about these properties
expect(settings.theme).toBe('dark');
expect(settings.includeDirectories).toEqual(['/path/to/dir']);
expect(settings.loadMemoryFromIncludeDirectories).toBe(true);
expect(settings.ui?.theme).toBe('dark');
expect(settings.context?.includeDirectories).toEqual(['/path/to/dir']);
expect(settings.context?.loadMemoryFromIncludeDirectories).toBe(true);
});
it('should have includeDirectories setting in schema', () => {
expect(SETTINGS_SCHEMA.includeDirectories).toBeDefined();
expect(SETTINGS_SCHEMA.includeDirectories.type).toBe('array');
expect(SETTINGS_SCHEMA.includeDirectories.category).toBe('General');
expect(SETTINGS_SCHEMA.includeDirectories.default).toEqual([]);
expect(
SETTINGS_SCHEMA.context?.properties.includeDirectories,
).toBeDefined();
expect(SETTINGS_SCHEMA.context?.properties.includeDirectories.type).toBe(
'array',
);
expect(
SETTINGS_SCHEMA.context?.properties.includeDirectories.category,
).toBe('Context');
expect(
SETTINGS_SCHEMA.context?.properties.includeDirectories.default,
).toEqual([]);
});
it('should have loadMemoryFromIncludeDirectories setting in schema', () => {
expect(SETTINGS_SCHEMA.loadMemoryFromIncludeDirectories).toBeDefined();
expect(SETTINGS_SCHEMA.loadMemoryFromIncludeDirectories.type).toBe(
'boolean',
);
expect(SETTINGS_SCHEMA.loadMemoryFromIncludeDirectories.category).toBe(
'General',
);
expect(SETTINGS_SCHEMA.loadMemoryFromIncludeDirectories.default).toBe(
false,
);
expect(
SETTINGS_SCHEMA.context?.properties.loadMemoryFromIncludeDirectories,
).toBeDefined();
expect(
SETTINGS_SCHEMA.context?.properties.loadMemoryFromIncludeDirectories
.type,
).toBe('boolean');
expect(
SETTINGS_SCHEMA.context?.properties.loadMemoryFromIncludeDirectories
.category,
).toBe('Context');
expect(
SETTINGS_SCHEMA.context?.properties.loadMemoryFromIncludeDirectories
.default,
).toBe(false);
});
it('should have folderTrustFeature setting in schema', () => {
expect(SETTINGS_SCHEMA.folderTrustFeature).toBeDefined();
expect(SETTINGS_SCHEMA.folderTrustFeature.type).toBe('boolean');
expect(SETTINGS_SCHEMA.folderTrustFeature.category).toBe('General');
expect(SETTINGS_SCHEMA.folderTrustFeature.default).toBe(false);
expect(SETTINGS_SCHEMA.folderTrustFeature.showInDialog).toBe(true);
expect(
SETTINGS_SCHEMA.security.properties.folderTrust.properties.enabled,
).toBeDefined();
expect(
SETTINGS_SCHEMA.security.properties.folderTrust.properties.enabled.type,
).toBe('boolean');
expect(
SETTINGS_SCHEMA.security.properties.folderTrust.properties.enabled
.category,
).toBe('Security');
expect(
SETTINGS_SCHEMA.security.properties.folderTrust.properties.enabled
.default,
).toBe(false);
expect(
SETTINGS_SCHEMA.security.properties.folderTrust.properties.enabled
.showInDialog,
).toBe(true);
});
it('should have debugKeystrokeLogging setting in schema', () => {
expect(
SETTINGS_SCHEMA.general.properties.debugKeystrokeLogging,
).toBeDefined();
expect(
SETTINGS_SCHEMA.general.properties.debugKeystrokeLogging.type,
).toBe('boolean');
expect(
SETTINGS_SCHEMA.general.properties.debugKeystrokeLogging.category,
).toBe('General');
expect(
SETTINGS_SCHEMA.general.properties.debugKeystrokeLogging.default,
).toBe(false);
expect(
SETTINGS_SCHEMA.general.properties.debugKeystrokeLogging
.requiresRestart,
).toBe(false);
expect(
SETTINGS_SCHEMA.general.properties.debugKeystrokeLogging.showInDialog,
).toBe(true);
expect(
SETTINGS_SCHEMA.general.properties.debugKeystrokeLogging.description,
).toBe('Enable debug logging of keystrokes to the console.');
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@
*/
// Mock 'os' first.
import * as osActual from 'os';
import * as osActual from 'node:os';
vi.mock('os', async (importOriginal) => {
const actualOs = await importOriginal<typeof osActual>();
return {
@@ -25,9 +25,9 @@ import {
type Mocked,
type Mock,
} from 'vitest';
import * as fs from 'fs';
import * as fs from 'node:fs';
import stripJsonComments from 'strip-json-comments';
import * as path from 'path';
import * as path from 'node:path';
import {
loadTrustedFolders,
@@ -35,7 +35,7 @@ import {
TrustLevel,
isWorkspaceTrusted,
} from './trustedFolders.js';
import { Settings } from './settings.js';
import type { Settings } from './settings.js';
vi.mock('fs', async (importOriginal) => {
const actualFs = await importOriginal<typeof fs>();
@@ -132,8 +132,12 @@ describe('isWorkspaceTrusted', () => {
let mockCwd: string;
const mockRules: Record<string, TrustLevel> = {};
const mockSettings: Settings = {
folderTrustFeature: true,
folderTrust: true,
security: {
folderTrust: {
featureEnabled: true,
enabled: true,
},
},
};
beforeEach(() => {

View File

@@ -4,11 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'fs';
import * as path from 'path';
import { homedir } from 'os';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { homedir } from 'node:os';
import { getErrorMessage, isWithinRoot } from '@qwen-code/qwen-code-core';
import { Settings } from './settings.js';
import type { Settings } from './settings.js';
import stripJsonComments from 'strip-json-comments';
export const TRUSTED_FOLDERS_FILENAME = 'trustedFolders.json';
@@ -111,8 +111,9 @@ export function saveTrustedFolders(
}
export function isWorkspaceTrusted(settings: Settings): boolean | undefined {
const folderTrustFeature = settings.folderTrustFeature ?? false;
const folderTrustSetting = settings.folderTrust ?? true;
const folderTrustFeature =
settings.security?.folderTrust?.featureEnabled ?? false;
const folderTrustSetting = settings.security?.folderTrust?.enabled ?? true;
const folderTrustEnabled = folderTrustFeature && folderTrustSetting;
if (!folderTrustEnabled) {

View File

@@ -4,19 +4,18 @@
* SPDX-License-Identifier: Apache-2.0
*/
import stripAnsi from 'strip-ansi';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
main,
setupUnhandledRejectionHandler,
validateDnsResolutionOrder,
startInteractiveUI,
} from './gemini.js';
import {
LoadedSettings,
SettingsFile,
loadSettings,
} from './config/settings.js';
import type { SettingsFile } from './config/settings.js';
import { LoadedSettings, loadSettings } from './config/settings.js';
import { appEvents, AppEvent } from './utils/events.js';
import type { Config } from '@qwen-code/qwen-code-core';
import { FatalConfigError } from '@qwen-code/qwen-code-core';
// Custom error to identify mock process.exit calls
class MockProcessExitError extends Error {
@@ -76,7 +75,6 @@ vi.mock('./utils/sandbox.js', () => ({
}));
describe('gemini.tsx main function', () => {
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
let loadSettingsMock: ReturnType<typeof vi.mocked<typeof loadSettings>>;
let originalEnvGeminiSandbox: string | undefined;
let originalEnvSandbox: string | undefined;
@@ -98,7 +96,6 @@ describe('gemini.tsx main function', () => {
delete process.env['GEMINI_SANDBOX'];
delete process.env['SANDBOX'];
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
initialUnhandledRejectionListeners =
process.listeners('unhandledRejection');
});
@@ -127,7 +124,7 @@ describe('gemini.tsx main function', () => {
vi.restoreAllMocks();
});
it('should call process.exit(1) if settings have errors', async () => {
it('should throw InvalidConfigurationError if settings have errors', async () => {
const settingsError = {
message: 'Test settings error',
path: '/test/settings.json',
@@ -144,37 +141,23 @@ describe('gemini.tsx main function', () => {
path: '/system/settings.json',
settings: {},
};
const systemDefaultsFile: SettingsFile = {
path: '/system/system-defaults.json',
settings: {},
};
const mockLoadedSettings = new LoadedSettings(
systemSettingsFile,
systemDefaultsFile,
userSettingsFile,
workspaceSettingsFile,
[settingsError],
true,
new Set(),
);
loadSettingsMock.mockReturnValue(mockLoadedSettings);
try {
await main();
// If main completes without throwing, the test should fail because process.exit was expected
expect.fail('main function did not exit as expected');
} catch (error) {
expect(error).toBeInstanceOf(MockProcessExitError);
if (error instanceof MockProcessExitError) {
expect(error.code).toBe(1);
}
}
// Verify console.error was called with the error message
expect(consoleErrorSpy).toHaveBeenCalledTimes(2);
expect(stripAnsi(String(consoleErrorSpy.mock.calls[0][0]))).toBe(
'Error in /test/settings.json: Test settings error',
);
expect(stripAnsi(String(consoleErrorSpy.mock.calls[1][0]))).toBe(
'Please fix /test/settings.json and try again.',
);
// Verify process.exit was called.
expect(processExitSpy).toHaveBeenCalledWith(1);
await expect(main()).rejects.toThrow(FatalConfigError);
});
it('should log unhandled promise rejections and open debug console on first error', async () => {
@@ -250,3 +233,100 @@ describe('validateDnsResolutionOrder', () => {
);
});
});
describe('startInteractiveUI', () => {
// Mock dependencies
const mockConfig = {
getProjectRoot: () => '/root',
getScreenReader: () => false,
} as Config;
const mockSettings = {
merged: {
ui: {
hideWindowTitle: false,
},
},
} as LoadedSettings;
const mockStartupWarnings = ['warning1'];
const mockWorkspaceRoot = '/root';
vi.mock('./utils/version.js', () => ({
getCliVersion: vi.fn(() => Promise.resolve('1.0.0')),
}));
vi.mock('./ui/utils/kittyProtocolDetector.js', () => ({
detectAndEnableKittyProtocol: vi.fn(() => Promise.resolve()),
}));
vi.mock('./ui/utils/updateCheck.js', () => ({
checkForUpdates: vi.fn(() => Promise.resolve(null)),
}));
vi.mock('./utils/cleanup.js', () => ({
cleanupCheckpoints: vi.fn(() => Promise.resolve()),
registerCleanup: vi.fn(),
}));
vi.mock('ink', () => ({
render: vi.fn().mockReturnValue({ unmount: vi.fn() }),
}));
beforeEach(() => {
vi.clearAllMocks();
});
it('should render the UI with proper React context and exitOnCtrlC disabled', async () => {
const { render } = await import('ink');
const renderSpy = vi.mocked(render);
await startInteractiveUI(
mockConfig,
mockSettings,
mockStartupWarnings,
mockWorkspaceRoot,
);
// Verify render was called with correct options
expect(renderSpy).toHaveBeenCalledTimes(1);
const [reactElement, options] = renderSpy.mock.calls[0];
// Verify render options
expect(options).toEqual({
exitOnCtrlC: false,
isScreenReaderEnabled: false,
});
// Verify React element structure is valid (but don't deep dive into JSX internals)
expect(reactElement).toBeDefined();
});
it('should perform all startup tasks in correct order', async () => {
const { getCliVersion } = await import('./utils/version.js');
const { detectAndEnableKittyProtocol } = await import(
'./ui/utils/kittyProtocolDetector.js'
);
const { checkForUpdates } = await import('./ui/utils/updateCheck.js');
const { registerCleanup } = await import('./utils/cleanup.js');
await startInteractiveUI(
mockConfig,
mockSettings,
mockStartupWarnings,
mockWorkspaceRoot,
);
// Verify all startup tasks were called
expect(getCliVersion).toHaveBeenCalledTimes(1);
expect(detectAndEnableKittyProtocol).toHaveBeenCalledTimes(1);
expect(registerCleanup).toHaveBeenCalledTimes(1);
// Verify cleanup handler is registered with unmount function
const cleanupFn = vi.mocked(registerCleanup).mock.calls[0][0];
expect(typeof cleanupFn).toBe('function');
// checkForUpdates should be called asynchronously (not waited for)
// We need a small delay to let it execute
await new Promise((resolve) => setTimeout(resolve, 0));
expect(checkForUpdates).toHaveBeenCalledTimes(1);
});
});

View File

@@ -4,49 +4,47 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { render } from 'ink';
import { AppWrapper } from './ui/App.js';
import { loadCliConfig, parseArguments } from './config/config.js';
import { readStdin } from './utils/readStdin.js';
import { basename } from 'node:path';
import v8 from 'node:v8';
import os from 'node:os';
import dns from 'node:dns';
import { spawn } from 'node:child_process';
import { start_sandbox } from './utils/sandbox.js';
import type { Config } from '@qwen-code/qwen-code-core';
import {
DnsResolutionOrder,
LoadedSettings,
loadSettings,
SettingScope,
} from './config/settings.js';
import { themeManager } from './ui/themes/theme-manager.js';
import { getStartupWarnings } from './utils/startupWarnings.js';
import { getUserStartupWarnings } from './utils/userStartupWarnings.js';
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
import { runNonInteractive } from './nonInteractiveCli.js';
import { loadExtensions } from './config/extension.js';
import { cleanupCheckpoints, registerCleanup } from './utils/cleanup.js';
import { getCliVersion } from './utils/version.js';
import {
Config,
sessionId,
logUserPrompt,
AuthType,
FatalConfigError,
getOauthClient,
logIdeConnection,
IdeConnectionEvent,
IdeConnectionType,
logIdeConnection,
logUserPrompt,
sessionId,
} from '@qwen-code/qwen-code-core';
import { render } from 'ink';
import { spawn } from 'node:child_process';
import dns from 'node:dns';
import os from 'node:os';
import { basename } from 'node:path';
import v8 from 'node:v8';
import React from 'react';
import { validateAuthMethod } from './config/auth.js';
import { loadCliConfig, parseArguments } from './config/config.js';
import { loadExtensions } from './config/extension.js';
import type { DnsResolutionOrder, LoadedSettings } from './config/settings.js';
import { loadSettings, SettingScope } from './config/settings.js';
import { runNonInteractive } from './nonInteractiveCli.js';
import { AppWrapper } from './ui/App.js';
import { setMaxSizedBoxDebugging } from './ui/components/shared/MaxSizedBox.js';
import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
import { SettingsContext } from './ui/contexts/SettingsContext.js';
import { themeManager } from './ui/themes/theme-manager.js';
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
import { detectAndEnableKittyProtocol } from './ui/utils/kittyProtocolDetector.js';
import { checkForUpdates } from './ui/utils/updateCheck.js';
import { cleanupCheckpoints, registerCleanup } from './utils/cleanup.js';
import { AppEvent, appEvents } from './utils/events.js';
import { handleAutoUpdate } from './utils/handleAutoUpdate.js';
import { appEvents, AppEvent } from './utils/events.js';
import { SettingsContext } from './ui/contexts/SettingsContext.js';
import { readStdin } from './utils/readStdin.js';
import { start_sandbox } from './utils/sandbox.js';
import { getStartupWarnings } from './utils/startupWarnings.js';
import { getUserStartupWarnings } from './utils/userStartupWarnings.js';
import { getCliVersion } from './utils/version.js';
import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
import { runZedIntegration } from './zed-integration/zedIntegration.js';
export function validateDnsResolutionOrder(
order: string | undefined,
@@ -108,7 +106,6 @@ async function relaunchWithAdditionalArgs(additionalArgs: string[]) {
await new Promise((resolve) => child.on('close', resolve));
process.exit(0);
}
import { runZedIntegration } from './zed-integration/zedIntegration.js';
export function setupUnhandledRejectionHandler() {
let unhandledRejectionOccurred = false;
@@ -132,6 +129,44 @@ ${reason.stack}`
});
}
export async function startInteractiveUI(
config: Config,
settings: LoadedSettings,
startupWarnings: string[],
workspaceRoot: string,
) {
const version = await getCliVersion();
// Detect and enable Kitty keyboard protocol once at startup
await detectAndEnableKittyProtocol();
setWindowTitle(basename(workspaceRoot), settings);
const instance = render(
<React.StrictMode>
<SettingsContext.Provider value={settings}>
<AppWrapper
config={config}
settings={settings}
startupWarnings={startupWarnings}
version={version}
/>
</SettingsContext.Provider>
</React.StrictMode>,
{ exitOnCtrlC: false, isScreenReaderEnabled: config.getScreenReader() },
);
checkForUpdates()
.then((info) => {
handleAutoUpdate(info, settings, config.getProjectRoot());
})
.catch((err) => {
// Silently ignore update check errors.
if (config.getDebugMode()) {
console.error('Update check failed:', err);
}
});
registerCleanup(() => instance.unmount());
}
export async function main() {
setupUnhandledRejectionHandler();
const workspaceRoot = process.cwd();
@@ -139,18 +174,15 @@ export async function main() {
await cleanupCheckpoints();
if (settings.errors.length > 0) {
for (const error of settings.errors) {
let errorMessage = `Error in ${error.path}: ${error.message}`;
if (!process.env['NO_COLOR']) {
errorMessage = `\x1b[31m${errorMessage}\x1b[0m`;
}
console.error(errorMessage);
console.error(`Please fix ${error.path} and try again.`);
}
process.exit(1);
const errorMessages = settings.errors.map(
(error) => `Error in ${error.path}: ${error.message}`,
);
throw new FatalConfigError(
`${errorMessages.join('\n')}\nPlease fix the configuration file(s) and try again.`,
);
}
const argv = await parseArguments();
const argv = await parseArguments(settings.merged);
const extensions = loadExtensions(workspaceRoot);
const config = await loadCliConfig(
settings.merged,
@@ -167,7 +199,7 @@ export async function main() {
registerCleanup(consolePatcher.cleanup);
dns.setDefaultResultOrder(
validateDnsResolutionOrder(settings.merged.dnsResolutionOrder),
validateDnsResolutionOrder(settings.merged.advanced?.dnsResolutionOrder),
);
if (argv.promptInteractive && !process.stdin.isTTY) {
@@ -186,7 +218,7 @@ export async function main() {
}
// Set a default auth type if one isn't set.
if (!settings.merged.selectedAuthType) {
if (!settings.merged.security?.auth?.selectedType) {
if (process.env['CLOUD_SHELL'] === 'true') {
settings.setValue(
SettingScope.User,
@@ -195,6 +227,14 @@ export async function main() {
);
}
}
// Empty key causes issues with the GoogleGenAI package.
if (process.env['GEMINI_API_KEY']?.trim() === '') {
delete process.env['GEMINI_API_KEY'];
}
if (process.env['GOOGLE_API_KEY']?.trim() === '') {
delete process.env['GOOGLE_API_KEY'];
}
setMaxSizedBoxDebugging(config.getDebugMode());
@@ -206,40 +246,72 @@ export async function main() {
}
// Load custom themes from settings
themeManager.loadCustomThemes(settings.merged.customThemes);
themeManager.loadCustomThemes(settings.merged.ui?.customThemes);
if (settings.merged.theme) {
if (!themeManager.setActiveTheme(settings.merged.theme)) {
if (settings.merged.ui?.theme) {
if (!themeManager.setActiveTheme(settings.merged.ui?.theme)) {
// If the theme is not found during initial load, log a warning and continue.
// The useThemeCommand hook in App.tsx will handle opening the dialog.
console.warn(`Warning: Theme "${settings.merged.theme}" not found.`);
console.warn(`Warning: Theme "${settings.merged.ui?.theme}" not found.`);
}
}
// hop into sandbox if we are outside and sandboxing is enabled
if (!process.env['SANDBOX']) {
const memoryArgs = settings.merged.autoConfigureMaxOldSpaceSize
const memoryArgs = settings.merged.advanced?.autoConfigureMemory
? getNodeMemoryArgs(config)
: [];
const sandboxConfig = config.getSandbox();
if (sandboxConfig) {
if (
settings.merged.selectedAuthType &&
!settings.merged.useExternalAuth
settings.merged.security?.auth?.selectedType &&
!settings.merged.security?.auth?.useExternal
) {
// Validate authentication here because the sandbox will interfere with the Oauth2 web redirect.
try {
const err = validateAuthMethod(settings.merged.selectedAuthType);
const err = validateAuthMethod(
settings.merged.security.auth.selectedType,
);
if (err) {
throw new Error(err);
}
await config.refreshAuth(settings.merged.selectedAuthType);
await config.refreshAuth(settings.merged.security.auth.selectedType);
} catch (err) {
console.error('Error authenticating:', err);
process.exit(1);
}
}
await start_sandbox(sandboxConfig, memoryArgs, config);
let stdinData = '';
if (!process.stdin.isTTY) {
stdinData = await readStdin();
}
// This function is a copy of the one from sandbox.ts
// It is moved here to decouple sandbox.ts from the CLI's argument structure.
const injectStdinIntoArgs = (
args: string[],
stdinData?: string,
): string[] => {
const finalArgs = [...args];
if (stdinData) {
const promptIndex = finalArgs.findIndex(
(arg) => arg === '--prompt' || arg === '-p',
);
if (promptIndex > -1 && finalArgs.length > promptIndex + 1) {
// If there's a prompt argument, prepend stdin to it
finalArgs[promptIndex + 1] =
`${stdinData}\n\n${finalArgs[promptIndex + 1]}`;
} else {
// If there's no prompt argument, add stdin as the prompt
finalArgs.push('--prompt', stdinData);
}
}
return finalArgs;
};
const sandboxArgs = injectStdinIntoArgs(process.argv, stdinData);
await start_sandbox(sandboxConfig, memoryArgs, config, sandboxArgs);
process.exit(0);
} else {
// Not in a sandbox and not entering one, so relaunch with additional
@@ -252,11 +324,12 @@ export async function main() {
}
if (
settings.merged.selectedAuthType === AuthType.LOGIN_WITH_GOOGLE &&
settings.merged.security?.auth?.selectedType ===
AuthType.LOGIN_WITH_GOOGLE &&
config.isBrowserLaunchSuppressed()
) {
// Do oauth before app renders to make copying the link possible.
await getOauthClient(settings.merged.selectedAuthType, config);
await getOauthClient(settings.merged.security.auth.selectedType, config);
}
if (config.getExperimentalZedIntegration()) {
@@ -271,36 +344,7 @@ export async function main() {
// Render UI, passing necessary config values. Check that there is no command line question.
if (config.isInteractive()) {
const version = await getCliVersion();
// Detect and enable Kitty keyboard protocol once at startup
await detectAndEnableKittyProtocol();
setWindowTitle(basename(workspaceRoot), settings);
const instance = render(
<React.StrictMode>
<SettingsContext.Provider value={settings}>
<AppWrapper
config={config}
settings={settings}
startupWarnings={startupWarnings}
version={version}
/>
</SettingsContext.Provider>
</React.StrictMode>,
{ exitOnCtrlC: false },
);
checkForUpdates()
.then((info) => {
handleAutoUpdate(info, settings, config.getProjectRoot());
})
.catch((err) => {
// Silently ignore update check errors.
if (config.getDebugMode()) {
console.error('Update check failed:', err);
}
});
registerCleanup(() => instance.unmount());
await startInteractiveUI(config, settings, startupWarnings, workspaceRoot);
return;
}
// If not a TTY, read from stdin
@@ -312,7 +356,9 @@ export async function main() {
}
}
if (!input) {
console.error('No input provided via stdin.');
console.error(
`No input provided via stdin. Input can be provided by piping data into gemini or using the --prompt option.`,
);
process.exit(1);
}
@@ -327,17 +373,21 @@ export async function main() {
});
const nonInteractiveConfig = await validateNonInteractiveAuth(
settings.merged.selectedAuthType,
settings.merged.useExternalAuth,
settings.merged.security?.auth?.selectedType,
settings.merged.security?.auth?.useExternal,
config,
);
if (config.getDebugMode()) {
console.log('Session ID: %s', sessionId);
}
await runNonInteractive(nonInteractiveConfig, input, prompt_id);
process.exit(0);
}
function setWindowTitle(title: string, settings: LoadedSettings) {
if (!settings.merged.hideWindowTitle) {
if (!settings.merged.ui?.hideWindowTitle) {
const windowTitle = (process.env['CLI_TITLE'] || `Qwen - ${title}`).replace(
// eslint-disable-next-line no-control-regex
/[\x00-\x1F\x7F]/g,

View File

@@ -5,19 +5,20 @@
*/
import {
Config,
type Config,
type ToolRegistry,
executeToolCall,
ToolRegistry,
ToolErrorType,
shutdownTelemetry,
GeminiEventType,
ServerGeminiStreamEvent,
type ServerGeminiStreamEvent,
} from '@qwen-code/qwen-code-core';
import { Part } from '@google/genai';
import { type Part } from '@google/genai';
import { runNonInteractive } from './nonInteractiveCli.js';
import { vi } from 'vitest';
// Mock core modules
vi.mock('./ui/hooks/atCommandProcessor.js');
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const original =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
@@ -35,20 +36,16 @@ describe('runNonInteractive', () => {
let mockCoreExecuteToolCall: vi.Mock;
let mockShutdownTelemetry: vi.Mock;
let consoleErrorSpy: vi.SpyInstance;
let processExitSpy: vi.SpyInstance;
let processStdoutSpy: vi.SpyInstance;
let mockGeminiClient: {
sendMessageStream: vi.Mock;
};
beforeEach(() => {
beforeEach(async () => {
mockCoreExecuteToolCall = vi.mocked(executeToolCall);
mockShutdownTelemetry = vi.mocked(shutdownTelemetry);
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((() => {}) as (code?: number) => never);
processStdoutSpy = vi
.spyOn(process.stdout, 'write')
.mockImplementation(() => true);
@@ -72,6 +69,14 @@ describe('runNonInteractive', () => {
getContentGeneratorConfig: vi.fn().mockReturnValue({}),
getDebugMode: vi.fn().mockReturnValue(false),
} as unknown as Config;
const { handleAtCommand } = await import(
'./ui/hooks/atCommandProcessor.js'
);
vi.mocked(handleAtCommand).mockImplementation(async ({ query }) => ({
processedQuery: [{ text: query }],
shouldProceed: true,
}));
});
afterEach(() => {
@@ -163,14 +168,16 @@ describe('runNonInteractive', () => {
mockCoreExecuteToolCall.mockResolvedValue({
error: new Error('Execution failed'),
errorType: ToolErrorType.EXECUTION_FAILED,
responseParts: {
functionResponse: {
name: 'errorTool',
response: {
output: 'Error: Execution failed',
responseParts: [
{
functionResponse: {
name: 'errorTool',
response: {
output: 'Error: Execution failed',
},
},
},
},
],
resultDisplay: 'Execution failed',
});
const finalResponse: ServerGeminiStreamEvent[] = [
@@ -189,7 +196,6 @@ describe('runNonInteractive', () => {
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Error executing tool errorTool: Execution failed',
);
expect(processExitSpy).not.toHaveBeenCalled();
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2);
expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith(
2,
@@ -215,12 +221,9 @@ describe('runNonInteractive', () => {
throw apiError;
});
await runNonInteractive(mockConfig, 'Initial fail', 'prompt-id-4');
expect(consoleErrorSpy).toHaveBeenCalledWith(
'[API Error: API connection failed]',
);
expect(processExitSpy).toHaveBeenCalledWith(1);
await expect(
runNonInteractive(mockConfig, 'Initial fail', 'prompt-id-4'),
).rejects.toThrow(apiError);
});
it('should not exit if a tool is not found, and should send error back to model', async () => {
@@ -259,7 +262,6 @@ describe('runNonInteractive', () => {
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Error executing tool nonexistentTool: Tool "nonexistentTool" not found in registry.',
);
expect(processExitSpy).not.toHaveBeenCalled();
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2);
expect(processStdoutSpy).toHaveBeenCalledWith(
"Sorry, I can't find that tool.",
@@ -268,9 +270,54 @@ describe('runNonInteractive', () => {
it('should exit when max session turns are exceeded', async () => {
vi.mocked(mockConfig.getMaxSessionTurns).mockReturnValue(0);
await runNonInteractive(mockConfig, 'Trigger loop', 'prompt-id-6');
expect(consoleErrorSpy).toHaveBeenCalledWith(
'\n Reached max session turns for this session. Increase the number of turns by specifying maxSessionTurns in settings.json.',
await expect(
runNonInteractive(mockConfig, 'Trigger loop', 'prompt-id-6'),
).rejects.toThrow(
'Reached max session turns for this session. Increase the number of turns by specifying maxSessionTurns in settings.json.',
);
});
it('should preprocess @include commands before sending to the model', async () => {
// 1. Mock the imported atCommandProcessor
const { handleAtCommand } = await import(
'./ui/hooks/atCommandProcessor.js'
);
const mockHandleAtCommand = vi.mocked(handleAtCommand);
// 2. Define the raw input and the expected processed output
const rawInput = 'Summarize @file.txt';
const processedParts: Part[] = [
{ text: 'Summarize @file.txt' },
{ text: '\n--- Content from referenced files ---\n' },
{ text: 'This is the content of the file.' },
{ text: '\n--- End of content ---' },
];
// 3. Setup the mock to return the processed parts
mockHandleAtCommand.mockResolvedValue({
processedQuery: processedParts,
shouldProceed: true,
});
// Mock a simple stream response from the Gemini client
const events: ServerGeminiStreamEvent[] = [
{ type: GeminiEventType.Content, value: 'Summary complete.' },
];
mockGeminiClient.sendMessageStream.mockReturnValue(
createStreamFromEvents(events),
);
// 4. Run the non-interactive mode with the raw input
await runNonInteractive(mockConfig, rawInput, 'prompt-id-7');
// 5. Assert that sendMessageStream was called with the PROCESSED parts, not the raw input
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith(
processedParts,
expect.any(AbortSignal),
'prompt-id-7',
);
// 6. Assert the final output is correct
expect(processStdoutSpy).toHaveBeenCalledWith('Summary complete.');
});
});

View File

@@ -4,18 +4,20 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { Config, ToolCallRequestInfo } from '@qwen-code/qwen-code-core';
import {
Config,
ToolCallRequestInfo,
executeToolCall,
shutdownTelemetry,
isTelemetrySdkInitialized,
GeminiEventType,
parseAndFormatApiError,
FatalInputError,
FatalTurnLimitedError,
} from '@qwen-code/qwen-code-core';
import { Content, Part, FunctionCall } from '@google/genai';
import type { Content, Part } from '@google/genai';
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
import { handleAtCommand } from './ui/hooks/atCommandProcessor.js';
export async function runNonInteractive(
config: Config,
@@ -40,9 +42,28 @@ export async function runNonInteractive(
const geminiClient = config.getGeminiClient();
const abortController = new AbortController();
const { processedQuery, shouldProceed } = await handleAtCommand({
query: input,
config,
addItem: (_item, _timestamp) => 0,
onDebugMessage: () => {},
messageId: Date.now(),
signal: abortController.signal,
});
if (!shouldProceed || !processedQuery) {
// An error occurred during @include processing (e.g., file not found).
// The error message is already logged by handleAtCommand.
throw new FatalInputError(
'Exiting due to an error processing the @ command.',
);
}
let currentMessages: Content[] = [
{ role: 'user', parts: [{ text: input }] },
{ role: 'user', parts: processedQuery as Part[] },
];
let turnCount = 0;
while (true) {
turnCount++;
@@ -50,12 +71,11 @@ export async function runNonInteractive(
config.getMaxSessionTurns() >= 0 &&
turnCount > config.getMaxSessionTurns()
) {
console.error(
'\n Reached max session turns for this session. Increase the number of turns by specifying maxSessionTurns in settings.json.',
throw new FatalTurnLimitedError(
'Reached max session turns for this session. Increase the number of turns by specifying maxSessionTurns in settings.json.',
);
return;
}
const functionCalls: FunctionCall[] = [];
const toolCallRequests: ToolCallRequestInfo[] = [];
const responseStream = geminiClient.sendMessageStream(
currentMessages[0]?.parts || [],
@@ -72,29 +92,13 @@ export async function runNonInteractive(
if (event.type === GeminiEventType.Content) {
process.stdout.write(event.value);
} else if (event.type === GeminiEventType.ToolCallRequest) {
const toolCallRequest = event.value;
const fc: FunctionCall = {
name: toolCallRequest.name,
args: toolCallRequest.args,
id: toolCallRequest.callId,
};
functionCalls.push(fc);
toolCallRequests.push(event.value);
}
}
if (functionCalls.length > 0) {
if (toolCallRequests.length > 0) {
const toolResponseParts: Part[] = [];
for (const fc of functionCalls) {
const callId = fc.id ?? `${fc.name}-${Date.now()}`;
const requestInfo: ToolCallRequestInfo = {
callId,
name: fc.name as string,
args: (fc.args ?? {}) as Record<string, unknown>,
isClientInitiated: false,
prompt_id,
};
for (const requestInfo of toolCallRequests) {
const toolResponse = await executeToolCall(
config,
requestInfo,
@@ -103,21 +107,12 @@ export async function runNonInteractive(
if (toolResponse.error) {
console.error(
`Error executing tool ${fc.name}: ${toolResponse.resultDisplay || toolResponse.error.message}`,
`Error executing tool ${requestInfo.name}: ${toolResponse.resultDisplay || toolResponse.error.message}`,
);
}
if (toolResponse.responseParts) {
const parts = Array.isArray(toolResponse.responseParts)
? toolResponse.responseParts
: [toolResponse.responseParts];
for (const part of parts) {
if (typeof part === 'string') {
toolResponseParts.push({ text: part });
} else if (part) {
toolResponseParts.push(part);
}
}
toolResponseParts.push(...toolResponse.responseParts);
}
}
currentMessages = [{ role: 'user', parts: toolResponseParts }];
@@ -133,7 +128,7 @@ export async function runNonInteractive(
config.getContentGeneratorConfig()?.authType,
),
);
process.exit(1);
throw error;
} finally {
consolePatcher.cleanup();
if (isTelemetrySdkInitialized()) {

View File

@@ -22,7 +22,7 @@ vi.mock('../ui/commands/restoreCommand.js', () => ({
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { BuiltinCommandLoader } from './BuiltinCommandLoader.js';
import { Config } from '@qwen-code/qwen-code-core';
import type { Config } from '@qwen-code/qwen-code-core';
import { CommandKind } from '../ui/commands/types.js';
import { ideCommand } from '../ui/commands/ideCommand.js';

View File

@@ -4,9 +4,9 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { ICommandLoader } from './types.js';
import { SlashCommand } from '../ui/commands/types.js';
import { Config } from '@qwen-code/qwen-code-core';
import type { ICommandLoader } from './types.js';
import type { SlashCommand } from '../ui/commands/types.js';
import type { Config } from '@qwen-code/qwen-code-core';
import { aboutCommand } from '../ui/commands/aboutCommand.js';
import { authCommand } from '../ui/commands/authCommand.js';
import { bugCommand } from '../ui/commands/bugCommand.js';

View File

@@ -4,8 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { SlashCommand } from '../ui/commands/types.js';
import { ICommandLoader } from './types.js';
import type { SlashCommand } from '../ui/commands/types.js';
import type { ICommandLoader } from './types.js';
/**
* Orchestrates the discovery and loading of all slash commands for the CLI.

View File

@@ -5,11 +5,8 @@
*/
import * as path from 'node:path';
import {
Config,
getProjectCommandsDir,
getUserCommandsDir,
} from '@qwen-code/qwen-code-core';
import type { Config } from '@qwen-code/qwen-code-core';
import { Storage } from '@qwen-code/qwen-code-core';
import mock from 'mock-fs';
import { FileCommandLoader } from './FileCommandLoader.js';
import { assert, vi } from 'vitest';
@@ -17,15 +14,23 @@ import { createMockCommandContext } from '../test-utils/mockCommandContext.js';
import {
SHELL_INJECTION_TRIGGER,
SHORTHAND_ARGS_PLACEHOLDER,
type PromptPipelineContent,
} from './prompt-processors/types.js';
import {
ConfirmationRequiredError,
ShellProcessor,
} from './prompt-processors/shellProcessor.js';
import { DefaultArgumentProcessor } from './prompt-processors/argumentProcessor.js';
import { CommandContext } from '../ui/commands/types.js';
import type { CommandContext } from '../ui/commands/types.js';
import { AtFileProcessor } from './prompt-processors/atFileProcessor.js';
const mockShellProcess = vi.hoisted(() => vi.fn());
const mockAtFileProcess = vi.hoisted(() => vi.fn());
vi.mock('./prompt-processors/atFileProcessor.js', () => ({
AtFileProcessor: vi.fn().mockImplementation(() => ({
process: mockAtFileProcess,
})),
}));
vi.mock('./prompt-processors/shellProcessor.js', () => ({
ShellProcessor: vi.fn().mockImplementation(() => ({
process: mockShellProcess,
@@ -57,6 +62,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
return {
...original,
Storage: original.Storage,
isCommandAllowed: vi.fn(),
ShellExecutionService: {
execute: vi.fn(),
@@ -70,15 +76,28 @@ describe('FileCommandLoader', () => {
beforeEach(() => {
vi.clearAllMocks();
mockShellProcess.mockImplementation(
(prompt: string, context: CommandContext) => {
(prompt: PromptPipelineContent, context: CommandContext) => {
const userArgsRaw = context?.invocation?.args || '';
const processedPrompt = prompt.replaceAll(
// This is a simplified mock. A real implementation would need to iterate
// through all parts and process only the text parts.
const firstTextPart = prompt.find(
(p) => typeof p === 'string' || 'text' in p,
);
let textContent = '';
if (typeof firstTextPart === 'string') {
textContent = firstTextPart;
} else if (firstTextPart && 'text' in firstTextPart) {
textContent = firstTextPart.text ?? '';
}
const processedText = textContent.replaceAll(
SHORTHAND_ARGS_PLACEHOLDER,
userArgsRaw,
);
return Promise.resolve(processedPrompt);
return Promise.resolve([{ text: processedText }]);
},
);
mockAtFileProcess.mockImplementation(async (prompt: string) => prompt);
});
afterEach(() => {
@@ -86,7 +105,7 @@ describe('FileCommandLoader', () => {
});
it('loads a single command from a file', async () => {
const userCommandsDir = getUserCommandsDir();
const userCommandsDir = Storage.getUserCommandsDir();
mock({
[userCommandsDir]: {
'test.toml': 'prompt = "This is a test prompt"',
@@ -112,7 +131,7 @@ describe('FileCommandLoader', () => {
'',
);
if (result?.type === 'submit_prompt') {
expect(result.content).toBe('This is a test prompt');
expect(result.content).toEqual([{ text: 'This is a test prompt' }]);
} else {
assert.fail('Incorrect action type');
}
@@ -127,7 +146,7 @@ describe('FileCommandLoader', () => {
itif(process.platform !== 'win32')(
'loads commands from a symlinked directory',
async () => {
const userCommandsDir = getUserCommandsDir();
const userCommandsDir = Storage.getUserCommandsDir();
const realCommandsDir = '/real/commands';
mock({
[realCommandsDir]: {
@@ -152,7 +171,7 @@ describe('FileCommandLoader', () => {
itif(process.platform !== 'win32')(
'loads commands from a symlinked subdirectory',
async () => {
const userCommandsDir = getUserCommandsDir();
const userCommandsDir = Storage.getUserCommandsDir();
const realNamespacedDir = '/real/namespaced-commands';
mock({
[userCommandsDir]: {
@@ -176,7 +195,7 @@ describe('FileCommandLoader', () => {
);
it('loads multiple commands', async () => {
const userCommandsDir = getUserCommandsDir();
const userCommandsDir = Storage.getUserCommandsDir();
mock({
[userCommandsDir]: {
'test1.toml': 'prompt = "Prompt 1"',
@@ -191,7 +210,7 @@ describe('FileCommandLoader', () => {
});
it('creates deeply nested namespaces correctly', async () => {
const userCommandsDir = getUserCommandsDir();
const userCommandsDir = Storage.getUserCommandsDir();
mock({
[userCommandsDir]: {
@@ -213,7 +232,7 @@ describe('FileCommandLoader', () => {
});
it('creates namespaces from nested directories', async () => {
const userCommandsDir = getUserCommandsDir();
const userCommandsDir = Storage.getUserCommandsDir();
mock({
[userCommandsDir]: {
git: {
@@ -232,8 +251,10 @@ describe('FileCommandLoader', () => {
});
it('returns both user and project commands in order', async () => {
const userCommandsDir = getUserCommandsDir();
const projectCommandsDir = getProjectCommandsDir(process.cwd());
const userCommandsDir = Storage.getUserCommandsDir();
const projectCommandsDir = new Storage(
process.cwd(),
).getProjectCommandsDir();
mock({
[userCommandsDir]: {
'test.toml': 'prompt = "User prompt"',
@@ -262,7 +283,7 @@ describe('FileCommandLoader', () => {
'',
);
if (userResult?.type === 'submit_prompt') {
expect(userResult.content).toBe('User prompt');
expect(userResult.content).toEqual([{ text: 'User prompt' }]);
} else {
assert.fail('Incorrect action type for user command');
}
@@ -277,14 +298,14 @@ describe('FileCommandLoader', () => {
'',
);
if (projectResult?.type === 'submit_prompt') {
expect(projectResult.content).toBe('Project prompt');
expect(projectResult.content).toEqual([{ text: 'Project prompt' }]);
} else {
assert.fail('Incorrect action type for project command');
}
});
it('ignores files with TOML syntax errors', async () => {
const userCommandsDir = getUserCommandsDir();
const userCommandsDir = Storage.getUserCommandsDir();
mock({
[userCommandsDir]: {
'invalid.toml': 'this is not valid toml',
@@ -300,7 +321,7 @@ describe('FileCommandLoader', () => {
});
it('ignores files that are semantically invalid (missing prompt)', async () => {
const userCommandsDir = getUserCommandsDir();
const userCommandsDir = Storage.getUserCommandsDir();
mock({
[userCommandsDir]: {
'no_prompt.toml': 'description = "This file is missing a prompt"',
@@ -316,7 +337,7 @@ describe('FileCommandLoader', () => {
});
it('handles filename edge cases correctly', async () => {
const userCommandsDir = getUserCommandsDir();
const userCommandsDir = Storage.getUserCommandsDir();
mock({
[userCommandsDir]: {
'test.v1.toml': 'prompt = "Test prompt"',
@@ -338,7 +359,7 @@ describe('FileCommandLoader', () => {
});
it('uses a default description if not provided', async () => {
const userCommandsDir = getUserCommandsDir();
const userCommandsDir = Storage.getUserCommandsDir();
mock({
[userCommandsDir]: {
'test.toml': 'prompt = "Test prompt"',
@@ -353,7 +374,7 @@ describe('FileCommandLoader', () => {
});
it('uses the provided description', async () => {
const userCommandsDir = getUserCommandsDir();
const userCommandsDir = Storage.getUserCommandsDir();
mock({
[userCommandsDir]: {
'test.toml': 'prompt = "Test prompt"\ndescription = "My test command"',
@@ -368,7 +389,7 @@ describe('FileCommandLoader', () => {
});
it('should sanitize colons in filenames to prevent namespace conflicts', async () => {
const userCommandsDir = getUserCommandsDir();
const userCommandsDir = Storage.getUserCommandsDir();
mock({
[userCommandsDir]: {
'legacy:command.toml': 'prompt = "This is a legacy command"',
@@ -388,7 +409,7 @@ describe('FileCommandLoader', () => {
describe('Processor Instantiation Logic', () => {
it('instantiates only DefaultArgumentProcessor if no {{args}} or !{} are present', async () => {
const userCommandsDir = getUserCommandsDir();
const userCommandsDir = Storage.getUserCommandsDir();
mock({
[userCommandsDir]: {
'simple.toml': `prompt = "Just a regular prompt"`,
@@ -403,7 +424,7 @@ describe('FileCommandLoader', () => {
});
it('instantiates only ShellProcessor if {{args}} is present (but not !{})', async () => {
const userCommandsDir = getUserCommandsDir();
const userCommandsDir = Storage.getUserCommandsDir();
mock({
[userCommandsDir]: {
'args.toml': `prompt = "Prompt with {{args}}"`,
@@ -418,7 +439,7 @@ describe('FileCommandLoader', () => {
});
it('instantiates ShellProcessor and DefaultArgumentProcessor if !{} is present (but not {{args}})', async () => {
const userCommandsDir = getUserCommandsDir();
const userCommandsDir = Storage.getUserCommandsDir();
mock({
[userCommandsDir]: {
'shell.toml': `prompt = "Prompt with !{cmd}"`,
@@ -433,7 +454,7 @@ describe('FileCommandLoader', () => {
});
it('instantiates only ShellProcessor if both {{args}} and !{} are present', async () => {
const userCommandsDir = getUserCommandsDir();
const userCommandsDir = Storage.getUserCommandsDir();
mock({
[userCommandsDir]: {
'both.toml': `prompt = "Prompt with {{args}} and !{cmd}"`,
@@ -446,12 +467,62 @@ describe('FileCommandLoader', () => {
expect(ShellProcessor).toHaveBeenCalledTimes(1);
expect(DefaultArgumentProcessor).not.toHaveBeenCalled();
});
it('instantiates AtFileProcessor and DefaultArgumentProcessor if @{} is present', async () => {
const userCommandsDir = Storage.getUserCommandsDir();
mock({
[userCommandsDir]: {
'at-file.toml': `prompt = "Context: @{./my-file.txt}"`,
},
});
const loader = new FileCommandLoader(null as unknown as Config);
await loader.loadCommands(signal);
expect(AtFileProcessor).toHaveBeenCalledTimes(1);
expect(ShellProcessor).not.toHaveBeenCalled();
expect(DefaultArgumentProcessor).toHaveBeenCalledTimes(1);
});
it('instantiates ShellProcessor and AtFileProcessor if !{} and @{} are present', async () => {
const userCommandsDir = Storage.getUserCommandsDir();
mock({
[userCommandsDir]: {
'shell-and-at.toml': `prompt = "Run !{cmd} with @{file.txt}"`,
},
});
const loader = new FileCommandLoader(null as unknown as Config);
await loader.loadCommands(signal);
expect(ShellProcessor).toHaveBeenCalledTimes(1);
expect(AtFileProcessor).toHaveBeenCalledTimes(1);
expect(DefaultArgumentProcessor).toHaveBeenCalledTimes(1); // because no {{args}}
});
it('instantiates only ShellProcessor and AtFileProcessor if {{args}} and @{} are present', async () => {
const userCommandsDir = Storage.getUserCommandsDir();
mock({
[userCommandsDir]: {
'args-and-at.toml': `prompt = "Run {{args}} with @{file.txt}"`,
},
});
const loader = new FileCommandLoader(null as unknown as Config);
await loader.loadCommands(signal);
expect(ShellProcessor).toHaveBeenCalledTimes(1);
expect(AtFileProcessor).toHaveBeenCalledTimes(1);
expect(DefaultArgumentProcessor).not.toHaveBeenCalled();
});
});
describe('Extension Command Loading', () => {
it('loads commands from active extensions', async () => {
const userCommandsDir = getUserCommandsDir();
const projectCommandsDir = getProjectCommandsDir(process.cwd());
const userCommandsDir = Storage.getUserCommandsDir();
const projectCommandsDir = new Storage(
process.cwd(),
).getProjectCommandsDir();
const extensionDir = path.join(
process.cwd(),
'.gemini/extensions/test-ext',
@@ -499,8 +570,10 @@ describe('FileCommandLoader', () => {
});
it('extension commands have extensionName metadata for conflict resolution', async () => {
const userCommandsDir = getUserCommandsDir();
const projectCommandsDir = getProjectCommandsDir(process.cwd());
const userCommandsDir = Storage.getUserCommandsDir();
const projectCommandsDir = new Storage(
process.cwd(),
).getProjectCommandsDir();
const extensionDir = path.join(
process.cwd(),
'.gemini/extensions/test-ext',
@@ -555,7 +628,7 @@ describe('FileCommandLoader', () => {
);
expect(result0?.type).toBe('submit_prompt');
if (result0?.type === 'submit_prompt') {
expect(result0.content).toBe('User deploy command');
expect(result0.content).toEqual([{ text: 'User deploy command' }]);
}
expect(commands[1].name).toBe('deploy');
@@ -572,7 +645,7 @@ describe('FileCommandLoader', () => {
);
expect(result1?.type).toBe('submit_prompt');
if (result1?.type === 'submit_prompt') {
expect(result1.content).toBe('Project deploy command');
expect(result1.content).toEqual([{ text: 'Project deploy command' }]);
}
expect(commands[2].name).toBe('deploy');
@@ -590,7 +663,7 @@ describe('FileCommandLoader', () => {
);
expect(result2?.type).toBe('submit_prompt');
if (result2?.type === 'submit_prompt') {
expect(result2.content).toBe('Extension deploy command');
expect(result2.content).toEqual([{ text: 'Extension deploy command' }]);
}
});
@@ -733,7 +806,9 @@ describe('FileCommandLoader', () => {
'',
);
if (result?.type === 'submit_prompt') {
expect(result.content).toBe('Nested command from extension a');
expect(result.content).toEqual([
{ text: 'Nested command from extension a' },
]);
} else {
assert.fail('Incorrect action type');
}
@@ -742,7 +817,7 @@ describe('FileCommandLoader', () => {
describe('Argument Handling Integration (via ShellProcessor)', () => {
it('correctly processes a command with {{args}}', async () => {
const userCommandsDir = getUserCommandsDir();
const userCommandsDir = Storage.getUserCommandsDir();
mock({
[userCommandsDir]: {
'shorthand.toml':
@@ -767,14 +842,16 @@ describe('FileCommandLoader', () => {
);
expect(result?.type).toBe('submit_prompt');
if (result?.type === 'submit_prompt') {
expect(result.content).toBe('The user wants to: do something cool');
expect(result.content).toEqual([
{ text: 'The user wants to: do something cool' },
]);
}
});
});
describe('Default Argument Processor Integration', () => {
it('correctly processes a command without {{args}}', async () => {
const userCommandsDir = getUserCommandsDir();
const userCommandsDir = Storage.getUserCommandsDir();
mock({
[userCommandsDir]: {
'model_led.toml':
@@ -801,14 +878,14 @@ describe('FileCommandLoader', () => {
if (result?.type === 'submit_prompt') {
const expectedContent =
'This is the instruction.\n\n/model_led 1.2.0 added "a feature"';
expect(result.content).toBe(expectedContent);
expect(result.content).toEqual([{ text: expectedContent }]);
}
});
});
describe('Shell Processor Integration', () => {
it('instantiates ShellProcessor if {{args}} is present (even without shell trigger)', async () => {
const userCommandsDir = getUserCommandsDir();
const userCommandsDir = Storage.getUserCommandsDir();
mock({
[userCommandsDir]: {
'args_only.toml': `prompt = "Hello {{args}}"`,
@@ -821,7 +898,7 @@ describe('FileCommandLoader', () => {
expect(ShellProcessor).toHaveBeenCalledWith('args_only');
});
it('instantiates ShellProcessor if the trigger is present', async () => {
const userCommandsDir = getUserCommandsDir();
const userCommandsDir = Storage.getUserCommandsDir();
mock({
[userCommandsDir]: {
'shell.toml': `prompt = "Run this: ${SHELL_INJECTION_TRIGGER}echo hello}"`,
@@ -835,7 +912,7 @@ describe('FileCommandLoader', () => {
});
it('does not instantiate ShellProcessor if no triggers ({{args}} or !{}) are present', async () => {
const userCommandsDir = getUserCommandsDir();
const userCommandsDir = Storage.getUserCommandsDir();
mock({
[userCommandsDir]: {
'regular.toml': `prompt = "Just a regular prompt"`,
@@ -849,13 +926,13 @@ describe('FileCommandLoader', () => {
});
it('returns a "submit_prompt" action if shell processing succeeds', async () => {
const userCommandsDir = getUserCommandsDir();
const userCommandsDir = Storage.getUserCommandsDir();
mock({
[userCommandsDir]: {
'shell.toml': `prompt = "Run !{echo 'hello'}"`,
},
});
mockShellProcess.mockResolvedValue('Run hello');
mockShellProcess.mockResolvedValue([{ text: 'Run hello' }]);
const loader = new FileCommandLoader(null as unknown as Config);
const commands = await loader.loadCommands(signal);
@@ -871,12 +948,12 @@ describe('FileCommandLoader', () => {
expect(result?.type).toBe('submit_prompt');
if (result?.type === 'submit_prompt') {
expect(result.content).toBe('Run hello');
expect(result.content).toEqual([{ text: 'Run hello' }]);
}
});
it('returns a "confirm_shell_commands" action if shell processing requires it', async () => {
const userCommandsDir = getUserCommandsDir();
const userCommandsDir = Storage.getUserCommandsDir();
const rawInvocation = '/shell rm -rf /';
mock({
[userCommandsDir]: {
@@ -910,7 +987,7 @@ describe('FileCommandLoader', () => {
});
it('re-throws other errors from the processor', async () => {
const userCommandsDir = getUserCommandsDir();
const userCommandsDir = Storage.getUserCommandsDir();
mock({
[userCommandsDir]: {
'shell.toml': `prompt = "Run !{something}"`,
@@ -934,23 +1011,36 @@ describe('FileCommandLoader', () => {
),
).rejects.toThrow('Something else went wrong');
});
it('assembles the processor pipeline in the correct order (Shell -> Default)', async () => {
const userCommandsDir = getUserCommandsDir();
it('assembles the processor pipeline in the correct order (AtFile -> Shell -> Default)', async () => {
const userCommandsDir = Storage.getUserCommandsDir();
mock({
[userCommandsDir]: {
// This prompt uses !{} but NOT {{args}}, so both processors should be active.
// This prompt uses !{}, @{}, but NOT {{args}}, so all processors should be active.
'pipeline.toml': `
prompt = "Shell says: ${SHELL_INJECTION_TRIGGER}echo foo}."
prompt = "Shell says: !{echo foo}. File says: @{./bar.txt}"
`,
},
'./bar.txt': 'bar content',
});
const defaultProcessMock = vi
.fn()
.mockImplementation((p) => Promise.resolve(`${p}-default-processed`));
.mockImplementation((p: PromptPipelineContent) =>
Promise.resolve([
{ text: `${(p[0] as { text: string }).text}-default-processed` },
]),
);
mockShellProcess.mockImplementation((p) =>
Promise.resolve(`${p}-shell-processed`),
mockShellProcess.mockImplementation((p: PromptPipelineContent) =>
Promise.resolve([
{ text: `${(p[0] as { text: string }).text}-shell-processed` },
]),
);
mockAtFileProcess.mockImplementation((p: PromptPipelineContent) =>
Promise.resolve([
{ text: `${(p[0] as { text: string }).text}-at-file-processed` },
]),
);
vi.mocked(DefaultArgumentProcessor).mockImplementation(
@@ -968,35 +1058,115 @@ describe('FileCommandLoader', () => {
const result = await command!.action!(
createMockCommandContext({
invocation: {
raw: '/pipeline bar',
raw: '/pipeline baz',
name: 'pipeline',
args: 'bar',
args: 'baz',
},
}),
'bar',
'baz',
);
expect(mockAtFileProcess.mock.invocationCallOrder[0]).toBeLessThan(
mockShellProcess.mock.invocationCallOrder[0],
);
expect(mockShellProcess.mock.invocationCallOrder[0]).toBeLessThan(
defaultProcessMock.mock.invocationCallOrder[0],
);
// Verify the flow of the prompt through the processors
// 1. Shell processor runs first
expect(mockShellProcess).toHaveBeenCalledWith(
expect.stringContaining(SHELL_INJECTION_TRIGGER),
// 1. AtFile processor runs first
expect(mockAtFileProcess).toHaveBeenCalledWith(
[{ text: expect.stringContaining('@{./bar.txt}') }],
expect.any(Object),
);
// 2. Default processor runs second
// 2. Shell processor runs second
expect(mockShellProcess).toHaveBeenCalledWith(
[{ text: expect.stringContaining('-at-file-processed') }],
expect.any(Object),
);
// 3. Default processor runs third
expect(defaultProcessMock).toHaveBeenCalledWith(
expect.stringContaining('-shell-processed'),
[{ text: expect.stringContaining('-shell-processed') }],
expect.any(Object),
);
if (result?.type === 'submit_prompt') {
expect(result.content).toContain('-shell-processed-default-processed');
const contentAsArray = Array.isArray(result.content)
? result.content
: [result.content];
expect(contentAsArray.length).toBeGreaterThan(0);
const firstPart = contentAsArray[0];
if (typeof firstPart === 'object' && firstPart && 'text' in firstPart) {
expect(firstPart.text).toContain(
'-at-file-processed-shell-processed-default-processed',
);
} else {
assert.fail(
'First part of content is not a text part or is a string',
);
}
} else {
assert.fail('Incorrect action type');
}
});
});
describe('@-file Processor Integration', () => {
it('correctly processes a command with @{file}', async () => {
const userCommandsDir = Storage.getUserCommandsDir();
mock({
[userCommandsDir]: {
'at-file.toml':
'prompt = "Context from file: @{./test.txt}"\ndescription = "@-file test"',
},
'./test.txt': 'file content',
});
mockAtFileProcess.mockImplementation(
async (prompt: PromptPipelineContent) => {
// A simplified mock of AtFileProcessor's behavior
const textContent = (prompt[0] as { text: string }).text;
if (textContent.includes('@{./test.txt}')) {
return [
{
text: textContent.replace('@{./test.txt}', 'file content'),
},
];
}
return prompt;
},
);
// Prevent default processor from interfering
vi.mocked(DefaultArgumentProcessor).mockImplementation(
() =>
({
process: (p: PromptPipelineContent) => Promise.resolve(p),
}) as unknown as DefaultArgumentProcessor,
);
const loader = new FileCommandLoader(null as unknown as Config);
const commands = await loader.loadCommands(signal);
const command = commands.find((c) => c.name === 'at-file');
expect(command).toBeDefined();
const result = await command!.action?.(
createMockCommandContext({
invocation: {
raw: '/at-file',
name: 'at-file',
args: '',
},
}),
'',
);
expect(result?.type).toBe('submit_prompt');
if (result?.type === 'submit_prompt') {
expect(result.content).toEqual([
{ text: 'Context from file: file content' },
]);
}
});
});
});

View File

@@ -4,33 +4,35 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { promises as fs } from 'fs';
import path from 'path';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import toml from '@iarna/toml';
import { glob } from 'glob';
import { z } from 'zod';
import {
Config,
getProjectCommandsDir,
getUserCommandsDir,
} from '@qwen-code/qwen-code-core';
import { ICommandLoader } from './types.js';
import {
import type { Config } from '@qwen-code/qwen-code-core';
import { Storage } from '@qwen-code/qwen-code-core';
import type { ICommandLoader } from './types.js';
import type {
CommandContext,
CommandKind,
SlashCommand,
SlashCommandActionReturn,
} from '../ui/commands/types.js';
import { CommandKind } from '../ui/commands/types.js';
import { DefaultArgumentProcessor } from './prompt-processors/argumentProcessor.js';
import {
import type {
IPromptProcessor,
PromptPipelineContent,
} from './prompt-processors/types.js';
import {
SHORTHAND_ARGS_PLACEHOLDER,
SHELL_INJECTION_TRIGGER,
AT_FILE_INJECTION_TRIGGER,
} from './prompt-processors/types.js';
import {
ConfirmationRequiredError,
ShellProcessor,
} from './prompt-processors/shellProcessor.js';
import { AtFileProcessor } from './prompt-processors/atFileProcessor.js';
interface CommandDirectory {
path: string;
@@ -130,11 +132,13 @@ export class FileCommandLoader implements ICommandLoader {
private getCommandDirectories(): CommandDirectory[] {
const dirs: CommandDirectory[] = [];
const storage = this.config?.storage ?? new Storage(this.projectRoot);
// 1. User commands
dirs.push({ path: getUserCommandsDir() });
dirs.push({ path: Storage.getUserCommandsDir() });
// 2. Project commands (override user commands)
dirs.push({ path: getProjectCommandsDir(this.projectRoot) });
dirs.push({ path: storage.getProjectCommandsDir() });
// 3. Extension commands (processed last to detect all conflicts)
if (this.config) {
@@ -225,16 +229,25 @@ export class FileCommandLoader implements ICommandLoader {
const usesShellInjection = validDef.prompt.includes(
SHELL_INJECTION_TRIGGER,
);
const usesAtFileInjection = validDef.prompt.includes(
AT_FILE_INJECTION_TRIGGER,
);
// Interpolation (Shell Execution and Argument Injection)
// If the prompt uses either shell injection OR argument placeholders,
// we must use the ShellProcessor.
// 1. @-File Injection (Security First).
// This runs first to ensure we're not executing shell commands that
// could dynamically generate malicious @-paths.
if (usesAtFileInjection) {
processors.push(new AtFileProcessor(baseCommandName));
}
// 2. Argument and Shell Injection.
// This runs after file content has been safely injected.
if (usesShellInjection || usesArgs) {
processors.push(new ShellProcessor(baseCommandName));
}
// Default Argument Handling
// If NO explicit argument injection ({{args}}) was used, we append the raw invocation.
// 3. Default Argument Handling.
// Appends the raw invocation if no explicit {{args}} are used.
if (!usesArgs) {
processors.push(new DefaultArgumentProcessor());
}
@@ -254,19 +267,24 @@ export class FileCommandLoader implements ICommandLoader {
);
return {
type: 'submit_prompt',
content: validDef.prompt, // Fallback to unprocessed prompt
content: [{ text: validDef.prompt }], // Fallback to unprocessed prompt
};
}
try {
let processedPrompt = validDef.prompt;
let processedContent: PromptPipelineContent = [
{ text: validDef.prompt },
];
for (const processor of processors) {
processedPrompt = await processor.process(processedPrompt, context);
processedContent = await processor.process(
processedContent,
context,
);
}
return {
type: 'submit_prompt',
content: processedPrompt,
content: processedContent,
};
} catch (e) {
// Check if it's our specific error type

View File

@@ -0,0 +1,128 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { McpPromptLoader } from './McpPromptLoader.js';
import type { Config } from '@qwen-code/qwen-code-core';
import type { PromptArgument } from '@modelcontextprotocol/sdk/types.js';
import { describe, it, expect } from 'vitest';
describe('McpPromptLoader', () => {
const mockConfig = {} as Config;
describe('parseArgs', () => {
it('should handle multi-word positional arguments', () => {
const loader = new McpPromptLoader(mockConfig);
const promptArgs: PromptArgument[] = [
{ name: 'arg1', required: true },
{ name: 'arg2', required: true },
];
const userArgs = 'hello world';
const result = loader.parseArgs(userArgs, promptArgs);
expect(result).toEqual({ arg1: 'hello', arg2: 'world' });
});
it('should handle quoted multi-word positional arguments', () => {
const loader = new McpPromptLoader(mockConfig);
const promptArgs: PromptArgument[] = [
{ name: 'arg1', required: true },
{ name: 'arg2', required: true },
];
const userArgs = '"hello world" foo';
const result = loader.parseArgs(userArgs, promptArgs);
expect(result).toEqual({ arg1: 'hello world', arg2: 'foo' });
});
it('should handle a single positional argument with multiple words', () => {
const loader = new McpPromptLoader(mockConfig);
const promptArgs: PromptArgument[] = [{ name: 'arg1', required: true }];
const userArgs = 'hello world';
const result = loader.parseArgs(userArgs, promptArgs);
expect(result).toEqual({ arg1: 'hello world' });
});
it('should handle escaped quotes in positional arguments', () => {
const loader = new McpPromptLoader(mockConfig);
const promptArgs: PromptArgument[] = [{ name: 'arg1', required: true }];
const userArgs = '"hello \\"world\\""';
const result = loader.parseArgs(userArgs, promptArgs);
expect(result).toEqual({ arg1: 'hello "world"' });
});
it('should handle escaped backslashes in positional arguments', () => {
const loader = new McpPromptLoader(mockConfig);
const promptArgs: PromptArgument[] = [{ name: 'arg1', required: true }];
const userArgs = '"hello\\\\world"';
const result = loader.parseArgs(userArgs, promptArgs);
expect(result).toEqual({ arg1: 'hello\\world' });
});
it('should handle named args followed by positional args', () => {
const loader = new McpPromptLoader(mockConfig);
const promptArgs: PromptArgument[] = [
{ name: 'named', required: true },
{ name: 'pos', required: true },
];
const userArgs = '--named="value" positional';
const result = loader.parseArgs(userArgs, promptArgs);
expect(result).toEqual({ named: 'value', pos: 'positional' });
});
it('should handle positional args followed by named args', () => {
const loader = new McpPromptLoader(mockConfig);
const promptArgs: PromptArgument[] = [
{ name: 'pos', required: true },
{ name: 'named', required: true },
];
const userArgs = 'positional --named="value"';
const result = loader.parseArgs(userArgs, promptArgs);
expect(result).toEqual({ pos: 'positional', named: 'value' });
});
it('should handle positional args interspersed with named args', () => {
const loader = new McpPromptLoader(mockConfig);
const promptArgs: PromptArgument[] = [
{ name: 'pos1', required: true },
{ name: 'named', required: true },
{ name: 'pos2', required: true },
];
const userArgs = 'p1 --named="value" p2';
const result = loader.parseArgs(userArgs, promptArgs);
expect(result).toEqual({ pos1: 'p1', named: 'value', pos2: 'p2' });
});
it('should treat an escaped quote at the start as a literal', () => {
const loader = new McpPromptLoader(mockConfig);
const promptArgs: PromptArgument[] = [
{ name: 'arg1', required: true },
{ name: 'arg2', required: true },
];
const userArgs = '\\"hello world';
const result = loader.parseArgs(userArgs, promptArgs);
expect(result).toEqual({ arg1: '"hello', arg2: 'world' });
});
it('should handle a complex mix of args', () => {
const loader = new McpPromptLoader(mockConfig);
const promptArgs: PromptArgument[] = [
{ name: 'pos1', required: true },
{ name: 'named1', required: true },
{ name: 'pos2', required: true },
{ name: 'named2', required: true },
{ name: 'pos3', required: true },
];
const userArgs =
'p1 --named1="value 1" "p2 has spaces" --named2=value2 "p3 \\"with quotes\\""';
const result = loader.parseArgs(userArgs, promptArgs);
expect(result).toEqual({
pos1: 'p1',
named1: 'value 1',
pos2: 'p2 has spaces',
named2: 'value2',
pos3: 'p3 "with quotes"',
});
});
});
});

View File

@@ -4,19 +4,19 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { Config } from '@qwen-code/qwen-code-core';
import {
Config,
getErrorMessage,
getMCPServerPrompts,
} from '@qwen-code/qwen-code-core';
import {
import type {
CommandContext,
CommandKind,
SlashCommand,
SlashCommandActionReturn,
} from '../ui/commands/types.js';
import { ICommandLoader } from './types.js';
import { PromptArgument } from '@modelcontextprotocol/sdk/types.js';
import { CommandKind } from '../ui/commands/types.js';
import type { ICommandLoader } from './types.js';
import type { PromptArgument } from '@modelcontextprotocol/sdk/types.js';
/**
* Discovers and loads executable slash commands from prompts exposed by
@@ -169,7 +169,16 @@ export class McpPromptLoader implements ICommandLoader {
return Promise.resolve(promptCommands);
}
private parseArgs(
/**
* Parses the `userArgs` string representing the prompt arguments (all the text
* after the command) into a record matching the shape of the `promptArgs`.
*
* @param userArgs
* @param promptArgs
* @returns A record of the parsed arguments
* @visibleForTesting
*/
parseArgs(
userArgs: string,
promptArgs: PromptArgument[] | undefined,
): Record<string, unknown> | Error {
@@ -177,28 +186,36 @@ export class McpPromptLoader implements ICommandLoader {
const promptInputs: Record<string, unknown> = {};
// arg parsing: --key="value" or --key=value
const namedArgRegex = /--([^=]+)=(?:"((?:\\.|[^"\\])*)"|([^ ]*))/g;
const namedArgRegex = /--([^=]+)=(?:"((?:\\.|[^"\\])*)"|([^ ]+))/g;
let match;
const remainingArgs: string[] = [];
let lastIndex = 0;
const positionalParts: string[] = [];
while ((match = namedArgRegex.exec(userArgs)) !== null) {
const key = match[1];
const value = match[2] ?? match[3]; // Quoted or unquoted value
// Extract the quoted or unquoted argument and remove escape chars.
const value = (match[2] ?? match[3]).replace(/\\(.)/g, '$1');
argValues[key] = value;
// Capture text between matches as potential positional args
if (match.index > lastIndex) {
remainingArgs.push(userArgs.substring(lastIndex, match.index).trim());
positionalParts.push(userArgs.substring(lastIndex, match.index));
}
lastIndex = namedArgRegex.lastIndex;
}
// Capture any remaining text after the last named arg
if (lastIndex < userArgs.length) {
remainingArgs.push(userArgs.substring(lastIndex).trim());
positionalParts.push(userArgs.substring(lastIndex));
}
const positionalArgs = remainingArgs.join(' ').split(/ +/);
const positionalArgsString = positionalParts.join('').trim();
// extracts either quoted strings or non-quoted sequences of non-space characters.
const positionalArgRegex = /(?:"((?:\\.|[^"\\])*)"|([^ ]+))/g;
const positionalArgs: string[] = [];
while ((match = positionalArgRegex.exec(positionalArgsString)) !== null) {
// Extract the quoted or unquoted argument and remove escape chars.
positionalArgs.push((match[1] ?? match[2]).replace(/\\(.)/g, '$1'));
}
if (!promptArgs) {
return promptInputs;
@@ -213,19 +230,27 @@ export class McpPromptLoader implements ICommandLoader {
(arg) => arg.required && !promptInputs[arg.name],
);
const missingArgs: string[] = [];
for (let i = 0; i < unfilledArgs.length; i++) {
if (positionalArgs.length > i && positionalArgs[i]) {
promptInputs[unfilledArgs[i].name] = positionalArgs[i];
} else {
missingArgs.push(unfilledArgs[i].name);
if (unfilledArgs.length === 1) {
// If we have only one unfilled arg, we don't require quotes we just
// join all the given arguments together as if they were quoted.
promptInputs[unfilledArgs[0].name] = positionalArgs.join(' ');
} else {
const missingArgs: string[] = [];
for (let i = 0; i < unfilledArgs.length; i++) {
if (positionalArgs.length > i) {
promptInputs[unfilledArgs[i].name] = positionalArgs[i];
} else {
missingArgs.push(unfilledArgs[i].name);
}
}
if (missingArgs.length > 0) {
const missingArgNames = missingArgs
.map((name) => `--${name}`)
.join(', ');
return new Error(`Missing required argument(s): ${missingArgNames}`);
}
}
if (missingArgs.length > 0) {
const missingArgNames = missingArgs.map((name) => `--${name}`).join(', ');
return new Error(`Missing required argument(s): ${missingArgNames}`);
}
return promptInputs;
}
}

View File

@@ -13,7 +13,7 @@ describe('Argument Processors', () => {
const processor = new DefaultArgumentProcessor();
it('should append the full command if args are provided', async () => {
const prompt = 'Parse the command.';
const prompt = [{ text: 'Parse the command.' }];
const context = createMockCommandContext({
invocation: {
raw: '/mycommand arg1 "arg two"',
@@ -22,11 +22,13 @@ describe('Argument Processors', () => {
},
});
const result = await processor.process(prompt, context);
expect(result).toBe('Parse the command.\n\n/mycommand arg1 "arg two"');
expect(result).toEqual([
{ text: 'Parse the command.\n\n/mycommand arg1 "arg two"' },
]);
});
it('should NOT append the full command if no args are provided', async () => {
const prompt = 'Parse the command.';
const prompt = [{ text: 'Parse the command.' }];
const context = createMockCommandContext({
invocation: {
raw: '/mycommand',
@@ -35,7 +37,7 @@ describe('Argument Processors', () => {
},
});
const result = await processor.process(prompt, context);
expect(result).toBe('Parse the command.');
expect(result).toEqual([{ text: 'Parse the command.' }]);
});
});
});

View File

@@ -4,8 +4,9 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { IPromptProcessor } from './types.js';
import { CommandContext } from '../../ui/commands/types.js';
import { appendToLastTextPart } from '@qwen-code/qwen-code-core';
import type { IPromptProcessor, PromptPipelineContent } from './types.js';
import type { CommandContext } from '../../ui/commands/types.js';
/**
* Appends the user's full command invocation to the prompt if arguments are
@@ -14,9 +15,12 @@ import { CommandContext } from '../../ui/commands/types.js';
* This processor is only used if the prompt does NOT contain {{args}}.
*/
export class DefaultArgumentProcessor implements IPromptProcessor {
async process(prompt: string, context: CommandContext): Promise<string> {
if (context.invocation!.args) {
return `${prompt}\n\n${context.invocation!.raw}`;
async process(
prompt: PromptPipelineContent,
context: CommandContext,
): Promise<PromptPipelineContent> {
if (context.invocation?.args) {
return appendToLastTextPart(prompt, context.invocation.raw);
}
return prompt;
}

View File

@@ -0,0 +1,221 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { type CommandContext } from '../../ui/commands/types.js';
import { AtFileProcessor } from './atFileProcessor.js';
import { MessageType } from '../../ui/types.js';
import type { Config } from '@qwen-code/qwen-code-core';
import type { PartUnion } from '@google/genai';
// Mock the core dependency
const mockReadPathFromWorkspace = vi.hoisted(() => vi.fn());
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const original = await importOriginal<object>();
return {
...original,
readPathFromWorkspace: mockReadPathFromWorkspace,
};
});
describe('AtFileProcessor', () => {
let context: CommandContext;
let mockConfig: Config;
beforeEach(() => {
vi.clearAllMocks();
mockConfig = {
// The processor only passes the config through, so we don't need a full mock.
} as unknown as Config;
context = createMockCommandContext({
services: {
config: mockConfig,
},
});
// Default mock success behavior: return content wrapped in a text part.
mockReadPathFromWorkspace.mockImplementation(
async (path: string): Promise<PartUnion[]> => [
{ text: `content of ${path}` },
],
);
});
it('should not change the prompt if no @{ trigger is present', async () => {
const processor = new AtFileProcessor();
const prompt: PartUnion[] = [{ text: 'This is a simple prompt.' }];
const result = await processor.process(prompt, context);
expect(result).toEqual(prompt);
expect(mockReadPathFromWorkspace).not.toHaveBeenCalled();
});
it('should not change the prompt if config service is missing', async () => {
const processor = new AtFileProcessor();
const prompt: PartUnion[] = [{ text: 'Analyze @{file.txt}' }];
const contextWithoutConfig = createMockCommandContext({
services: {
config: null,
},
});
const result = await processor.process(prompt, contextWithoutConfig);
expect(result).toEqual(prompt);
expect(mockReadPathFromWorkspace).not.toHaveBeenCalled();
});
describe('Parsing Logic', () => {
it('should replace a single valid @{path/to/file.txt} placeholder', async () => {
const processor = new AtFileProcessor();
const prompt: PartUnion[] = [
{ text: 'Analyze this file: @{path/to/file.txt}' },
];
const result = await processor.process(prompt, context);
expect(mockReadPathFromWorkspace).toHaveBeenCalledWith(
'path/to/file.txt',
mockConfig,
);
expect(result).toEqual([
{ text: 'Analyze this file: ' },
{ text: 'content of path/to/file.txt' },
]);
});
it('should replace multiple different @{...} placeholders', async () => {
const processor = new AtFileProcessor();
const prompt: PartUnion[] = [
{ text: 'Compare @{file1.js} with @{file2.js}' },
];
const result = await processor.process(prompt, context);
expect(mockReadPathFromWorkspace).toHaveBeenCalledTimes(2);
expect(mockReadPathFromWorkspace).toHaveBeenCalledWith(
'file1.js',
mockConfig,
);
expect(mockReadPathFromWorkspace).toHaveBeenCalledWith(
'file2.js',
mockConfig,
);
expect(result).toEqual([
{ text: 'Compare ' },
{ text: 'content of file1.js' },
{ text: ' with ' },
{ text: 'content of file2.js' },
]);
});
it('should handle placeholders at the beginning, middle, and end', async () => {
const processor = new AtFileProcessor();
const prompt: PartUnion[] = [
{ text: '@{start.txt} in the @{middle.txt} and @{end.txt}' },
];
const result = await processor.process(prompt, context);
expect(result).toEqual([
{ text: 'content of start.txt' },
{ text: ' in the ' },
{ text: 'content of middle.txt' },
{ text: ' and ' },
{ text: 'content of end.txt' },
]);
});
it('should correctly parse paths that contain balanced braces', async () => {
const processor = new AtFileProcessor();
const prompt: PartUnion[] = [
{ text: 'Analyze @{path/with/{braces}/file.txt}' },
];
const result = await processor.process(prompt, context);
expect(mockReadPathFromWorkspace).toHaveBeenCalledWith(
'path/with/{braces}/file.txt',
mockConfig,
);
expect(result).toEqual([
{ text: 'Analyze ' },
{ text: 'content of path/with/{braces}/file.txt' },
]);
});
it('should throw an error if the prompt contains an unclosed trigger', async () => {
const processor = new AtFileProcessor();
const prompt: PartUnion[] = [{ text: 'Hello @{world' }];
// The new parser throws an error for unclosed injections.
await expect(processor.process(prompt, context)).rejects.toThrow(
/Unclosed injection/,
);
});
});
describe('Integration and Error Handling', () => {
it('should leave the placeholder unmodified if readPathFromWorkspace throws', async () => {
const processor = new AtFileProcessor();
const prompt: PartUnion[] = [
{ text: 'Analyze @{not-found.txt} and @{good-file.txt}' },
];
mockReadPathFromWorkspace.mockImplementation(async (path: string) => {
if (path === 'not-found.txt') {
throw new Error('File not found');
}
return [{ text: `content of ${path}` }];
});
const result = await processor.process(prompt, context);
expect(result).toEqual([
{ text: 'Analyze ' },
{ text: '@{not-found.txt}' }, // Placeholder is preserved as a text part
{ text: ' and ' },
{ text: 'content of good-file.txt' },
]);
});
});
describe('UI Feedback', () => {
it('should call ui.addItem with an ERROR on failure', async () => {
const processor = new AtFileProcessor();
const prompt: PartUnion[] = [{ text: 'Analyze @{bad-file.txt}' }];
mockReadPathFromWorkspace.mockRejectedValue(new Error('Access denied'));
await processor.process(prompt, context);
expect(context.ui.addItem).toHaveBeenCalledTimes(1);
expect(context.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: "Failed to inject content for '@{bad-file.txt}': Access denied",
},
expect.any(Number),
);
});
it('should call ui.addItem with a WARNING if the file was ignored', async () => {
const processor = new AtFileProcessor();
const prompt: PartUnion[] = [{ text: 'Analyze @{ignored.txt}' }];
// Simulate an ignored file by returning an empty array.
mockReadPathFromWorkspace.mockResolvedValue([]);
const result = await processor.process(prompt, context);
// The placeholder should be removed, resulting in only the prefix.
expect(result).toEqual([{ text: 'Analyze ' }]);
expect(context.ui.addItem).toHaveBeenCalledTimes(1);
expect(context.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: "File '@{ignored.txt}' was ignored by .gitignore or .geminiignore and was not included in the prompt.",
},
expect.any(Number),
);
});
it('should NOT call ui.addItem on success', async () => {
const processor = new AtFileProcessor();
const prompt: PartUnion[] = [{ text: 'Analyze @{good-file.txt}' }];
await processor.process(prompt, context);
expect(context.ui.addItem).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,96 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
flatMapTextParts,
readPathFromWorkspace,
} from '@qwen-code/qwen-code-core';
import type { CommandContext } from '../../ui/commands/types.js';
import { MessageType } from '../../ui/types.js';
import {
AT_FILE_INJECTION_TRIGGER,
type IPromptProcessor,
type PromptPipelineContent,
} from './types.js';
import { extractInjections } from './injectionParser.js';
export class AtFileProcessor implements IPromptProcessor {
constructor(private readonly commandName?: string) {}
async process(
input: PromptPipelineContent,
context: CommandContext,
): Promise<PromptPipelineContent> {
const config = context.services.config;
if (!config) {
return input;
}
return flatMapTextParts(input, async (text) => {
if (!text.includes(AT_FILE_INJECTION_TRIGGER)) {
return [{ text }];
}
const injections = extractInjections(
text,
AT_FILE_INJECTION_TRIGGER,
this.commandName,
);
if (injections.length === 0) {
return [{ text }];
}
const output: PromptPipelineContent = [];
let lastIndex = 0;
for (const injection of injections) {
const prefix = text.substring(lastIndex, injection.startIndex);
if (prefix) {
output.push({ text: prefix });
}
const pathStr = injection.content;
try {
const fileContentParts = await readPathFromWorkspace(pathStr, config);
if (fileContentParts.length === 0) {
const uiMessage = `File '@{${pathStr}}' was ignored by .gitignore or .geminiignore and was not included in the prompt.`;
context.ui.addItem(
{ type: MessageType.INFO, text: uiMessage },
Date.now(),
);
}
output.push(...fileContentParts);
} catch (error) {
const message =
error instanceof Error ? error.message : String(error);
const uiMessage = `Failed to inject content for '@{${pathStr}}': ${message}`;
console.error(
`[AtFileProcessor] ${uiMessage}. Leaving placeholder in prompt.`,
);
context.ui.addItem(
{ type: MessageType.ERROR, text: uiMessage },
Date.now(),
);
const placeholder = text.substring(
injection.startIndex,
injection.endIndex,
);
output.push({ text: placeholder });
}
lastIndex = injection.endIndex;
}
const suffix = text.substring(lastIndex);
if (suffix) {
output.push({ text: suffix });
}
return output;
});
}
}

View File

@@ -0,0 +1,223 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { extractInjections } from './injectionParser.js';
describe('extractInjections', () => {
const SHELL_TRIGGER = '!{';
const AT_FILE_TRIGGER = '@{';
describe('Basic Functionality', () => {
it('should return an empty array if no trigger is present', () => {
const prompt = 'This is a simple prompt without injections.';
const result = extractInjections(prompt, SHELL_TRIGGER);
expect(result).toEqual([]);
});
it('should extract a single, simple injection', () => {
const prompt = 'Run this command: !{ls -la}';
const result = extractInjections(prompt, SHELL_TRIGGER);
expect(result).toEqual([
{
content: 'ls -la',
startIndex: 18,
endIndex: 27,
},
]);
});
it('should extract multiple injections', () => {
const prompt = 'First: !{cmd1}, Second: !{cmd2}';
const result = extractInjections(prompt, SHELL_TRIGGER);
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
content: 'cmd1',
startIndex: 7,
endIndex: 14,
});
expect(result[1]).toEqual({
content: 'cmd2',
startIndex: 24,
endIndex: 31,
});
});
it('should handle different triggers (e.g., @{)', () => {
const prompt = 'Read this file: @{path/to/file.txt}';
const result = extractInjections(prompt, AT_FILE_TRIGGER);
expect(result).toEqual([
{
content: 'path/to/file.txt',
startIndex: 16,
endIndex: 35,
},
]);
});
});
describe('Positioning and Edge Cases', () => {
it('should handle injections at the start and end of the prompt', () => {
const prompt = '!{start} middle text !{end}';
const result = extractInjections(prompt, SHELL_TRIGGER);
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
content: 'start',
startIndex: 0,
endIndex: 8,
});
expect(result[1]).toEqual({
content: 'end',
startIndex: 21,
endIndex: 27,
});
});
it('should handle adjacent injections', () => {
const prompt = '!{A}!{B}';
const result = extractInjections(prompt, SHELL_TRIGGER);
expect(result).toHaveLength(2);
expect(result[0]).toEqual({ content: 'A', startIndex: 0, endIndex: 4 });
expect(result[1]).toEqual({ content: 'B', startIndex: 4, endIndex: 8 });
});
it('should handle empty injections', () => {
const prompt = 'Empty: !{}';
const result = extractInjections(prompt, SHELL_TRIGGER);
expect(result).toEqual([
{
content: '',
startIndex: 7,
endIndex: 10,
},
]);
});
it('should trim whitespace within the content', () => {
const prompt = '!{ \n command with space \t }';
const result = extractInjections(prompt, SHELL_TRIGGER);
expect(result).toEqual([
{
content: 'command with space',
startIndex: 0,
endIndex: 29,
},
]);
});
it('should ignore similar patterns that are not the exact trigger', () => {
const prompt = 'Not a trigger: !(cmd) or {cmd} or ! {cmd}';
const result = extractInjections(prompt, SHELL_TRIGGER);
expect(result).toEqual([]);
});
it('should ignore extra closing braces before the trigger', () => {
const prompt = 'Ignore this } then !{run}';
const result = extractInjections(prompt, SHELL_TRIGGER);
expect(result).toEqual([
{
content: 'run',
startIndex: 19,
endIndex: 25,
},
]);
});
it('should stop parsing at the first balanced closing brace (non-greedy)', () => {
// This tests that the parser doesn't greedily consume extra closing braces
const prompt = 'Run !{ls -l}} extra braces';
const result = extractInjections(prompt, SHELL_TRIGGER);
expect(result).toEqual([
{
content: 'ls -l',
startIndex: 4,
endIndex: 12,
},
]);
});
});
describe('Nested Braces (Balanced)', () => {
it('should correctly parse content with simple nested braces (e.g., JSON)', () => {
const prompt = `Send JSON: !{curl -d '{"key": "value"}'}`;
const result = extractInjections(prompt, SHELL_TRIGGER);
expect(result).toHaveLength(1);
expect(result[0].content).toBe(`curl -d '{"key": "value"}'`);
});
it('should correctly parse content with shell constructs (e.g., awk)', () => {
const prompt = `Process text: !{awk '{print $1}' file.txt}`;
const result = extractInjections(prompt, SHELL_TRIGGER);
expect(result).toHaveLength(1);
expect(result[0].content).toBe(`awk '{print $1}' file.txt`);
});
it('should correctly parse multiple levels of nesting', () => {
const prompt = `!{level1 {level2 {level3}} suffix}`;
const result = extractInjections(prompt, SHELL_TRIGGER);
expect(result).toHaveLength(1);
expect(result[0].content).toBe(`level1 {level2 {level3}} suffix`);
expect(result[0].endIndex).toBe(prompt.length);
});
it('should correctly parse paths containing balanced braces', () => {
const prompt = 'Analyze @{path/with/{braces}/file.txt}';
const result = extractInjections(prompt, AT_FILE_TRIGGER);
expect(result).toHaveLength(1);
expect(result[0].content).toBe('path/with/{braces}/file.txt');
});
it('should correctly handle an injection containing the trigger itself', () => {
// This works because the parser counts braces, it doesn't look for the trigger again until the current one is closed.
const prompt = '!{echo "The trigger is !{ confusing }"}';
const expectedContent = 'echo "The trigger is !{ confusing }"';
const result = extractInjections(prompt, SHELL_TRIGGER);
expect(result).toHaveLength(1);
expect(result[0].content).toBe(expectedContent);
});
});
describe('Error Handling (Unbalanced/Unclosed)', () => {
it('should throw an error for a simple unclosed injection', () => {
const prompt = 'This prompt has !{an unclosed trigger';
expect(() => extractInjections(prompt, SHELL_TRIGGER)).toThrow(
/Invalid syntax: Unclosed injection starting at index 16 \('!{'\)/,
);
});
it('should throw an error if the prompt ends inside a nested block', () => {
const prompt = 'This fails: !{outer {inner';
expect(() => extractInjections(prompt, SHELL_TRIGGER)).toThrow(
/Invalid syntax: Unclosed injection starting at index 12 \('!{'\)/,
);
});
it('should include the context name in the error message if provided', () => {
const prompt = 'Failing !{command';
const contextName = 'test-command';
expect(() =>
extractInjections(prompt, SHELL_TRIGGER, contextName),
).toThrow(
/Invalid syntax in command 'test-command': Unclosed injection starting at index 8/,
);
});
it('should throw if content contains unbalanced braces (e.g., missing closing)', () => {
// This is functionally the same as an unclosed injection from the parser's perspective.
const prompt = 'Analyze @{path/with/braces{example.txt}';
expect(() => extractInjections(prompt, AT_FILE_TRIGGER)).toThrow(
/Invalid syntax: Unclosed injection starting at index 8 \('@{'\)/,
);
});
it('should clearly state that unbalanced braces in content are not supported in the error', () => {
const prompt = 'Analyze @{path/with/braces{example.txt}';
expect(() => extractInjections(prompt, AT_FILE_TRIGGER)).toThrow(
/Paths or commands with unbalanced braces are not supported directly/,
);
});
});
});

View File

@@ -0,0 +1,89 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Represents a single detected injection site in a prompt string.
*/
export interface Injection {
/** The content extracted from within the braces (e.g., the command or path), trimmed. */
content: string;
/** The starting index of the injection (inclusive, points to the start of the trigger). */
startIndex: number;
/** The ending index of the injection (exclusive, points after the closing '}'). */
endIndex: number;
}
/**
* Iteratively parses a prompt string to extract injections (e.g., !{...} or @{...}),
* correctly handling nested braces within the content.
*
* This parser relies on simple brace counting and does not support escaping.
*
* @param prompt The prompt string to parse.
* @param trigger The opening trigger sequence (e.g., '!{', '@{').
* @param contextName Optional context name (e.g., command name) for error messages.
* @returns An array of extracted Injection objects.
* @throws Error if an unclosed injection is found.
*/
export function extractInjections(
prompt: string,
trigger: string,
contextName?: string,
): Injection[] {
const injections: Injection[] = [];
let index = 0;
while (index < prompt.length) {
const startIndex = prompt.indexOf(trigger, index);
if (startIndex === -1) {
break;
}
let currentIndex = startIndex + trigger.length;
let braceCount = 1;
let foundEnd = false;
while (currentIndex < prompt.length) {
const char = prompt[currentIndex];
if (char === '{') {
braceCount++;
} else if (char === '}') {
braceCount--;
if (braceCount === 0) {
const injectionContent = prompt.substring(
startIndex + trigger.length,
currentIndex,
);
const endIndex = currentIndex + 1;
injections.push({
content: injectionContent.trim(),
startIndex,
endIndex,
});
index = endIndex;
foundEnd = true;
break;
}
}
currentIndex++;
}
// Check if the inner loop finished without finding the closing brace.
if (!foundEnd) {
const contextInfo = contextName ? ` in command '${contextName}'` : '';
// Enforce strict parsing (Comment 1) and clarify limitations (Comment 2).
throw new Error(
`Invalid syntax${contextInfo}: Unclosed injection starting at index ${startIndex} ('${trigger}'). Ensure braces are balanced. Paths or commands with unbalanced braces are not supported directly.`,
);
}
}
return injections;
}

View File

@@ -7,14 +7,16 @@
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
import { ConfirmationRequiredError, ShellProcessor } from './shellProcessor.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { CommandContext } from '../../ui/commands/types.js';
import { ApprovalMode, Config } from '@qwen-code/qwen-code-core';
import os from 'os';
import type { CommandContext } from '../../ui/commands/types.js';
import type { Config } from '@qwen-code/qwen-code-core';
import { ApprovalMode } from '@qwen-code/qwen-code-core';
import os from 'node:os';
import { quote } from 'shell-quote';
import { createPartFromText } from '@google/genai';
import type { PromptPipelineContent } from './types.js';
// Helper function to determine the expected escaped string based on the current OS,
// mirroring the logic in the actual `escapeShellArg` implementation. This makes
// our tests robust and platform-agnostic.
// mirroring the logic in the actual `escapeShellArg` implementation.
function getExpectedEscapedArgForPlatform(arg: string): string {
if (os.platform() === 'win32') {
const comSpec = (process.env['ComSpec'] || 'cmd.exe').toLowerCase();
@@ -31,6 +33,11 @@ function getExpectedEscapedArgForPlatform(arg: string): string {
}
}
// Helper to create PromptPipelineContent
function createPromptPipelineContent(text: string): PromptPipelineContent {
return [createPartFromText(text)];
}
const mockCheckCommandPermissions = vi.hoisted(() => vi.fn());
const mockShellExecute = vi.hoisted(() => vi.fn());
@@ -92,7 +99,7 @@ describe('ShellProcessor', () => {
it('should throw an error if config is missing', async () => {
const processor = new ShellProcessor('test-command');
const prompt = '!{ls}';
const prompt: PromptPipelineContent = createPromptPipelineContent('!{ls}');
const contextWithoutConfig = createMockCommandContext({
services: {
config: null,
@@ -106,15 +113,19 @@ describe('ShellProcessor', () => {
it('should not change the prompt if no shell injections are present', async () => {
const processor = new ShellProcessor('test-command');
const prompt = 'This is a simple prompt with no injections.';
const prompt: PromptPipelineContent = createPromptPipelineContent(
'This is a simple prompt with no injections.',
);
const result = await processor.process(prompt, context);
expect(result).toBe(prompt);
expect(result).toEqual(prompt);
expect(mockShellExecute).not.toHaveBeenCalled();
});
it('should process a single valid shell injection if allowed', async () => {
const processor = new ShellProcessor('test-command');
const prompt = 'The current status is: !{git status}';
const prompt: PromptPipelineContent = createPromptPipelineContent(
'The current status is: !{git status}',
);
mockCheckCommandPermissions.mockReturnValue({
allAllowed: true,
disallowedCommands: [],
@@ -137,12 +148,14 @@ describe('ShellProcessor', () => {
expect.any(Object),
false,
);
expect(result).toBe('The current status is: On branch main');
expect(result).toEqual([{ text: 'The current status is: On branch main' }]);
});
it('should process multiple valid shell injections if all are allowed', async () => {
const processor = new ShellProcessor('test-command');
const prompt = '!{git status} in !{pwd}';
const prompt: PromptPipelineContent = createPromptPipelineContent(
'!{git status} in !{pwd}',
);
mockCheckCommandPermissions.mockReturnValue({
allAllowed: true,
disallowedCommands: [],
@@ -163,12 +176,14 @@ describe('ShellProcessor', () => {
expect(mockCheckCommandPermissions).toHaveBeenCalledTimes(2);
expect(mockShellExecute).toHaveBeenCalledTimes(2);
expect(result).toBe('On branch main in /usr/home');
expect(result).toEqual([{ text: 'On branch main in /usr/home' }]);
});
it('should throw ConfirmationRequiredError if a command is not allowed in default mode', async () => {
const processor = new ShellProcessor('test-command');
const prompt = 'Do something dangerous: !{rm -rf /}';
const prompt: PromptPipelineContent = createPromptPipelineContent(
'Do something dangerous: !{rm -rf /}',
);
mockCheckCommandPermissions.mockReturnValue({
allAllowed: false,
disallowedCommands: ['rm -rf /'],
@@ -181,7 +196,9 @@ describe('ShellProcessor', () => {
it('should NOT throw ConfirmationRequiredError if a command is not allowed but approval mode is YOLO', async () => {
const processor = new ShellProcessor('test-command');
const prompt = 'Do something dangerous: !{rm -rf /}';
const prompt: PromptPipelineContent = createPromptPipelineContent(
'Do something dangerous: !{rm -rf /}',
);
mockCheckCommandPermissions.mockReturnValue({
allAllowed: false,
disallowedCommands: ['rm -rf /'],
@@ -202,12 +219,14 @@ describe('ShellProcessor', () => {
expect.any(Object),
false,
);
expect(result).toBe('Do something dangerous: deleted');
expect(result).toEqual([{ text: 'Do something dangerous: deleted' }]);
});
it('should still throw an error for a hard-denied command even in YOLO mode', async () => {
const processor = new ShellProcessor('test-command');
const prompt = 'Do something forbidden: !{reboot}';
const prompt: PromptPipelineContent = createPromptPipelineContent(
'Do something forbidden: !{reboot}',
);
mockCheckCommandPermissions.mockReturnValue({
allAllowed: false,
disallowedCommands: ['reboot'],
@@ -227,7 +246,9 @@ describe('ShellProcessor', () => {
it('should throw ConfirmationRequiredError with the correct command', async () => {
const processor = new ShellProcessor('test-command');
const prompt = 'Do something dangerous: !{rm -rf /}';
const prompt: PromptPipelineContent = createPromptPipelineContent(
'Do something dangerous: !{rm -rf /}',
);
mockCheckCommandPermissions.mockReturnValue({
allAllowed: false,
disallowedCommands: ['rm -rf /'],
@@ -249,7 +270,9 @@ describe('ShellProcessor', () => {
it('should throw ConfirmationRequiredError with multiple commands if multiple are disallowed', async () => {
const processor = new ShellProcessor('test-command');
const prompt = '!{cmd1} and !{cmd2}';
const prompt: PromptPipelineContent = createPromptPipelineContent(
'!{cmd1} and !{cmd2}',
);
mockCheckCommandPermissions.mockImplementation((cmd) => {
if (cmd === 'cmd1') {
return { allAllowed: false, disallowedCommands: ['cmd1'] };
@@ -274,7 +297,9 @@ describe('ShellProcessor', () => {
it('should not execute any commands if at least one requires confirmation', async () => {
const processor = new ShellProcessor('test-command');
const prompt = 'First: !{echo "hello"}, Second: !{rm -rf /}';
const prompt: PromptPipelineContent = createPromptPipelineContent(
'First: !{echo "hello"}, Second: !{rm -rf /}',
);
mockCheckCommandPermissions.mockImplementation((cmd) => {
if (cmd.includes('rm')) {
@@ -293,7 +318,9 @@ describe('ShellProcessor', () => {
it('should only request confirmation for disallowed commands in a mixed prompt', async () => {
const processor = new ShellProcessor('test-command');
const prompt = 'Allowed: !{ls -l}, Disallowed: !{rm -rf /}';
const prompt: PromptPipelineContent = createPromptPipelineContent(
'Allowed: !{ls -l}, Disallowed: !{rm -rf /}',
);
mockCheckCommandPermissions.mockImplementation((cmd) => ({
allAllowed: !cmd.includes('rm'),
@@ -313,7 +340,9 @@ describe('ShellProcessor', () => {
it('should execute all commands if they are on the session allowlist', async () => {
const processor = new ShellProcessor('test-command');
const prompt = 'Run !{cmd1} and !{cmd2}';
const prompt: PromptPipelineContent = createPromptPipelineContent(
'Run !{cmd1} and !{cmd2}',
);
// Add commands to the session allowlist
context.session.sessionShellAllowlist = new Set(['cmd1', 'cmd2']);
@@ -345,12 +374,14 @@ describe('ShellProcessor', () => {
context.session.sessionShellAllowlist,
);
expect(mockShellExecute).toHaveBeenCalledTimes(2);
expect(result).toBe('Run output1 and output2');
expect(result).toEqual([{ text: 'Run output1 and output2' }]);
});
it('should trim whitespace from the command inside the injection before interpolation', async () => {
const processor = new ShellProcessor('test-command');
const prompt = 'Files: !{ ls {{args}} -l }';
const prompt: PromptPipelineContent = createPromptPipelineContent(
'Files: !{ ls {{args}} -l }',
);
const rawArgs = context.invocation!.args;
@@ -384,7 +415,8 @@ describe('ShellProcessor', () => {
it('should handle an empty command inside the injection gracefully (skips execution)', async () => {
const processor = new ShellProcessor('test-command');
const prompt = 'This is weird: !{}';
const prompt: PromptPipelineContent =
createPromptPipelineContent('This is weird: !{}');
const result = await processor.process(prompt, context);
@@ -392,77 +424,14 @@ describe('ShellProcessor', () => {
expect(mockShellExecute).not.toHaveBeenCalled();
// It replaces !{} with an empty string.
expect(result).toBe('This is weird: ');
});
describe('Robust Parsing (Balanced Braces)', () => {
it('should correctly parse commands containing nested braces (e.g., awk)', async () => {
const processor = new ShellProcessor('test-command');
const command = "awk '{print $1}' file.txt";
const prompt = `Output: !{${command}}`;
mockShellExecute.mockReturnValue({
result: Promise.resolve({ ...SUCCESS_RESULT, output: 'result' }),
});
const result = await processor.process(prompt, context);
expect(mockCheckCommandPermissions).toHaveBeenCalledWith(
command,
expect.any(Object),
context.session.sessionShellAllowlist,
);
expect(mockShellExecute).toHaveBeenCalledWith(
command,
expect.any(String),
expect.any(Function),
expect.any(Object),
false,
);
expect(result).toBe('Output: result');
});
it('should handle deeply nested braces correctly', async () => {
const processor = new ShellProcessor('test-command');
const command = "echo '{{a},{b}}'";
const prompt = `!{${command}}`;
mockShellExecute.mockReturnValue({
result: Promise.resolve({ ...SUCCESS_RESULT, output: '{{a},{b}}' }),
});
const result = await processor.process(prompt, context);
expect(mockShellExecute).toHaveBeenCalledWith(
command,
expect.any(String),
expect.any(Function),
expect.any(Object),
false,
);
expect(result).toBe('{{a},{b}}');
});
it('should throw an error for unclosed shell injections', async () => {
const processor = new ShellProcessor('test-command');
const prompt = 'This prompt is broken: !{ls -l';
await expect(processor.process(prompt, context)).rejects.toThrow(
/Unclosed shell injection/,
);
});
it('should throw an error for unclosed nested braces', async () => {
const processor = new ShellProcessor('test-command');
const prompt = 'Broken: !{echo {a}';
await expect(processor.process(prompt, context)).rejects.toThrow(
/Unclosed shell injection/,
);
});
expect(result).toEqual([{ text: 'This is weird: ' }]);
});
describe('Error Reporting', () => {
it('should append exit code and command name on failure', async () => {
const processor = new ShellProcessor('test-command');
const prompt = '!{cmd}';
const prompt: PromptPipelineContent =
createPromptPipelineContent('!{cmd}');
mockShellExecute.mockReturnValue({
result: Promise.resolve({
...SUCCESS_RESULT,
@@ -474,14 +443,17 @@ describe('ShellProcessor', () => {
const result = await processor.process(prompt, context);
expect(result).toBe(
"some error output\n[Shell command 'cmd' exited with code 1]",
);
expect(result).toEqual([
{
text: "some error output\n[Shell command 'cmd' exited with code 1]",
},
]);
});
it('should append signal info and command name if terminated by signal', async () => {
const processor = new ShellProcessor('test-command');
const prompt = '!{cmd}';
const prompt: PromptPipelineContent =
createPromptPipelineContent('!{cmd}');
mockShellExecute.mockReturnValue({
result: Promise.resolve({
...SUCCESS_RESULT,
@@ -494,14 +466,17 @@ describe('ShellProcessor', () => {
const result = await processor.process(prompt, context);
expect(result).toBe(
"output\n[Shell command 'cmd' terminated by signal SIGTERM]",
);
expect(result).toEqual([
{
text: "output\n[Shell command 'cmd' terminated by signal SIGTERM]",
},
]);
});
it('should throw a detailed error if the shell fails to spawn', async () => {
const processor = new ShellProcessor('test-command');
const prompt = '!{bad-command}';
const prompt: PromptPipelineContent =
createPromptPipelineContent('!{bad-command}');
const spawnError = new Error('spawn EACCES');
mockShellExecute.mockReturnValue({
result: Promise.resolve({
@@ -521,7 +496,9 @@ describe('ShellProcessor', () => {
it('should report abort status with command name if aborted', async () => {
const processor = new ShellProcessor('test-command');
const prompt = '!{long-running-command}';
const prompt: PromptPipelineContent = createPromptPipelineContent(
'!{long-running-command}',
);
const spawnError = new Error('Aborted');
mockShellExecute.mockReturnValue({
result: Promise.resolve({
@@ -535,9 +512,11 @@ describe('ShellProcessor', () => {
});
const result = await processor.process(prompt, context);
expect(result).toBe(
"partial output\n[Shell command 'long-running-command' aborted]",
);
expect(result).toEqual([
{
text: "partial output\n[Shell command 'long-running-command' aborted]",
},
]);
});
});
@@ -551,29 +530,35 @@ describe('ShellProcessor', () => {
it('should perform raw replacement if no shell injections are present (optimization path)', async () => {
const processor = new ShellProcessor('test-command');
const prompt = 'The user said: {{args}}';
const prompt: PromptPipelineContent = createPromptPipelineContent(
'The user said: {{args}}',
);
const result = await processor.process(prompt, context);
expect(result).toBe(`The user said: ${rawArgs}`);
expect(result).toEqual([{ text: `The user said: ${rawArgs}` }]);
expect(mockShellExecute).not.toHaveBeenCalled();
});
it('should perform raw replacement outside !{} blocks', async () => {
const processor = new ShellProcessor('test-command');
const prompt = 'Outside: {{args}}. Inside: !{echo "hello"}';
const prompt: PromptPipelineContent = createPromptPipelineContent(
'Outside: {{args}}. Inside: !{echo "hello"}',
);
mockShellExecute.mockReturnValue({
result: Promise.resolve({ ...SUCCESS_RESULT, output: 'hello' }),
});
const result = await processor.process(prompt, context);
expect(result).toBe(`Outside: ${rawArgs}. Inside: hello`);
expect(result).toEqual([{ text: `Outside: ${rawArgs}. Inside: hello` }]);
});
it('should perform escaped replacement inside !{} blocks', async () => {
const processor = new ShellProcessor('test-command');
const prompt = 'Command: !{grep {{args}} file.txt}';
const prompt: PromptPipelineContent = createPromptPipelineContent(
'Command: !{grep {{args}} file.txt}',
);
mockShellExecute.mockReturnValue({
result: Promise.resolve({ ...SUCCESS_RESULT, output: 'match found' }),
});
@@ -591,12 +576,14 @@ describe('ShellProcessor', () => {
false,
);
expect(result).toBe('Command: match found');
expect(result).toEqual([{ text: 'Command: match found' }]);
});
it('should handle both raw (outside) and escaped (inside) injection simultaneously', async () => {
const processor = new ShellProcessor('test-command');
const prompt = 'User "({{args}})" requested search: !{search {{args}}}';
const prompt: PromptPipelineContent = createPromptPipelineContent(
'User "({{args}})" requested search: !{search {{args}}}',
);
mockShellExecute.mockReturnValue({
result: Promise.resolve({ ...SUCCESS_RESULT, output: 'results' }),
});
@@ -613,12 +600,15 @@ describe('ShellProcessor', () => {
false,
);
expect(result).toBe(`User "(${rawArgs})" requested search: results`);
expect(result).toEqual([
{ text: `User "(${rawArgs})" requested search: results` },
]);
});
it('should perform security checks on the final, resolved (escaped) command', async () => {
const processor = new ShellProcessor('test-command');
const prompt = '!{rm {{args}}}';
const prompt: PromptPipelineContent =
createPromptPipelineContent('!{rm {{args}}}');
const expectedEscapedArgs = getExpectedEscapedArgForPlatform(rawArgs);
const expectedResolvedCommand = `rm ${expectedEscapedArgs}`;
@@ -641,7 +631,8 @@ describe('ShellProcessor', () => {
it('should report the resolved command if a hard denial occurs', async () => {
const processor = new ShellProcessor('test-command');
const prompt = '!{rm {{args}}}';
const prompt: PromptPipelineContent =
createPromptPipelineContent('!{rm {{args}}}');
const expectedEscapedArgs = getExpectedEscapedArgForPlatform(rawArgs);
const expectedResolvedCommand = `rm ${expectedEscapedArgs}`;
mockCheckCommandPermissions.mockReturnValue({
@@ -661,7 +652,9 @@ describe('ShellProcessor', () => {
const processor = new ShellProcessor('test-command');
const multilineArgs = 'first line\nsecond line';
context.invocation!.args = multilineArgs;
const prompt = 'Commit message: !{git commit -m {{args}}}';
const prompt: PromptPipelineContent = createPromptPipelineContent(
'Commit message: !{git commit -m {{args}}}',
);
const expectedEscapedArgs =
getExpectedEscapedArgForPlatform(multilineArgs);
@@ -690,7 +683,8 @@ describe('ShellProcessor', () => {
])('should safely escape args containing $name', async ({ input }) => {
const processor = new ShellProcessor('test-command');
context.invocation!.args = input;
const prompt = '!{echo {{args}}}';
const prompt: PromptPipelineContent =
createPromptPipelineContent('!{echo {{args}}}');
const expectedEscapedArgs = getExpectedEscapedArgForPlatform(input);
const expectedCommand = `echo ${expectedEscapedArgs}`;

View File

@@ -10,14 +10,16 @@ import {
escapeShellArg,
getShellConfiguration,
ShellExecutionService,
flatMapTextParts,
} from '@qwen-code/qwen-code-core';
import { CommandContext } from '../../ui/commands/types.js';
import type { CommandContext } from '../../ui/commands/types.js';
import type { IPromptProcessor, PromptPipelineContent } from './types.js';
import {
IPromptProcessor,
SHELL_INJECTION_TRIGGER,
SHORTHAND_ARGS_PLACEHOLDER,
} from './types.js';
import { extractInjections, type Injection } from './injectionParser.js';
export class ConfirmationRequiredError extends Error {
constructor(
@@ -30,15 +32,10 @@ export class ConfirmationRequiredError extends Error {
}
/**
* Represents a single detected shell injection site in the prompt.
* Represents a single detected shell injection site in the prompt,
* after resolution of arguments. Extends the base Injection interface.
*/
interface ShellInjection {
/** The shell command extracted from within !{...}, trimmed. */
command: string;
/** The starting index of the injection (inclusive, points to '!'). */
startIndex: number;
/** The ending index of the injection (exclusive, points after '}'). */
endIndex: number;
interface ResolvedShellInjection extends Injection {
/** The command after {{args}} has been escaped and substituted. */
resolvedCommand?: string;
}
@@ -56,11 +53,25 @@ interface ShellInjection {
export class ShellProcessor implements IPromptProcessor {
constructor(private readonly commandName: string) {}
async process(prompt: string, context: CommandContext): Promise<string> {
async process(
prompt: PromptPipelineContent,
context: CommandContext,
): Promise<PromptPipelineContent> {
return flatMapTextParts(prompt, (text) =>
this.processString(text, context),
);
}
private async processString(
prompt: string,
context: CommandContext,
): Promise<PromptPipelineContent> {
const userArgsRaw = context.invocation?.args || '';
if (!prompt.includes(SHELL_INJECTION_TRIGGER)) {
return prompt.replaceAll(SHORTHAND_ARGS_PLACEHOLDER, userArgsRaw);
return [
{ text: prompt.replaceAll(SHORTHAND_ARGS_PLACEHOLDER, userArgsRaw) },
];
}
const config = context.services.config;
@@ -71,26 +82,37 @@ export class ShellProcessor implements IPromptProcessor {
}
const { sessionShellAllowlist } = context.session;
const injections = this.extractInjections(prompt);
const injections = extractInjections(
prompt,
SHELL_INJECTION_TRIGGER,
this.commandName,
);
// If extractInjections found no closed blocks (and didn't throw), treat as raw.
if (injections.length === 0) {
return prompt.replaceAll(SHORTHAND_ARGS_PLACEHOLDER, userArgsRaw);
return [
{ text: prompt.replaceAll(SHORTHAND_ARGS_PLACEHOLDER, userArgsRaw) },
];
}
const { shell } = getShellConfiguration();
const userArgsEscaped = escapeShellArg(userArgsRaw, shell);
const resolvedInjections = injections.map((injection) => {
if (injection.command === '') {
return injection;
}
// Replace {{args}} inside the command string with the escaped version.
const resolvedCommand = injection.command.replaceAll(
SHORTHAND_ARGS_PLACEHOLDER,
userArgsEscaped,
);
return { ...injection, resolvedCommand };
});
const resolvedInjections: ResolvedShellInjection[] = injections.map(
(injection) => {
const command = injection.content;
if (command === '') {
return { ...injection, resolvedCommand: undefined };
}
const resolvedCommand = command.replaceAll(
SHORTHAND_ARGS_PLACEHOLDER,
userArgsEscaped,
);
return { ...injection, resolvedCommand };
},
);
const commandsToConfirm = new Set<string>();
for (const injection of resolvedInjections) {
@@ -180,69 +202,6 @@ export class ShellProcessor implements IPromptProcessor {
userArgsRaw,
);
return processedPrompt;
}
/**
* Iteratively parses the prompt string to extract shell injections (!{...}),
* correctly handling nested braces within the command.
*
* @param prompt The prompt string to parse.
* @returns An array of extracted ShellInjection objects.
* @throws Error if an unclosed injection (`!{`) is found.
*/
private extractInjections(prompt: string): ShellInjection[] {
const injections: ShellInjection[] = [];
let index = 0;
while (index < prompt.length) {
const startIndex = prompt.indexOf(SHELL_INJECTION_TRIGGER, index);
if (startIndex === -1) {
break;
}
let currentIndex = startIndex + SHELL_INJECTION_TRIGGER.length;
let braceCount = 1;
let foundEnd = false;
while (currentIndex < prompt.length) {
const char = prompt[currentIndex];
// We count literal braces. This parser does not interpret shell quoting/escaping.
if (char === '{') {
braceCount++;
} else if (char === '}') {
braceCount--;
if (braceCount === 0) {
const commandContent = prompt.substring(
startIndex + SHELL_INJECTION_TRIGGER.length,
currentIndex,
);
const endIndex = currentIndex + 1;
injections.push({
command: commandContent.trim(),
startIndex,
endIndex,
});
index = endIndex;
foundEnd = true;
break;
}
}
currentIndex++;
}
// Check if the inner loop finished without finding the closing brace.
if (!foundEnd) {
throw new Error(
`Invalid syntax in command '${this.commandName}': Unclosed shell injection starting at index ${startIndex} ('!{'). Ensure braces are balanced.`,
);
}
}
return injections;
return [{ text: processedPrompt }];
}
}

View File

@@ -4,7 +4,13 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { CommandContext } from '../../ui/commands/types.js';
import type { CommandContext } from '../../ui/commands/types.js';
import type { PartUnion } from '@google/genai';
/**
* Defines the input/output type for prompt processors.
*/
export type PromptPipelineContent = PartUnion[];
/**
* Defines the interface for a prompt processor, a module that can transform
@@ -13,12 +19,8 @@ import { CommandContext } from '../../ui/commands/types.js';
*/
export interface IPromptProcessor {
/**
* Processes a prompt string, applying a specific transformation as part of a pipeline.
*
* Each processor in a command's pipeline receives the output of the previous
* processor. This method provides the full command context, allowing for
* complex transformations that may require access to invocation details,
* application services, or UI state.
* Processes a prompt input (which may contain text and multi-modal parts),
* applying a specific transformation as part of a pipeline.
*
* @param prompt The current state of the prompt string. This may have been
* modified by previous processors in the pipeline.
@@ -28,7 +30,10 @@ export interface IPromptProcessor {
* @returns A promise that resolves to the transformed prompt string, which
* will be passed to the next processor or, if it's the last one, sent to the model.
*/
process(prompt: string, context: CommandContext): Promise<string>;
process(
prompt: PromptPipelineContent,
context: CommandContext,
): Promise<PromptPipelineContent>;
}
/**
@@ -42,3 +47,8 @@ export const SHORTHAND_ARGS_PLACEHOLDER = '{{args}}';
* The trigger string for shell command injection in custom commands.
*/
export const SHELL_INJECTION_TRIGGER = '!{';
/**
* The trigger string for at file injection in custom commands.
*/
export const AT_FILE_INJECTION_TRIGGER = '@{';

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { SlashCommand } from '../ui/commands/types.js';
import type { SlashCommand } from '../ui/commands/types.js';
/**
* Defines the contract for any class that can load and provide slash commands.

View File

@@ -12,7 +12,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { Assertion, expect } from 'vitest';
import type { Assertion } from 'vitest';
import { expect } from 'vitest';
import type { TextBuffer } from '../ui/components/shared/text-buffer.js';
// RegExp to detect invalid characters: backspace, and ANSI escape codes

View File

@@ -5,10 +5,10 @@
*/
import { vi } from 'vitest';
import { CommandContext } from '../ui/commands/types.js';
import { LoadedSettings } from '../config/settings.js';
import { GitService } from '@qwen-code/qwen-code-core';
import { SessionStatsState } from '../ui/contexts/SessionContext.js';
import type { CommandContext } from '../ui/commands/types.js';
import type { LoadedSettings } from '../config/settings.js';
import type { GitService } from '@qwen-code/qwen-code-core';
import type { SessionStatsState } from '../ui/contexts/SessionContext.js';
// A utility type to make all properties of an object, and its nested objects, partial.
type DeepPartial<T> = T extends object

View File

@@ -5,7 +5,7 @@
*/
import { render } from 'ink-testing-library';
import React from 'react';
import type React from 'react';
import { KeypressProvider } from '../ui/contexts/KeypressContext.js';
export const renderWithProviders = (

View File

@@ -4,31 +4,41 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest';
import { renderWithProviders } from '../test-utils/render.js';
import { AppWrapper as App } from './App.js';
import {
Config as ServerConfig,
MCPServerConfig,
ApprovalMode,
ToolRegistry,
import type {
AccessibilitySettings,
SandboxConfig,
AuthType,
GeminiClient,
ideContext,
type AuthType,
MCPServerConfig,
SandboxConfig,
ToolRegistry,
} from '@qwen-code/qwen-code-core';
import { LoadedSettings, SettingsFile, Settings } from '../config/settings.js';
import {
ApprovalMode,
Config as ServerConfig,
ideContext,
} from '@qwen-code/qwen-code-core';
import { waitFor } from '@testing-library/react';
import { EventEmitter } from 'node:events';
import process from 'node:process';
import { useGeminiStream } from './hooks/useGeminiStream.js';
import { useConsoleMessages } from './hooks/useConsoleMessages.js';
import { StreamingState, ConsoleMessageItem } from './types.js';
import { Tips } from './components/Tips.js';
import { checkForUpdates, UpdateObject } from './utils/updateCheck.js';
import { EventEmitter } from 'events';
import { updateEventEmitter } from '../utils/updateEventEmitter.js';
import type { Mock } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as auth from '../config/auth.js';
import {
LoadedSettings,
type Settings,
type SettingsFile,
} from '../config/settings.js';
import { renderWithProviders } from '../test-utils/render.js';
import { updateEventEmitter } from '../utils/updateEventEmitter.js';
import { AppWrapper as App } from './App.js';
import { Tips } from './components/Tips.js';
import { useConsoleMessages } from './hooks/useConsoleMessages.js';
import { useGeminiStream } from './hooks/useGeminiStream.js';
import * as useTerminalSize from './hooks/useTerminalSize.js';
import type { ConsoleMessageItem } from './types.js';
import { StreamingState, ToolCallStatus } from './types.js';
import type { UpdateObject } from './utils/updateCheck.js';
import { checkForUpdates } from './utils/updateCheck.js';
// Define a more complete mock server config based on actual Config
interface MockServerConfig {
@@ -52,6 +62,7 @@ interface MockServerConfig {
showMemoryUsage?: boolean;
accessibility?: AccessibilitySettings;
embeddingModel: string;
checkpointing?: boolean;
getApiKey: Mock<() => string>;
getModel: Mock<() => string>;
@@ -66,6 +77,7 @@ interface MockServerConfig {
getToolCallCommand: Mock<() => string | undefined>;
getMcpServerCommand: Mock<() => string | undefined>;
getMcpServers: Mock<() => Record<string, MCPServerConfig> | undefined>;
getPromptRegistry: Mock<() => Record<string, unknown>>;
getExtensions: Mock<
() => Array<{ name: string; version: string; isActive: boolean }>
>;
@@ -83,10 +95,34 @@ interface MockServerConfig {
getShowMemoryUsage: Mock<() => boolean>;
getAccessibility: Mock<() => AccessibilitySettings>;
getProjectRoot: Mock<() => string | undefined>;
getAllGeminiMdFilenames: Mock<() => string[]>;
getEnablePromptCompletion: Mock<() => boolean>;
getGeminiClient: Mock<() => GeminiClient | undefined>;
getCheckpointingEnabled: Mock<() => boolean>;
getAllGeminiMdFilenames: Mock<() => string[]>;
setFlashFallbackHandler: Mock<(handler: (fallback: boolean) => void) => void>;
getSessionId: Mock<() => string>;
getUserTier: Mock<() => Promise<string | undefined>>;
getIdeClient: Mock<() => { getCurrentIde: Mock<() => string | undefined> }>;
getIdeMode: Mock<() => boolean>;
getWorkspaceContext: Mock<
() => {
getDirectories: Mock<() => string[]>;
}
>;
getIdeClient: Mock<
() => {
getCurrentIde: Mock<() => string | undefined>;
getDetectedIdeDisplayName: Mock<() => string>;
addStatusChangeListener: Mock<
(listener: (status: string) => void) => void
>;
removeStatusChangeListener: Mock<
(listener: (status: string) => void) => void
>;
getConnectionStatus: Mock<() => string>;
}
>;
isTrustedFolder: Mock<() => boolean>;
getScreenReader: Mock<() => boolean>;
}
// Mock @qwen-code/qwen-code-core and its Config class
@@ -147,6 +183,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
getShowMemoryUsage: vi.fn(() => opts.showMemoryUsage ?? false),
getAccessibility: vi.fn(() => opts.accessibility ?? {}),
getProjectRoot: vi.fn(() => opts.targetDir),
getEnablePromptCompletion: vi.fn(() => false),
getGeminiClient: vi.fn(() => ({
getUserTier: vi.fn(),
})),
@@ -167,6 +204,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
getConnectionStatus: vi.fn(() => 'connected'),
})),
isTrustedFolder: vi.fn(() => true),
getScreenReader: vi.fn(() => false),
};
});
@@ -193,6 +231,7 @@ vi.mock('./hooks/useGeminiStream', () => ({
initError: null,
pendingHistoryItems: [],
thought: null,
cancelOngoingRequest: vi.fn(),
})),
}));
@@ -209,8 +248,10 @@ vi.mock('./hooks/useAuthCommand', () => ({
vi.mock('./hooks/useFolderTrust', () => ({
useFolderTrust: vi.fn(() => ({
isTrusted: undefined,
isFolderTrustDialogOpen: false,
handleFolderTrustSelect: vi.fn(),
isRestarting: false,
})),
}));
@@ -283,6 +324,10 @@ describe('App UI', () => {
path: '/system/settings.json',
settings: settings.system || {},
};
const systemDefaultsFile: SettingsFile = {
path: '/system/system-defaults.json',
settings: {},
};
const userSettingsFile: SettingsFile = {
path: '/user/settings.json',
settings: settings.user || {},
@@ -293,9 +338,12 @@ describe('App UI', () => {
};
return new LoadedSettings(
systemSettingsFile,
systemDefaultsFile,
userSettingsFile,
workspaceSettingsFile,
[],
true,
new Set(),
);
};
@@ -327,7 +375,9 @@ describe('App UI', () => {
mockConfig.getShowMemoryUsage.mockReturnValue(false); // Default for most tests
// Ensure a theme is set so the theme dialog does not appear.
mockSettings = createMockSettings({ workspace: { theme: 'Default' } });
mockSettings = createMockSettings({
workspace: { ui: { theme: 'Default' } },
});
// Ensure getWorkspaceContext is available if not added by the constructor
if (!mockConfig.getWorkspaceContext) {
@@ -352,9 +402,19 @@ describe('App UI', () => {
beforeEach(async () => {
const { spawn } = await import('node:child_process');
spawnEmitter = new EventEmitter();
spawnEmitter.stdout = new EventEmitter();
spawnEmitter.stderr = new EventEmitter();
(spawn as vi.Mock).mockReturnValue(spawnEmitter);
(
spawnEmitter as EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
}
).stdout = new EventEmitter();
(
spawnEmitter as EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
}
).stderr = new EventEmitter();
(spawn as Mock).mockReturnValue(spawnEmitter);
});
afterEach(() => {
@@ -368,6 +428,7 @@ describe('App UI', () => {
name: '@qwen-code/qwen-code',
latest: '1.1.0',
current: '1.0.0',
type: 'major' as const,
},
message: 'Qwen Code update available!',
};
@@ -383,9 +444,10 @@ describe('App UI', () => {
);
currentUnmount = unmount;
await new Promise((resolve) => setTimeout(resolve, 10));
expect(spawn).not.toHaveBeenCalled();
// Wait for any potential async operations to complete
await waitFor(() => {
expect(spawn).not.toHaveBeenCalled();
});
});
it('should show a success message when update succeeds', async () => {
@@ -395,6 +457,7 @@ describe('App UI', () => {
name: '@qwen-code/qwen-code',
latest: '1.1.0',
current: '1.0.0',
type: 'major' as const,
},
message: 'Update available',
};
@@ -411,11 +474,12 @@ describe('App UI', () => {
updateEventEmitter.emit('update-success', info);
await new Promise((resolve) => setTimeout(resolve, 10));
expect(lastFrame()).toContain(
'Update successful! The new version will be used on your next run.',
);
// Wait for the success message to appear
await waitFor(() => {
expect(lastFrame()).toContain(
'Update successful! The new version will be used on your next run.',
);
});
});
it('should show an error message when update fails', async () => {
@@ -425,6 +489,7 @@ describe('App UI', () => {
name: '@qwen-code/qwen-code',
latest: '1.1.0',
current: '1.0.0',
type: 'major' as const,
},
message: 'Update available',
};
@@ -441,11 +506,12 @@ describe('App UI', () => {
updateEventEmitter.emit('update-failed', info);
await new Promise((resolve) => setTimeout(resolve, 10));
expect(lastFrame()).toContain(
'Automatic update failed. Please try updating manually',
);
// Wait for the error message to appear
await waitFor(() => {
expect(lastFrame()).toContain(
'Automatic update failed. Please try updating manually',
);
});
});
it('should show an error message when spawn fails', async () => {
@@ -455,6 +521,7 @@ describe('App UI', () => {
name: '@qwen-code/qwen-code',
latest: '1.1.0',
current: '1.0.0',
type: 'major' as const,
},
message: 'Update available',
};
@@ -473,11 +540,12 @@ describe('App UI', () => {
// which is what should be emitted when a spawn error occurs elsewhere.
updateEventEmitter.emit('update-failed', info);
await new Promise((resolve) => setTimeout(resolve, 10));
expect(lastFrame()).toContain(
'Automatic update failed. Please try updating manually',
);
// Wait for the error message to appear
await waitFor(() => {
expect(lastFrame()).toContain(
'Automatic update failed. Please try updating manually',
);
});
});
it('should not auto-update if GEMINI_CLI_DISABLE_AUTOUPDATER is true', async () => {
@@ -488,6 +556,7 @@ describe('App UI', () => {
name: '@qwen-code/qwen-code',
latest: '1.1.0',
current: '1.0.0',
type: 'major' as const,
},
message: 'Update available',
};
@@ -503,9 +572,10 @@ describe('App UI', () => {
);
currentUnmount = unmount;
await new Promise((resolve) => setTimeout(resolve, 10));
expect(spawn).not.toHaveBeenCalled();
// Wait for any potential async operations to complete
await waitFor(() => {
expect(spawn).not.toHaveBeenCalled();
});
});
});
@@ -659,7 +729,10 @@ describe('App UI', () => {
it('should display custom contextFileName in footer when set and count is 1', async () => {
mockSettings = createMockSettings({
workspace: { contextFileName: 'AGENTS.md', theme: 'Default' },
workspace: {
context: { fileName: 'AGENTS.md' },
ui: { theme: 'Default' },
},
});
mockConfig.getGeminiMdFileCount.mockReturnValue(1);
mockConfig.getAllGeminiMdFilenames.mockReturnValue(['AGENTS.md']);
@@ -681,8 +754,8 @@ describe('App UI', () => {
it('should display a generic message when multiple context files with different names are provided', async () => {
mockSettings = createMockSettings({
workspace: {
contextFileName: ['AGENTS.md', 'CONTEXT.md'],
theme: 'Default',
context: { fileName: ['AGENTS.md', 'CONTEXT.md'] },
ui: { theme: 'Default' },
},
});
mockConfig.getGeminiMdFileCount.mockReturnValue(2);
@@ -707,7 +780,10 @@ describe('App UI', () => {
it('should display custom contextFileName with plural when set and count is > 1', async () => {
mockSettings = createMockSettings({
workspace: { contextFileName: 'MY_NOTES.TXT', theme: 'Default' },
workspace: {
context: { fileName: 'MY_NOTES.TXT' },
ui: { theme: 'Default' },
},
});
mockConfig.getGeminiMdFileCount.mockReturnValue(3);
mockConfig.getAllGeminiMdFilenames.mockReturnValue([
@@ -732,7 +808,10 @@ describe('App UI', () => {
it('should not display context file message if count is 0, even if contextFileName is set', async () => {
mockSettings = createMockSettings({
workspace: { contextFileName: 'ANY_FILE.MD', theme: 'Default' },
workspace: {
context: { fileName: 'ANY_FILE.MD' },
ui: { theme: 'Default' },
},
});
mockConfig.getGeminiMdFileCount.mockReturnValue(0);
mockConfig.getAllGeminiMdFilenames.mockReturnValue([]);
@@ -810,7 +889,7 @@ describe('App UI', () => {
it('should not display Tips component when hideTips is true', async () => {
mockSettings = createMockSettings({
workspace: {
hideTips: true,
ui: { hideTips: true },
},
});
@@ -843,7 +922,7 @@ describe('App UI', () => {
it('should not display Header component when hideBanner is true', async () => {
const { Header } = await import('./components/Header.js');
mockSettings = createMockSettings({
user: { hideBanner: true },
user: { ui: { hideBanner: true } },
});
const { unmount } = renderWithProviders(
@@ -874,7 +953,7 @@ describe('App UI', () => {
it('should not display Footer component when hideFooter is true', async () => {
mockSettings = createMockSettings({
user: { hideFooter: true },
user: { ui: { hideFooter: true } },
});
const { lastFrame, unmount } = renderWithProviders(
@@ -892,9 +971,9 @@ describe('App UI', () => {
it('should show footer if system says show, but workspace and user settings say hide', async () => {
mockSettings = createMockSettings({
system: { hideFooter: false },
user: { hideFooter: true },
workspace: { hideFooter: true },
system: { ui: { hideFooter: false } },
user: { ui: { hideFooter: true } },
workspace: { ui: { hideFooter: true } },
});
const { lastFrame, unmount } = renderWithProviders(
@@ -912,9 +991,9 @@ describe('App UI', () => {
it('should show tips if system says show, but workspace and user settings say hide', async () => {
mockSettings = createMockSettings({
system: { hideTips: false },
user: { hideTips: true },
workspace: { hideTips: true },
system: { ui: { hideTips: false } },
user: { ui: { hideTips: true } },
workspace: { ui: { hideTips: true } },
});
const { unmount } = renderWithProviders(
@@ -995,6 +1074,7 @@ describe('App UI', () => {
initError: null,
pendingHistoryItems: [],
thought: null,
cancelOngoingRequest: vi.fn(),
});
const { lastFrame, unmount } = renderWithProviders(
@@ -1020,6 +1100,7 @@ describe('App UI', () => {
initError: null,
pendingHistoryItems: [],
thought: null,
cancelOngoingRequest: vi.fn(),
});
mockConfig.getGeminiClient.mockReturnValue({
@@ -1089,9 +1170,13 @@ describe('App UI', () => {
const validateAuthMethodSpy = vi.spyOn(auth, 'validateAuthMethod');
mockSettings = createMockSettings({
workspace: {
selectedAuthType: 'USE_GEMINI' as AuthType,
useExternalAuth: false,
theme: 'Default',
security: {
auth: {
selectedType: 'USE_GEMINI' as AuthType,
useExternal: false,
},
},
ui: { theme: 'Default' },
},
});
@@ -1111,9 +1196,13 @@ describe('App UI', () => {
const validateAuthMethodSpy = vi.spyOn(auth, 'validateAuthMethod');
mockSettings = createMockSettings({
workspace: {
selectedAuthType: 'USE_GEMINI' as AuthType,
useExternalAuth: true,
theme: 'Default',
security: {
auth: {
selectedType: 'USE_GEMINI' as AuthType,
useExternal: true,
},
},
ui: { theme: 'Default' },
},
});
@@ -1181,8 +1270,10 @@ describe('App UI', () => {
it('should display the folder trust dialog when isFolderTrustDialogOpen is true', async () => {
const { useFolderTrust } = await import('./hooks/useFolderTrust.js');
vi.mocked(useFolderTrust).mockReturnValue({
isTrusted: undefined,
isFolderTrustDialogOpen: true,
handleFolderTrustSelect: vi.fn(),
isRestarting: false,
});
const { lastFrame, unmount } = renderWithProviders(
@@ -1200,8 +1291,10 @@ describe('App UI', () => {
it('should display the folder trust dialog when the feature is enabled but the folder is not trusted', async () => {
const { useFolderTrust } = await import('./hooks/useFolderTrust.js');
vi.mocked(useFolderTrust).mockReturnValue({
isTrusted: false,
isFolderTrustDialogOpen: true,
handleFolderTrustSelect: vi.fn(),
isRestarting: false,
});
mockConfig.isTrustedFolder.mockReturnValue(false);
@@ -1220,8 +1313,10 @@ describe('App UI', () => {
it('should not display the folder trust dialog when the feature is disabled', async () => {
const { useFolderTrust } = await import('./hooks/useFolderTrust.js');
vi.mocked(useFolderTrust).mockReturnValue({
isTrusted: false,
isFolderTrustDialogOpen: false,
handleFolderTrustSelect: vi.fn(),
isRestarting: false,
});
mockConfig.isTrustedFolder.mockReturnValue(false);
@@ -1239,7 +1334,7 @@ describe('App UI', () => {
});
describe('Message Queuing', () => {
let mockSubmitQuery: typeof vi.fn;
let mockSubmitQuery: Mock;
beforeEach(() => {
mockSubmitQuery = vi.fn();
@@ -1257,6 +1352,7 @@ describe('App UI', () => {
initError: null,
pendingHistoryItems: [],
thought: null,
cancelOngoingRequest: vi.fn(),
});
const { unmount } = renderWithProviders(
@@ -1282,6 +1378,7 @@ describe('App UI', () => {
initError: null,
pendingHistoryItems: [],
thought: null,
cancelOngoingRequest: vi.fn(),
});
const { unmount, rerender } = renderWithProviders(
@@ -1300,6 +1397,7 @@ describe('App UI', () => {
initError: null,
pendingHistoryItems: [],
thought: null,
cancelOngoingRequest: vi.fn(),
});
// Rerender to trigger the useEffect with new state
@@ -1328,7 +1426,8 @@ describe('App UI', () => {
submitQuery: mockSubmitQuery,
initError: null,
pendingHistoryItems: [],
thought: 'Processing...',
thought: { subject: 'Processing', description: 'Processing...' },
cancelOngoingRequest: vi.fn(),
});
const { unmount, lastFrame } = renderWithProviders(
@@ -1356,6 +1455,7 @@ describe('App UI', () => {
initError: null,
pendingHistoryItems: [],
thought: null,
cancelOngoingRequest: vi.fn(),
});
const { unmount, lastFrame } = renderWithProviders(
@@ -1385,6 +1485,7 @@ describe('App UI', () => {
initError: null,
pendingHistoryItems: [],
thought: null,
cancelOngoingRequest: vi.fn(),
});
const { unmount } = renderWithProviders(
@@ -1413,6 +1514,7 @@ describe('App UI', () => {
initError: null,
pendingHistoryItems: [],
thought: null,
cancelOngoingRequest: vi.fn(),
});
const { unmount, lastFrame } = renderWithProviders(
@@ -1440,7 +1542,8 @@ describe('App UI', () => {
submitQuery: mockSubmitQuery,
initError: null,
pendingHistoryItems: [],
thought: 'Processing...',
thought: { subject: 'Processing', description: 'Processing...' },
cancelOngoingRequest: vi.fn(),
});
const { lastFrame, unmount } = renderWithProviders(
@@ -1471,7 +1574,8 @@ describe('App UI', () => {
submitQuery: mockSubmitQuery,
initError: null,
pendingHistoryItems: [],
thought: 'Processing...',
thought: { subject: 'Processing', description: 'Processing...' },
cancelOngoingRequest: vi.fn(),
});
const { lastFrame, unmount } = renderWithProviders(
@@ -1493,4 +1597,142 @@ describe('App UI', () => {
expect(output).toContain('esc to cancel');
});
});
describe('debug keystroke logging', () => {
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(() => {
consoleLogSpy.mockRestore();
});
it('should pass debugKeystrokeLogging setting to KeypressProvider', () => {
const mockSettingsWithDebug = createMockSettings({
workspace: {
ui: { theme: 'Default' },
general: { debugKeystrokeLogging: true },
},
});
const { lastFrame, unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettingsWithDebug}
version={mockVersion}
/>,
);
currentUnmount = unmount;
const output = lastFrame();
expect(output).toBeDefined();
expect(mockSettingsWithDebug.merged.general?.debugKeystrokeLogging).toBe(
true,
);
});
it('should use default false value when debugKeystrokeLogging is not set', () => {
const { lastFrame, unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
const output = lastFrame();
expect(output).toBeDefined();
expect(
mockSettings.merged.general?.debugKeystrokeLogging,
).toBeUndefined();
});
});
describe('Ctrl+C behavior', () => {
it('should call cancel but only clear the prompt when a tool is executing', async () => {
const mockCancel = vi.fn();
let onCancelSubmitCallback = () => {};
// Simulate a tool in the "Executing" state.
vi.mocked(useGeminiStream).mockImplementation(
(
_client,
_history,
_addItem,
_config,
_onDebugMessage,
_handleSlashCommand,
_shellModeActive,
_getPreferredEditor,
_onAuthError,
_performMemoryRefresh,
_modelSwitchedFromQuotaError,
_setModelSwitchedFromQuotaError,
_onEditorClose,
onCancelSubmit, // Capture the cancel callback from App.tsx
) => {
onCancelSubmitCallback = onCancelSubmit;
return {
streamingState: StreamingState.Responding,
submitQuery: vi.fn(),
initError: null,
pendingHistoryItems: [
{
type: 'tool_group',
tools: [
{
name: 'test_tool',
status: ToolCallStatus.Executing,
callId: 'test-call-id',
description: 'Test tool description',
resultDisplay: 'Test result',
confirmationDetails: undefined,
},
],
},
],
thought: null,
cancelOngoingRequest: () => {
mockCancel();
onCancelSubmitCallback(); // <--- This is the key change
},
};
},
);
const { stdin, lastFrame, unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
// Simulate user typing something into the prompt while a tool is running.
stdin.write('some text');
await new Promise((resolve) => setTimeout(resolve, 100));
// Verify the text is in the prompt.
expect(lastFrame()).toContain('some text');
// Simulate Ctrl+C.
stdin.write('\x03');
await new Promise((resolve) => setTimeout(resolve, 100));
// The main cancellation handler SHOULD be called.
expect(mockCancel).toHaveBeenCalled();
// The prompt should now be empty as a result of the cancellation handler's logic.
// We can't directly test the buffer's state, but we can see the rendered output.
await waitFor(() => {
expect(lastFrame()).not.toContain('some text');
});
});
});
});

View File

@@ -7,14 +7,20 @@
import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
import {
Box,
DOMElement,
type DOMElement,
measureElement,
Static,
Text,
useStdin,
useStdout,
} from 'ink';
import { StreamingState, type HistoryItem, MessageType } from './types.js';
import {
StreamingState,
type HistoryItem,
MessageType,
ToolCallStatus,
type HistoryItemWithoutId,
} from './types.js';
import { useTerminalSize } from './hooks/useTerminalSize.js';
import { useGeminiStream } from './hooks/useGeminiStream.js';
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
@@ -43,7 +49,8 @@ import { ShellConfirmationDialog } from './components/ShellConfirmationDialog.js
import { RadioButtonSelect } from './components/shared/RadioButtonSelect.js';
import { Colors } from './colors.js';
import { loadHierarchicalGeminiMemory } from '../config/config.js';
import { LoadedSettings, SettingScope } from '../config/settings.js';
import type { LoadedSettings } from '../config/settings.js';
import { SettingScope } from '../config/settings.js';
import { Tips } from './components/Tips.js';
import { ConsolePatcher } from './utils/ConsolePatcher.js';
import { registerCleanup } from '../utils/cleanup.js';
@@ -52,23 +59,22 @@ import { HistoryItemDisplay } from './components/HistoryItemDisplay.js';
import { ContextSummaryDisplay } from './components/ContextSummaryDisplay.js';
import { useHistory } from './hooks/useHistoryManager.js';
import process from 'node:process';
import type { EditorType, Config, IdeContext } from '@qwen-code/qwen-code-core';
import {
getErrorMessage,
type Config,
getAllGeminiMdFilenames,
ApprovalMode,
getAllGeminiMdFilenames,
isEditorAvailable,
EditorType,
FlashFallbackEvent,
logFlashFallback,
getErrorMessage,
AuthType,
type IdeContext,
logFlashFallback,
FlashFallbackEvent,
ideContext,
isProQuotaExceededError,
isGenericQuotaExceededError,
UserTierId,
} from '@qwen-code/qwen-code-core';
import {
IdeIntegrationNudge,
IdeIntegrationNudgeResult,
} from './IdeIntegrationNudge.js';
import type { IdeIntegrationNudgeResult } from './IdeIntegrationNudge.js';
import { IdeIntegrationNudge } from './IdeIntegrationNudge.js';
import { validateAuthMethod } from '../config/auth.js';
import { useLogger } from './hooks/useLogger.js';
import { StreamingContext } from './contexts/StreamingContext.js';
@@ -82,18 +88,14 @@ import { useBracketedPaste } from './hooks/useBracketedPaste.js';
import { useTextBuffer } from './components/shared/text-buffer.js';
import { useVimMode, VimModeProvider } from './contexts/VimModeContext.js';
import { useVim } from './hooks/vim.js';
import { useKeypress, Key } from './hooks/useKeypress.js';
import type { Key } from './hooks/useKeypress.js';
import { useKeypress } from './hooks/useKeypress.js';
import { KeypressProvider } from './contexts/KeypressContext.js';
import { useKittyKeyboardProtocol } from './hooks/useKittyKeyboardProtocol.js';
import { keyMatchers, Command } from './keyMatchers.js';
import * as fs from 'fs';
import * as fs from 'node:fs';
import { UpdateNotification } from './components/UpdateNotification.js';
import {
isProQuotaExceededError,
isGenericQuotaExceededError,
UserTierId,
} from '@qwen-code/qwen-code-core';
import { UpdateObject } from './utils/updateCheck.js';
import type { UpdateObject } from './utils/updateCheck.js';
import ansiEscapes from 'ansi-escapes';
import { OverflowProvider } from './contexts/OverflowContext.js';
import { ShowMoreLines } from './components/ShowMoreLines.js';
@@ -103,6 +105,8 @@ import { SettingsDialog } from './components/SettingsDialog.js';
import { setUpdateHandler } from '../utils/handleAutoUpdate.js';
import { appEvents, AppEvent } from '../utils/events.js';
import { isNarrowWidth } from './utils/isNarrowWidth.js';
import { useWorkspaceMigration } from './hooks/useWorkspaceMigration.js';
import { WorkspaceMigrationDialog } from './components/WorkspaceMigrationDialog.js';
const CTRL_EXIT_PROMPT_DURATION_MS = 1000;
// Maximum number of queued messages to display in UI to prevent performance issues
@@ -115,12 +119,26 @@ interface AppProps {
version: string;
}
function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
return pendingHistoryItems.some((item) => {
if (item && item.type === 'tool_group') {
return item.tools.some(
(tool) => ToolCallStatus.Executing === tool.status,
);
}
return false;
});
}
export const AppWrapper = (props: AppProps) => {
const kittyProtocolStatus = useKittyKeyboardProtocol();
return (
<KeypressProvider
kittyProtocolEnabled={kittyProtocolStatus.enabled}
config={props.config}
debugKeystrokeLogging={
props.settings.merged.general?.debugKeystrokeLogging
}
>
<SessionStatsProvider>
<VimModeProvider settings={props.settings}>
@@ -147,7 +165,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
const shouldShowIdePrompt =
currentIDE &&
!config.getIdeMode() &&
!settings.merged.hasSeenIdeIntegrationNudge &&
!settings.merged.ide?.hasSeenNudge &&
!idePromptAnswered;
useEffect(() => {
@@ -211,6 +229,12 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
>();
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
const [isProcessing, setIsProcessing] = useState<boolean>(false);
const {
showWorkspaceMigrationDialog,
workspaceExtensions,
onWorkspaceMigrationDialogOpen,
onWorkspaceMigrationDialogClose,
} = useWorkspaceMigration(settings);
useEffect(() => {
const unsubscribe = ideContext.subscribeToIdeContext(setIdeContextState);
@@ -269,10 +293,8 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
const { isSettingsDialogOpen, openSettingsDialog, closeSettingsDialog } =
useSettingsCommand();
const { isFolderTrustDialogOpen, handleFolderTrustSelect } = useFolderTrust(
settings,
setIsTrustedFolder,
);
const { isFolderTrustDialogOpen, handleFolderTrustSelect, isRestarting } =
useFolderTrust(settings, setIsTrustedFolder);
const {
isAuthDialogOpen,
@@ -292,16 +314,21 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
} = useQwenAuth(settings, isAuthenticating);
useEffect(() => {
if (settings.merged.selectedAuthType && !settings.merged.useExternalAuth) {
const error = validateAuthMethod(settings.merged.selectedAuthType);
if (
settings.merged.security?.auth?.selectedType &&
!settings.merged.security?.auth?.useExternal
) {
const error = validateAuthMethod(
settings.merged.security.auth.selectedType,
);
if (error) {
setAuthError(error);
openAuthDialog();
}
}
}, [
settings.merged.selectedAuthType,
settings.merged.useExternalAuth,
settings.merged.security?.auth?.selectedType,
settings.merged.security?.auth?.useExternal,
openAuthDialog,
setAuthError,
]);
@@ -357,14 +384,14 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
try {
const { memoryContent, fileCount } = await loadHierarchicalGeminiMemory(
process.cwd(),
settings.merged.loadMemoryFromIncludeDirectories
settings.merged.context?.loadMemoryFromIncludeDirectories
? config.getWorkspaceContext().getDirectories()
: [],
config.getDebugMode(),
config.getFileService(),
settings.merged,
config.getExtensionContextFilePaths(),
settings.merged.memoryImportFormat || 'tree', // Use setting or default to 'tree'
settings.merged.context?.importFormat || 'tree', // Use setting or default to 'tree'
config.getFileFilteringOptions(),
);
@@ -522,7 +549,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
}, []);
const getPreferredEditor = useCallback(() => {
const editorType = settings.merged.preferredEditor;
const editorType = settings.merged.general?.preferredEditor;
const isValidEditor = isEditorAvailable(editorType);
if (!isValidEditor) {
openEditorDialog();
@@ -608,6 +635,11 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
() => cancelHandlerRef.current(),
);
const pendingHistoryItems = useMemo(
() => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems],
[pendingSlashCommandHistoryItems, pendingGeminiHistoryItems],
);
// Message queue for handling input during streaming
const { messageQueue, addMessage, clearQueue, getQueuedMessagesText } =
useMessageQueue({
@@ -617,6 +649,11 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
// Update the cancel handler with message queue support
cancelHandlerRef.current = useCallback(() => {
if (isToolExecuting(pendingHistoryItems)) {
buffer.setText(''); // Just clear the prompt
return;
}
const lastUserMessage = userMessages.at(-1);
let textToSet = lastUserMessage || '';
@@ -630,7 +667,13 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
if (textToSet) {
buffer.setText(textToSet);
}
}, [buffer, userMessages, getQueuedMessagesText, clearQueue]);
}, [
buffer,
userMessages,
getQueuedMessagesText,
clearQueue,
pendingHistoryItems,
]);
// Input handling - queue messages for processing
const handleFinalSubmit = useCallback(
@@ -666,12 +709,10 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
);
const { handleInput: vimHandleInput } = useVim(buffer, handleFinalSubmit);
const pendingHistoryItems = [...pendingSlashCommandHistoryItems];
pendingHistoryItems.push(...pendingGeminiHistoryItems);
const { elapsedTime, currentLoadingPhrase } =
useLoadingIndicator(streamingState);
const showAutoAcceptIndicator = useAutoAcceptIndicator({ config });
const showAutoAcceptIndicator = useAutoAcceptIndicator({ config, addItem });
const handleExit = useCallback(
(
@@ -698,6 +739,11 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
const handleGlobalKeypress = useCallback(
(key: Key) => {
// Debug log keystrokes if enabled
if (settings.merged.general?.debugKeystrokeLogging) {
console.log('[DEBUG] Keystroke:', JSON.stringify(key));
}
let enteringConstrainHeightMode = false;
if (!constrainHeight) {
enteringConstrainHeightMode = true;
@@ -761,6 +807,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
handleSlashCommand,
isAuthenticating,
cancelOngoingRequest,
settings.merged.general?.debugKeystrokeLogging,
],
);
@@ -774,7 +821,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
}
}, [config, config.getGeminiMdFileCount]);
const logger = useLogger();
const logger = useLogger(config.storage);
useEffect(() => {
const fetchUserMessages = async () => {
@@ -876,12 +923,12 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
const branchName = useGitBranchName(config.getTargetDir());
const contextFileNames = useMemo(() => {
const fromSettings = settings.merged.contextFileName;
const fromSettings = settings.merged.context?.fileName;
if (fromSettings) {
return Array.isArray(fromSettings) ? fromSettings : [fromSettings];
}
return getAllGeminiMdFilenames();
}, [settings.merged.contextFileName]);
}, [settings.merged.context?.fileName]);
const initialPrompt = useMemo(() => config.getQuestion(), [config]);
const geminiClient = config.getGeminiClient();
@@ -957,10 +1004,12 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
key={staticKey}
items={[
<Box flexDirection="column" key="header">
{!settings.merged.hideBanner && (
<Header version={version} nightly={nightly} />
{!(
settings.merged.ui?.hideBanner || config.getScreenReader()
) && <Header version={version} nightly={nightly} />}
{!(settings.merged.ui?.hideTips || config.getScreenReader()) && (
<Tips config={config} />
)}
{!settings.merged.hideTips && <Tips config={config} />}
</Box>,
...history.map((h) => (
<HistoryItemDisplay
@@ -1016,14 +1065,22 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
))}
</Box>
)}
{shouldShowIdePrompt && currentIDE ? (
{showWorkspaceMigrationDialog ? (
<WorkspaceMigrationDialog
workspaceExtensions={workspaceExtensions}
onOpen={onWorkspaceMigrationDialogOpen}
onClose={onWorkspaceMigrationDialogClose}
/>
) : shouldShowIdePrompt && currentIDE ? (
<IdeIntegrationNudge
ide={currentIDE}
onComplete={handleIdePromptComplete}
/>
) : isFolderTrustDialogOpen ? (
<FolderTrustDialog onSelect={handleFolderTrustSelect} />
<FolderTrustDialog
onSelect={handleFolderTrustSelect}
isRestarting={isRestarting}
/>
) : shellConfirmationRequest ? (
<ShellConfirmationDialog request={shellConfirmationRequest} />
) : confirmationRequest ? (
@@ -1146,12 +1203,14 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
<LoadingIndicator
thought={
streamingState === StreamingState.WaitingForConfirmation ||
config.getAccessibility()?.disableLoadingPhrases
config.getAccessibility()?.disableLoadingPhrases ||
config.getScreenReader()
? undefined
: thought
}
currentLoadingPhrase={
config.getAccessibility()?.disableLoadingPhrases
config.getAccessibility()?.disableLoadingPhrases ||
config.getScreenReader()
? undefined
: currentLoadingPhrase
}
@@ -1182,8 +1241,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
<Box paddingLeft={2}>
<Text dimColor>
... (+
{messageQueue.length -
MAX_DISPLAYED_QUEUED_MESSAGES}{' '}
{messageQueue.length - MAX_DISPLAYED_QUEUED_MESSAGES}
more)
</Text>
</Box>
@@ -1303,7 +1361,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
)}
</Box>
)}
{!settings.merged.hideFooter && (
{!settings.merged.ui?.hideFooter && (
<Footer
model={currentModel}
targetDir={config.getTargetDir()}
@@ -1315,7 +1373,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
showErrorDetails={showErrorDetails}
showMemoryUsage={
config.getDebugMode() ||
settings.merged.showMemoryUsage ||
settings.merged.ui?.showMemoryUsage ||
false
}
promptTokenCount={sessionStats.lastPromptTokenCount}

View File

@@ -4,12 +4,10 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { DetectedIde, getIdeInfo } from '@qwen-code/qwen-code-core';
import { type DetectedIde, getIdeInfo } from '@qwen-code/qwen-code-core';
import { Box, Text } from 'ink';
import {
RadioButtonSelect,
RadioSelectItem,
} from './components/shared/RadioButtonSelect.js';
import type { RadioSelectItem } from './components/shared/RadioButtonSelect.js';
import { RadioButtonSelect } from './components/shared/RadioButtonSelect.js';
import { useKeypress } from './hooks/useKeypress.js';
export type IdeIntegrationNudgeResult = {
@@ -88,7 +86,7 @@ export function IdeIntegrationNudge({
<Box marginBottom={1} flexDirection="column">
<Text>
<Text color="yellow">{'> '}</Text>
{`Do you want to connect ${ideName ?? 'your'} editor to Qwen Code?`}
{`Do you want to connect ${ideName ?? 'your editor'} to Qwen Code?`}
</Text>
<Text dimColor>{installText}</Text>
</Box>

View File

@@ -5,7 +5,7 @@
*/
import { themeManager } from './themes/theme-manager.js';
import { ColorsTheme } from './themes/theme.js';
import type { ColorsTheme } from './themes/theme.js';
export const Colors: ColorsTheme = {
get type() {

View File

@@ -11,7 +11,7 @@ import { createMockCommandContext } from '../../test-utils/mockCommandContext.js
import * as versionUtils from '../../utils/version.js';
import { MessageType } from '../types.js';
import { IdeClient } from '../../../../core/src/ide/ide-client.js';
import type { IdeClient } from '../../../../core/src/ide/ide-client.js';
vi.mock('../../utils/version.js', () => ({
getCliVersion: vi.fn(),
@@ -32,7 +32,11 @@ describe('aboutCommand', () => {
},
settings: {
merged: {
selectedAuthType: 'test-auth',
security: {
auth: {
selectedType: 'test-auth',
},
},
},
},
},

View File

@@ -5,7 +5,8 @@
*/
import { getCliVersion } from '../../utils/version.js';
import { CommandKind, SlashCommand } from './types.js';
import type { SlashCommand } from './types.js';
import { CommandKind } from './types.js';
import process from 'node:process';
import { MessageType, type HistoryItemAbout } from '../types.js';
@@ -26,7 +27,7 @@ export const aboutCommand: SlashCommand = {
const modelVersion = context.services.config?.getModel() || 'Unknown';
const cliVersion = await getCliVersion();
const selectedAuthType =
context.services.settings.merged.selectedAuthType || '';
context.services.settings.merged.security?.auth?.selectedType || '';
const gcpProject = process.env['GOOGLE_CLOUD_PROJECT'] || '';
const ideClient =
(context.services.config?.getIdeMode() &&

View File

@@ -4,7 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { CommandKind, OpenDialogActionReturn, SlashCommand } from './types.js';
import type { OpenDialogActionReturn, SlashCommand } from './types.js';
import { CommandKind } from './types.js';
export const authCommand: SlashCommand = {
name: 'auth',

View File

@@ -4,29 +4,22 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {
vi,
describe,
it,
expect,
beforeEach,
afterEach,
Mocked,
} from 'vitest';
import type { Mocked } from 'vitest';
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
type CommandContext,
import type {
MessageActionReturn,
SlashCommand,
CommandContext,
} from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { Content } from '@google/genai';
import { GeminiClient } from '@qwen-code/qwen-code-core';
import type { Content } from '@google/genai';
import type { GeminiClient } from '@qwen-code/qwen-code-core';
import * as fsPromises from 'fs/promises';
import * as fsPromises from 'node:fs/promises';
import { chatCommand } from './chatCommand.js';
import { Stats } from 'fs';
import { HistoryItemWithoutId } from '../types.js';
import type { Stats } from 'node:fs';
import type { HistoryItemWithoutId } from '../types.js';
vi.mock('fs/promises', () => ({
stat: vi.fn(),
@@ -67,11 +60,14 @@ describe('chatCommand', () => {
mockContext = createMockCommandContext({
services: {
config: {
getProjectTempDir: () => '/tmp/gemini',
getProjectRoot: () => '/project/root',
getGeminiClient: () =>
({
getChat: mockGetChat,
}) as unknown as GeminiClient,
storage: {
getProjectTempDir: () => '/project/root/.gemini/tmp/mockhash',
},
},
logger: {
saveCheckpoint: mockSaveCheckpoint,

View File

@@ -4,20 +4,21 @@
* SPDX-License-Identifier: Apache-2.0
*/
import * as fsPromises from 'fs/promises';
import * as fsPromises from 'node:fs/promises';
import React from 'react';
import { Text } from 'ink';
import { Colors } from '../colors.js';
import {
import type {
CommandContext,
SlashCommand,
MessageActionReturn,
CommandKind,
SlashCommandActionReturn,
} from './types.js';
import { CommandKind } from './types.js';
import { decodeTagName } from '@qwen-code/qwen-code-core';
import path from 'path';
import { HistoryItemWithoutId, MessageType } from '../types.js';
import path from 'node:path';
import type { HistoryItemWithoutId } from '../types.js';
import { MessageType } from '../types.js';
interface ChatDetail {
name: string;
@@ -28,7 +29,8 @@ const getSavedChatTags = async (
context: CommandContext,
mtSortDesc: boolean,
): Promise<ChatDetail[]> => {
const geminiDir = context.services.config?.getProjectTempDir();
const cfg = context.services.config;
const geminiDir = cfg?.storage?.getProjectTempDir();
if (!geminiDir) {
return [];
}

View File

@@ -4,7 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach, Mock } from 'vitest';
import type { Mock } from 'vitest';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { clearCommand } from './clearCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
@@ -20,7 +21,8 @@ vi.mock('@qwen-code/qwen-code-core', async () => {
};
});
import { GeminiClient, uiTelemetryService } from '@qwen-code/qwen-code-core';
import type { GeminiClient } from '@qwen-code/qwen-code-core';
import { uiTelemetryService } from '@qwen-code/qwen-code-core';
describe('clearCommand', () => {
let mockContext: CommandContext;

View File

@@ -5,7 +5,8 @@
*/
import { uiTelemetryService } from '@qwen-code/qwen-code-core';
import { CommandKind, SlashCommand } from './types.js';
import type { SlashCommand } from './types.js';
import { CommandKind } from './types.js';
export const clearCommand: SlashCommand = {
name: 'clear',

View File

@@ -4,7 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { GeminiClient } from '@qwen-code/qwen-code-core';
import {
CompressionStatus,
type ChatCompressionInfo,
type GeminiClient,
} from '@qwen-code/qwen-code-core';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { compressCommand } from './compressCommand.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
@@ -35,6 +39,7 @@ describe('compressCommand', () => {
isPending: true,
originalTokenCount: null,
newTokenCount: null,
compressionStatus: null,
},
};
await compressCommand.action!(context, '');
@@ -50,25 +55,24 @@ describe('compressCommand', () => {
});
it('should set pending item, call tryCompressChat, and add result on success', async () => {
const compressedResult = {
const compressedResult: ChatCompressionInfo = {
originalTokenCount: 200,
compressionStatus: CompressionStatus.COMPRESSED,
newTokenCount: 100,
};
mockTryCompressChat.mockResolvedValue(compressedResult);
await compressCommand.action!(context, '');
expect(context.ui.setPendingItem).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
type: MessageType.COMPRESSION,
compression: {
isPending: true,
originalTokenCount: null,
newTokenCount: null,
},
}),
);
expect(context.ui.setPendingItem).toHaveBeenNthCalledWith(1, {
type: MessageType.COMPRESSION,
compression: {
isPending: true,
compressionStatus: null,
originalTokenCount: null,
newTokenCount: null,
},
});
expect(mockTryCompressChat).toHaveBeenCalledWith(
expect.stringMatching(/^compress-\d+$/),
@@ -76,14 +80,15 @@ describe('compressCommand', () => {
);
expect(context.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
{
type: MessageType.COMPRESSION,
compression: {
isPending: false,
compressionStatus: CompressionStatus.COMPRESSED,
originalTokenCount: 200,
newTokenCount: 100,
},
}),
},
expect.any(Number),
);

View File

@@ -4,8 +4,10 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { HistoryItemCompression, MessageType } from '../types.js';
import { CommandKind, SlashCommand } from './types.js';
import type { HistoryItemCompression } from '../types.js';
import { MessageType } from '../types.js';
import type { SlashCommand } from './types.js';
import { CommandKind } from './types.js';
export const compressCommand: SlashCommand = {
name: 'compress',
@@ -31,6 +33,7 @@ export const compressCommand: SlashCommand = {
isPending: true,
originalTokenCount: null,
newTokenCount: null,
compressionStatus: null,
},
};
@@ -48,6 +51,7 @@ export const compressCommand: SlashCommand = {
isPending: false,
originalTokenCount: compressed.originalTokenCount,
newTokenCount: compressed.newTokenCount,
compressionStatus: compressed.compressionStatus,
},
} as HistoryItemCompression,
Date.now(),

View File

@@ -4,7 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach, Mock } from 'vitest';
import type { Mock } from 'vitest';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { copyCommand } from './copyCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
@@ -227,7 +228,7 @@ describe('copyCommand', () => {
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Failed to copy to the clipboard.',
content: `Failed to copy to the clipboard. ${clipboardError.message}`,
});
});
@@ -242,14 +243,15 @@ describe('copyCommand', () => {
];
mockGetHistory.mockReturnValue(historyWithAiMessage);
mockCopyToClipboard.mockRejectedValue('String error');
const rejectedValue = 'String error';
mockCopyToClipboard.mockRejectedValue(rejectedValue);
const result = await copyCommand.action(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Failed to copy to the clipboard.',
content: `Failed to copy to the clipboard. ${rejectedValue}`,
});
});

View File

@@ -5,11 +5,8 @@
*/
import { copyToClipboard } from '../utils/commandUtils.js';
import {
CommandKind,
SlashCommand,
SlashCommandActionReturn,
} from './types.js';
import type { SlashCommand, SlashCommandActionReturn } from './types.js';
import { CommandKind } from './types.js';
export const copyCommand: SlashCommand = {
name: 'copy',
@@ -53,7 +50,7 @@ export const copyCommand: SlashCommand = {
return {
type: 'message',
messageType: 'error',
content: 'Failed to copy to the clipboard.',
content: `Failed to copy to the clipboard. ${message}`,
};
}
} else {

View File

@@ -6,11 +6,11 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { directoryCommand, expandHomeDir } from './directoryCommand.js';
import { Config, WorkspaceContext } from '@qwen-code/qwen-code-core';
import { CommandContext } from './types.js';
import type { Config, WorkspaceContext } from '@qwen-code/qwen-code-core';
import type { CommandContext } from './types.js';
import { MessageType } from '../types.js';
import * as os from 'os';
import * as path from 'path';
import * as os from 'node:os';
import * as path from 'node:path';
describe('directoryCommand', () => {
let mockContext: CommandContext;

View File

@@ -4,10 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { SlashCommand, CommandContext, CommandKind } from './types.js';
import type { SlashCommand, CommandContext } from './types.js';
import { CommandKind } from './types.js';
import { MessageType } from '../types.js';
import * as os from 'os';
import * as path from 'path';
import * as os from 'node:os';
import * as path from 'node:path';
import { loadServerHierarchicalMemory } from '@qwen-code/qwen-code-core';
export function expandHomeDir(p: string): string {
@@ -91,34 +92,36 @@ export const directoryCommand: SlashCommand = {
}
}
if (added.length > 0) {
try {
if (config.shouldLoadMemoryFromIncludeDirectories()) {
const { memoryContent, fileCount } =
await loadServerHierarchicalMemory(
config.getWorkingDir(),
[...config.getWorkspaceContext().getDirectories()],
config.getDebugMode(),
config.getFileService(),
config.getExtensionContextFilePaths(),
context.services.settings.merged.memoryImportFormat || 'tree', // Use setting or default to 'tree'
config.getFileFilteringOptions(),
context.services.settings.merged.memoryDiscoveryMaxDirs,
);
config.setUserMemory(memoryContent);
config.setGeminiMdFileCount(fileCount);
context.ui.setGeminiMdFileCount(fileCount);
}
addItem(
{
type: MessageType.INFO,
text: `Successfully added memory files from the following directories if there are:\n- ${added.join('\n- ')}`,
},
Date.now(),
);
} catch (error) {
errors.push(`Error refreshing memory: ${(error as Error).message}`);
try {
if (config.shouldLoadMemoryFromIncludeDirectories()) {
const { memoryContent, fileCount } =
await loadServerHierarchicalMemory(
config.getWorkingDir(),
[
...config.getWorkspaceContext().getDirectories(),
...pathsToAdd,
],
config.getDebugMode(),
config.getFileService(),
config.getExtensionContextFilePaths(),
context.services.settings.merged.context?.importFormat ||
'tree', // Use setting or default to 'tree'
config.getFileFilteringOptions(),
context.services.settings.merged.context?.discoveryMaxDirs,
);
config.setUserMemory(memoryContent);
config.setGeminiMdFileCount(fileCount);
context.ui.setGeminiMdFileCount(fileCount);
}
addItem(
{
type: MessageType.INFO,
text: `Successfully added GEMINI.md files from the following directories if there are:\n- ${added.join('\n- ')}`,
},
Date.now(),
);
} catch (error) {
errors.push(`Error refreshing memory: ${(error as Error).message}`);
}
if (added.length > 0) {

View File

@@ -4,7 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { CommandKind, SlashCommand } from './types.js';
import type { SlashCommand } from './types.js';
import { CommandKind } from './types.js';
import { MessageType, type HistoryItemHelp } from '../types.js';
export const helpCommand: SlashCommand = {

View File

@@ -4,15 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {
MockInstance,
vi,
describe,
it,
expect,
beforeEach,
afterEach,
} from 'vitest';
import type { MockInstance } from 'vitest';
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { ideCommand } from './ideCommand.js';
import { type CommandContext } from './types.js';
import { type Config, DetectedIde } from '@qwen-code/qwen-code-core';
@@ -20,7 +13,14 @@ import * as core from '@qwen-code/qwen-code-core';
vi.mock('child_process');
vi.mock('glob');
vi.mock('@qwen-code/qwen-code-core');
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const original = await importOriginal<typeof core>();
return {
...original,
getOauthClient: vi.fn(original.getOauthClient),
getIdeInstaller: vi.fn(original.getIdeInstaller),
};
});
describe('ideCommand', () => {
let mockContext: CommandContext;

View File

@@ -4,24 +4,20 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { Config, IdeClient, File } from '@qwen-code/qwen-code-core';
import {
Config,
DetectedIde,
QWEN_CODE_COMPANION_EXTENSION_NAME,
IDEConnectionStatus,
getIdeInfo,
getIdeInstaller,
IdeClient,
type File,
IDEConnectionStatus,
ideContext,
} from '@qwen-code/qwen-code-core';
import path from 'node:path';
import {
import type {
CommandContext,
SlashCommand,
SlashCommandActionReturn,
CommandKind,
} from './types.js';
import { CommandKind } from './types.js';
import { SettingScope } from '../../config/settings.js';
function getIdeStatusMessage(ideClient: IdeClient): {
@@ -130,11 +126,7 @@ export const ideCommand = (config: Config | null): SlashCommand | null => {
({
type: 'message',
messageType: 'error',
content: `IDE integration is not supported in your current environment. To use this feature, run Qwen Code in one of these supported IDEs: ${Object.values(
DetectedIde,
)
.map((ide) => getIdeInfo(ide).displayName)
.join(', ')}`,
content: `IDE integration is not supported in your current environment. To use this feature, run Qwen Code in one of these supported IDEs: VS Code or VS Code forks.`,
}) as const,
};
}
@@ -195,7 +187,11 @@ export const ideCommand = (config: Config | null): SlashCommand | null => {
Date.now(),
);
if (result.success) {
context.services.settings.setValue(SettingScope.User, 'ideMode', true);
context.services.settings.setValue(
SettingScope.User,
'ide.enabled',
true,
);
// Poll for up to 5 seconds for the extension to activate.
for (let i = 0; i < 10; i++) {
await config.setIdeModeAndSyncConnection(true);
@@ -235,7 +231,11 @@ export const ideCommand = (config: Config | null): SlashCommand | null => {
description: 'enable IDE integration',
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext) => {
context.services.settings.setValue(SettingScope.User, 'ideMode', true);
context.services.settings.setValue(
SettingScope.User,
'ide.enabled',
true,
);
await config.setIdeModeAndSyncConnection(true);
const { messageType, content } = getIdeStatusMessage(ideClient);
context.ui.addItem(
@@ -253,7 +253,11 @@ export const ideCommand = (config: Config | null): SlashCommand | null => {
description: 'disable IDE integration',
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext) => {
context.services.settings.setValue(SettingScope.User, 'ideMode', false);
context.services.settings.setValue(
SettingScope.User,
'ide.enabled',
false,
);
await config.setIdeModeAndSyncConnection(false);
const { messageType, content } = getIdeStatusMessage(ideClient);
context.ui.addItem(

View File

@@ -5,8 +5,8 @@
*/
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { initCommand } from './initCommand.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { type CommandContext } from './types.js';

View File

@@ -4,15 +4,15 @@
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'fs';
import * as path from 'path';
import {
import * as fs from 'node:fs';
import * as path from 'node:path';
import type {
CommandContext,
SlashCommand,
SlashCommandActionReturn,
CommandKind,
} from './types.js';
import { getCurrentGeminiMdFilename } from '@qwen-code/qwen-code-core';
import { CommandKind } from './types.js';
export const initCommand: SlashCommand = {
name: 'init',

View File

@@ -15,8 +15,9 @@ import {
DiscoveredMCPTool,
} from '@qwen-code/qwen-code-core';
import { MessageActionReturn } from './types.js';
import { Type, CallableTool } from '@google/genai';
import type { MessageActionReturn } from './types.js';
import type { CallableTool } from '@google/genai';
import { Type } from '@google/genai';
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actual =

View File

@@ -4,15 +4,15 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {
import type {
SlashCommand,
SlashCommandActionReturn,
CommandContext,
CommandKind,
MessageActionReturn,
} from './types.js';
import { CommandKind } from './types.js';
import type { DiscoveredMCPPrompt } from '@qwen-code/qwen-code-core';
import {
DiscoveredMCPPrompt,
DiscoveredMCPTool,
getMCPDiscoveryState,
getMCPServerStatus,

View File

@@ -4,12 +4,13 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach, Mock } from 'vitest';
import type { Mock } from 'vitest';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { memoryCommand } from './memoryCommand.js';
import { type CommandContext, SlashCommand } from './types.js';
import type { SlashCommand, type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { MessageType } from '../types.js';
import { LoadedSettings } from '../../config/settings.js';
import type { LoadedSettings } from '../../config/settings.js';
import {
getErrorMessage,
loadServerHierarchicalMemory,

View File

@@ -13,11 +13,8 @@ import path from 'node:path';
import os from 'os';
import fs from 'fs/promises';
import { MessageType } from '../types.js';
import {
CommandKind,
SlashCommand,
SlashCommandActionReturn,
} from './types.js';
import type { SlashCommand, SlashCommandActionReturn } from './types.js';
import { CommandKind } from './types.js';
export const memoryCommand: SlashCommand = {
name: 'memory',
@@ -269,9 +266,10 @@ export const memoryCommand: SlashCommand = {
config.getDebugMode(),
config.getFileService(),
config.getExtensionContextFilePaths(),
context.services.settings.merged.memoryImportFormat || 'tree', // Use setting or default to 'tree'
context.services.settings.merged.context?.importFormat ||
'tree', // Use setting or default to 'tree'
config.getFileFilteringOptions(),
context.services.settings.merged.memoryDiscoveryMaxDirs,
context.services.settings.merged.context?.discoveryMaxDirs,
);
config.setUserMemory(memoryContent);
config.setGeminiMdFileCount(fileCount);

View File

@@ -4,7 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { CommandKind, OpenDialogActionReturn, SlashCommand } from './types.js';
import type { OpenDialogActionReturn, SlashCommand } from './types.js';
import { CommandKind } from './types.js';
export const privacyCommand: SlashCommand = {
name: 'privacy',

View File

@@ -5,13 +5,13 @@
*/
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs/promises';
import * as os from 'os';
import * as path from 'path';
import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import * as path from 'node:path';
import { restoreCommand } from './restoreCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { Config, GitService } from '@qwen-code/qwen-code-core';
import type { Config, GitService } from '@qwen-code/qwen-code-core';
describe('restoreCommand', () => {
let mockContext: CommandContext;
@@ -39,7 +39,10 @@ describe('restoreCommand', () => {
mockConfig = {
getCheckpointingEnabled: vi.fn().mockReturnValue(true),
getProjectTempDir: vi.fn().mockReturnValue(geminiTempDir),
storage: {
getProjectTempCheckpointsDir: vi.fn().mockReturnValue(checkpointsDir),
getProjectTempDir: vi.fn().mockReturnValue(geminiTempDir),
},
getGeminiClient: vi.fn().mockReturnValue({
setHistory: mockSetHistory,
}),
@@ -77,7 +80,9 @@ describe('restoreCommand', () => {
describe('action', () => {
it('should return an error if temp dir is not found', async () => {
vi.mocked(mockConfig.getProjectTempDir).mockReturnValue('');
vi.mocked(
mockConfig.storage.getProjectTempCheckpointsDir,
).mockReturnValue('');
expect(
await restoreCommand(mockConfig)?.action?.(mockContext, ''),
@@ -219,7 +224,7 @@ describe('restoreCommand', () => {
describe('completion', () => {
it('should return an empty array if temp dir is not found', async () => {
vi.mocked(mockConfig.getProjectTempDir).mockReturnValue('');
vi.mocked(mockConfig.storage.getProjectTempDir).mockReturnValue('');
const command = restoreCommand(mockConfig);
expect(await command?.completion?.(mockContext, '')).toEqual([]);

View File

@@ -4,15 +4,15 @@
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'fs/promises';
import path from 'path';
import * as fs from 'node:fs/promises';
import path from 'node:path';
import {
type CommandContext,
type SlashCommand,
type SlashCommandActionReturn,
CommandKind,
} from './types.js';
import { Config } from '@qwen-code/qwen-code-core';
import type { Config } from '@qwen-code/qwen-code-core';
async function restoreAction(
context: CommandContext,
@@ -22,9 +22,7 @@ async function restoreAction(
const { config, git: gitService } = services;
const { addItem, loadHistory } = ui;
const checkpointDir = config?.getProjectTempDir()
? path.join(config.getProjectTempDir(), 'checkpoints')
: undefined;
const checkpointDir = config?.storage.getProjectTempCheckpointsDir();
if (!checkpointDir) {
return {
@@ -125,9 +123,7 @@ async function completion(
): Promise<string[]> {
const { services } = context;
const { config } = services;
const checkpointDir = config?.getProjectTempDir()
? path.join(config.getProjectTempDir(), 'checkpoints')
: undefined;
const checkpointDir = config?.storage.getProjectTempCheckpointsDir();
if (!checkpointDir) {
return [];
}

View File

@@ -4,7 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { CommandKind, OpenDialogActionReturn, SlashCommand } from './types.js';
import type { OpenDialogActionReturn, SlashCommand } from './types.js';
import { CommandKind } from './types.js';
export const settingsCommand: SlashCommand = {
name: 'settings',

View File

@@ -15,7 +15,7 @@ import {
updateGitignore,
GITHUB_WORKFLOW_PATHS,
} from './setupGithubCommand.js';
import { CommandContext, ToolActionReturn } from './types.js';
import type { CommandContext, ToolActionReturn } from './types.js';
import * as commandUtils from '../utils/commandUtils.js';
vi.mock('child_process');

View File

@@ -9,7 +9,7 @@ import * as fs from 'node:fs';
import { Writable } from 'node:stream';
import { ProxyAgent } from 'undici';
import { CommandContext } from '../../ui/commands/types.js';
import type { CommandContext } from '../../ui/commands/types.js';
import {
getGitRepoRoot,
getLatestGitHubRelease,
@@ -17,11 +17,8 @@ import {
getGitHubRepoInfo,
} from '../../utils/gitUtils.js';
import {
CommandKind,
SlashCommand,
SlashCommandActionReturn,
} from './types.js';
import type { SlashCommand, SlashCommandActionReturn } from './types.js';
import { CommandKind } from './types.js';
import { getUrlOpenCommand } from '../../ui/utils/commandUtils.js';
export const GITHUB_WORKFLOW_PATHS = [

View File

@@ -4,7 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { MessageType, HistoryItemStats } from '../types.js';
import type { HistoryItemStats } from '../types.js';
import { MessageType } from '../types.js';
import { formatDuration } from '../utils/formatters.js';
import {
type CommandContext,

View File

@@ -7,7 +7,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { terminalSetupCommand } from './terminalSetupCommand.js';
import * as terminalSetupModule from '../utils/terminalSetup.js';
import { CommandContext } from './types.js';
import type { CommandContext } from './types.js';
vi.mock('../utils/terminalSetup.js');

View File

@@ -4,7 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { MessageActionReturn, SlashCommand, CommandKind } from './types.js';
import type { MessageActionReturn, SlashCommand } from './types.js';
import { CommandKind } from './types.js';
import { terminalSetup } from '../utils/terminalSetup.js';
/**

View File

@@ -4,7 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { CommandKind, OpenDialogActionReturn, SlashCommand } from './types.js';
import type { OpenDialogActionReturn, SlashCommand } from './types.js';
import { CommandKind } from './types.js';
export const themeCommand: SlashCommand = {
name: 'theme',

View File

@@ -4,11 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect } from 'vitest';
import { toolsCommand } from './toolsCommand.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { MessageType } from '../types.js';
import { Tool } from '@qwen-code/qwen-code-core';
import type { Tool } from '@qwen-code/qwen-code-core';
// Mock tools for testing
const mockTools = [

View File

@@ -13,7 +13,7 @@ import { MessageType } from '../types.js';
export const toolsCommand: SlashCommand = {
name: 'tools',
description: 'list available Qwen Codetools',
description: 'list available Qwen Code tools. Usage: /tools [desc]',
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext, args?: string): Promise<void> => {
const subCommand = args?.trim();

View File

@@ -5,13 +5,12 @@
*/
import { type ReactNode } from 'react';
import { Content } from '@google/genai';
import { HistoryItemWithoutId } from '../types.js';
import { Config, GitService, Logger } from '@qwen-code/qwen-code-core';
import { LoadedSettings } from '../../config/settings.js';
import { UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
import type { HistoryItem } from '../types.js';
import { SessionStatsState } from '../contexts/SessionContext.js';
import type { Content, PartListUnion } from '@google/genai';
import type { HistoryItemWithoutId, HistoryItem } from '../types.js';
import type { Config, GitService, Logger } from '@qwen-code/qwen-code-core';
import type { LoadedSettings } from '../../config/settings.js';
import type { UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
import type { SessionStatsState } from '../contexts/SessionContext.js';
// Grouped dependencies for clarity and easier mocking
export interface CommandContext {
@@ -123,7 +122,7 @@ export interface LoadHistoryActionReturn {
*/
export interface SubmitPromptActionReturn {
type: 'submit_prompt';
content: string;
content: PartListUnion;
}
/**

View File

@@ -4,7 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { CommandKind, SlashCommand } from './types.js';
import type { SlashCommand } from './types.js';
import { CommandKind } from './types.js';
export const vimCommand: SlashCommand = {
name: 'vim',

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import type React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';

View File

@@ -18,7 +18,7 @@ describe('AuthDialog', () => {
beforeEach(() => {
originalEnv = { ...process.env };
process.env['GEMINI_API_KEY'] = '';
process.env['GEMINI_DEFAULT_AUTH_TYPE'] = '';
process.env['QWEN_DEFAULT_AUTH_TYPE'] = '';
vi.clearAllMocks();
});
@@ -31,20 +31,30 @@ describe('AuthDialog', () => {
const settings: LoadedSettings = new LoadedSettings(
{
settings: { customThemes: {}, mcpServers: {} },
settings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
{
settings: {},
path: '',
},
{
settings: {
selectedAuthType: AuthType.USE_GEMINI,
security: {
auth: {
selectedType: AuthType.USE_GEMINI,
},
},
},
path: '',
},
{
settings: { customThemes: {}, mcpServers: {} },
settings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
[],
true,
new Set(),
);
const { lastFrame } = renderWithProviders(
@@ -67,21 +77,27 @@ describe('AuthDialog', () => {
const settings: LoadedSettings = new LoadedSettings(
{
settings: {
selectedAuthType: undefined,
customThemes: {},
security: { auth: { selectedType: undefined } },
ui: { customThemes: {} },
mcpServers: {},
},
path: '',
},
{
settings: { customThemes: {}, mcpServers: {} },
settings: {},
path: '',
},
{
settings: { customThemes: {}, mcpServers: {} },
settings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
{
settings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
[],
true,
new Set(),
);
const { lastFrame } = renderWithProviders(
@@ -93,28 +109,34 @@ describe('AuthDialog', () => {
expect(lastFrame()).toContain('OpenAI');
});
it('should not show the GEMINI_API_KEY message if GEMINI_DEFAULT_AUTH_TYPE is set to something else', () => {
it('should not show the GEMINI_API_KEY message if QWEN_DEFAULT_AUTH_TYPE is set to something else', () => {
process.env['GEMINI_API_KEY'] = 'foobar';
process.env['GEMINI_DEFAULT_AUTH_TYPE'] = AuthType.LOGIN_WITH_GOOGLE;
process.env['QWEN_DEFAULT_AUTH_TYPE'] = AuthType.LOGIN_WITH_GOOGLE;
const settings: LoadedSettings = new LoadedSettings(
{
settings: {
selectedAuthType: undefined,
customThemes: {},
security: { auth: { selectedType: undefined } },
ui: { customThemes: {} },
mcpServers: {},
},
path: '',
},
{
settings: { customThemes: {}, mcpServers: {} },
settings: {},
path: '',
},
{
settings: { customThemes: {}, mcpServers: {} },
settings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
{
settings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
[],
true,
new Set(),
);
const { lastFrame } = renderWithProviders(
@@ -126,28 +148,34 @@ describe('AuthDialog', () => {
);
});
it('should show the GEMINI_API_KEY message if GEMINI_DEFAULT_AUTH_TYPE is set to use api key', () => {
it('should show the GEMINI_API_KEY message if QWEN_DEFAULT_AUTH_TYPE is set to use api key', () => {
process.env['GEMINI_API_KEY'] = 'foobar';
process.env['GEMINI_DEFAULT_AUTH_TYPE'] = AuthType.USE_GEMINI;
process.env['QWEN_DEFAULT_AUTH_TYPE'] = AuthType.USE_GEMINI;
const settings: LoadedSettings = new LoadedSettings(
{
settings: {
selectedAuthType: undefined,
customThemes: {},
security: { auth: { selectedType: undefined } },
ui: { customThemes: {} },
mcpServers: {},
},
path: '',
},
{
settings: { customThemes: {}, mcpServers: {} },
settings: {},
path: '',
},
{
settings: { customThemes: {}, mcpServers: {} },
settings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
{
settings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
[],
true,
new Set(),
);
const { lastFrame } = renderWithProviders(
@@ -160,28 +188,34 @@ describe('AuthDialog', () => {
});
});
describe('GEMINI_DEFAULT_AUTH_TYPE environment variable', () => {
it('should select the auth type specified by GEMINI_DEFAULT_AUTH_TYPE', () => {
process.env['GEMINI_DEFAULT_AUTH_TYPE'] = AuthType.USE_OPENAI;
describe('QWEN_DEFAULT_AUTH_TYPE environment variable', () => {
it('should select the auth type specified by QWEN_DEFAULT_AUTH_TYPE', () => {
process.env['QWEN_DEFAULT_AUTH_TYPE'] = AuthType.USE_OPENAI;
const settings: LoadedSettings = new LoadedSettings(
{
settings: {
selectedAuthType: undefined,
customThemes: {},
security: { auth: { selectedType: undefined } },
ui: { customThemes: {} },
mcpServers: {},
},
path: '',
},
{
settings: { customThemes: {}, mcpServers: {} },
settings: {},
path: '',
},
{
settings: { customThemes: {}, mcpServers: {} },
settings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
{
settings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
[],
true,
new Set(),
);
const { lastFrame } = renderWithProviders(
@@ -192,25 +226,31 @@ describe('AuthDialog', () => {
expect(lastFrame()).toContain('● 2. OpenAI');
});
it('should fall back to default if GEMINI_DEFAULT_AUTH_TYPE is not set', () => {
it('should fall back to default if QWEN_DEFAULT_AUTH_TYPE is not set', () => {
const settings: LoadedSettings = new LoadedSettings(
{
settings: {
selectedAuthType: undefined,
customThemes: {},
security: { auth: { selectedType: undefined } },
ui: { customThemes: {} },
mcpServers: {},
},
path: '',
},
{
settings: { customThemes: {}, mcpServers: {} },
settings: {},
path: '',
},
{
settings: { customThemes: {}, mcpServers: {} },
settings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
{
settings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
[],
true,
new Set(),
);
const { lastFrame } = renderWithProviders(
@@ -221,59 +261,116 @@ describe('AuthDialog', () => {
expect(lastFrame()).toContain('● 1. Qwen OAuth');
});
it('should show an error and fall back to default if GEMINI_DEFAULT_AUTH_TYPE is invalid', () => {
process.env['GEMINI_DEFAULT_AUTH_TYPE'] = 'invalid-auth-type';
it('should show an error and fall back to default if QWEN_DEFAULT_AUTH_TYPE is invalid', () => {
process.env['QWEN_DEFAULT_AUTH_TYPE'] = 'invalid-auth-type';
const settings: LoadedSettings = new LoadedSettings(
{
settings: {
selectedAuthType: undefined,
customThemes: {},
security: { auth: { selectedType: undefined } },
ui: { customThemes: {} },
mcpServers: {},
},
path: '',
},
{
settings: { customThemes: {}, mcpServers: {} },
settings: {},
path: '',
},
{
settings: { customThemes: {}, mcpServers: {} },
settings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
{
settings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
[],
true,
new Set(),
);
const { lastFrame } = renderWithProviders(
<AuthDialog onSelect={() => {}} settings={settings} />,
);
// Since the auth dialog doesn't show GEMINI_DEFAULT_AUTH_TYPE errors anymore,
// Since the auth dialog doesn't show QWEN_DEFAULT_AUTH_TYPE errors anymore,
// it will just show the default Qwen OAuth option
expect(lastFrame()).toContain('● 1. Qwen OAuth');
});
});
it('should prevent exiting when no auth method is selected and show error message', async () => {
const onSelect = vi.fn();
const settings: LoadedSettings = new LoadedSettings(
{
settings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
{
settings: {},
path: '',
},
{
settings: {
security: { auth: { selectedType: undefined } },
ui: { customThemes: {} },
mcpServers: {},
},
path: '',
},
{
settings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
[],
true,
new Set(),
);
const { lastFrame, stdin, unmount } = renderWithProviders(
<AuthDialog onSelect={onSelect} settings={settings} />,
);
await wait();
// Simulate pressing escape key
stdin.write('\u001b'); // ESC key
await wait();
// Should show error message instead of calling onSelect
expect(lastFrame()).toContain(
'You must select an auth method to proceed. Press Ctrl+C twice to exit.',
);
expect(onSelect).not.toHaveBeenCalled();
unmount();
});
it('should not exit if there is already an error message', async () => {
const onSelect = vi.fn();
const settings: LoadedSettings = new LoadedSettings(
{
settings: { customThemes: {}, mcpServers: {} },
settings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
{
settings: {},
path: '',
},
{
settings: {
selectedAuthType: undefined,
customThemes: {},
security: { auth: { selectedType: undefined } },
ui: { customThemes: {} },
mcpServers: {},
},
path: '',
},
{
settings: { customThemes: {}, mcpServers: {} },
settings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
[],
true,
new Set(),
);
const { lastFrame, stdin, unmount } = renderWithProviders(
@@ -300,22 +397,28 @@ describe('AuthDialog', () => {
const onSelect = vi.fn();
const settings: LoadedSettings = new LoadedSettings(
{
settings: { customThemes: {}, mcpServers: {} },
settings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
{
settings: {},
path: '',
},
{
settings: {
selectedAuthType: AuthType.USE_GEMINI,
customThemes: {},
security: { auth: { selectedType: AuthType.LOGIN_WITH_GOOGLE } },
ui: { customThemes: {} },
mcpServers: {},
},
path: '',
},
{
settings: { customThemes: {}, mcpServers: {} },
settings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
[],
true,
new Set(),
);
const { stdin, unmount } = renderWithProviders(

View File

@@ -4,16 +4,17 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useState } from 'react';
import { AuthType } from '@qwen-code/qwen-code-core';
import { Box, Text } from 'ink';
import React, { useState } from 'react';
import {
setOpenAIApiKey,
setOpenAIBaseUrl,
setOpenAIModel,
validateAuthMethod,
} from '../../config/auth.js';
import { LoadedSettings, SettingScope } from '../../config/settings.js';
import { type LoadedSettings, SettingScope } from '../../config/settings.js';
import { Colors } from '../colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { OpenAIKeyPrompt } from './OpenAIKeyPrompt.js';
@@ -54,12 +55,12 @@ export function AuthDialog({
const initialAuthIndex = Math.max(
0,
items.findIndex((item) => {
if (settings.merged.selectedAuthType) {
return item.value === settings.merged.selectedAuthType;
if (settings.merged.security?.auth?.selectedType) {
return item.value === settings.merged.security?.auth?.selectedType;
}
const defaultAuthType = parseDefaultAuthType(
process.env['GEMINI_DEFAULT_AUTH_TYPE'],
process.env['QWEN_DEFAULT_AUTH_TYPE'],
);
if (defaultAuthType) {
return item.value === defaultAuthType;
@@ -120,7 +121,7 @@ export function AuthDialog({
if (errorMessage) {
return;
}
if (settings.merged.selectedAuthType === undefined) {
if (settings.merged.security?.auth?.selectedType === undefined) {
// Prevent exiting if no auth method is set
setErrorMessage(
'You must select an auth method to proceed. Press Ctrl+C twice to exit.',

View File

@@ -4,7 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState, useEffect } from 'react';
import type React from 'react';
import { useState, useEffect } from 'react';
import { Box, Text } from 'ink';
import Spinner from 'ink-spinner';
import { Colors } from '../colors.js';

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import type React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { ApprovalMode } from '@qwen-code/qwen-code-core';

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import type React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import type React from 'react';
import { render } from 'ink-testing-library';
import { describe, it, expect, vi } from 'vitest';
import { ContextSummaryDisplay } from './ContextSummaryDisplay.js';

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import type React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import {

Some files were not shown because too many files have changed in this diff Show More