mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-21 09:17:53 +00:00
Sync upstream Gemini-CLI v0.8.2 (#838)
This commit is contained in:
@@ -23,17 +23,35 @@ vi.mock('../ui/commands/approvalModeCommand.js', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../ui/commands/ideCommand.js', () => ({ ideCommand: vi.fn() }));
|
||||
vi.mock('../ui/commands/ideCommand.js', async () => {
|
||||
const { CommandKind } = await import('../ui/commands/types.js');
|
||||
return {
|
||||
ideCommand: vi.fn().mockResolvedValue({
|
||||
name: 'ide',
|
||||
description: 'IDE command',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock('../ui/commands/restoreCommand.js', () => ({
|
||||
restoreCommand: vi.fn(),
|
||||
}));
|
||||
vi.mock('../ui/commands/permissionsCommand.js', async () => {
|
||||
const { CommandKind } = await import('../ui/commands/types.js');
|
||||
return {
|
||||
permissionsCommand: {
|
||||
name: 'permissions',
|
||||
description: 'Permissions command',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import { BuiltinCommandLoader } from './BuiltinCommandLoader.js';
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import { CommandKind } from '../ui/commands/types.js';
|
||||
|
||||
import { ideCommand } from '../ui/commands/ideCommand.js';
|
||||
import { restoreCommand } from '../ui/commands/restoreCommand.js';
|
||||
|
||||
vi.mock('../ui/commands/authCommand.js', () => ({ authCommand: {} }));
|
||||
@@ -49,7 +67,9 @@ vi.mock('../ui/commands/extensionsCommand.js', () => ({
|
||||
}));
|
||||
vi.mock('../ui/commands/helpCommand.js', () => ({ helpCommand: {} }));
|
||||
vi.mock('../ui/commands/memoryCommand.js', () => ({ memoryCommand: {} }));
|
||||
vi.mock('../ui/commands/privacyCommand.js', () => ({ privacyCommand: {} }));
|
||||
vi.mock('../ui/commands/modelCommand.js', () => ({
|
||||
modelCommand: { name: 'model' },
|
||||
}));
|
||||
vi.mock('../ui/commands/quitCommand.js', () => ({
|
||||
quitCommand: {},
|
||||
quitConfirmCommand: {},
|
||||
@@ -75,18 +95,15 @@ vi.mock('../ui/commands/modelCommand.js', () => ({
|
||||
describe('BuiltinCommandLoader', () => {
|
||||
let mockConfig: Config;
|
||||
|
||||
const ideCommandMock = ideCommand as Mock;
|
||||
const restoreCommandMock = restoreCommand as Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockConfig = { some: 'config' } as unknown as Config;
|
||||
mockConfig = {
|
||||
getFolderTrust: vi.fn().mockReturnValue(true),
|
||||
getUseModelRouter: () => false,
|
||||
} as unknown as Config;
|
||||
|
||||
ideCommandMock.mockReturnValue({
|
||||
name: 'ide',
|
||||
description: 'IDE command',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
});
|
||||
restoreCommandMock.mockReturnValue({
|
||||
name: 'restore',
|
||||
description: 'Restore command',
|
||||
@@ -94,25 +111,23 @@ describe('BuiltinCommandLoader', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should correctly pass the config object to command factory functions', async () => {
|
||||
it('should correctly pass the config object to restore command factory', async () => {
|
||||
const loader = new BuiltinCommandLoader(mockConfig);
|
||||
await loader.loadCommands(new AbortController().signal);
|
||||
|
||||
expect(ideCommandMock).toHaveBeenCalledTimes(1);
|
||||
expect(ideCommandMock).toHaveBeenCalledWith(mockConfig);
|
||||
// ideCommand is now a constant, no longer needs config
|
||||
expect(restoreCommandMock).toHaveBeenCalledTimes(1);
|
||||
expect(restoreCommandMock).toHaveBeenCalledWith(mockConfig);
|
||||
});
|
||||
|
||||
it('should filter out null command definitions returned by factories', async () => {
|
||||
// Override the mock's behavior for this specific test.
|
||||
ideCommandMock.mockReturnValue(null);
|
||||
// ideCommand is now a constant SlashCommand
|
||||
const loader = new BuiltinCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(new AbortController().signal);
|
||||
|
||||
// The 'ide' command should be filtered out.
|
||||
// The 'ide' command should be present.
|
||||
const ideCmd = commands.find((c) => c.name === 'ide');
|
||||
expect(ideCmd).toBeUndefined();
|
||||
expect(ideCmd).toBeDefined();
|
||||
|
||||
// Other commands should still be present.
|
||||
const aboutCmd = commands.find((c) => c.name === 'about');
|
||||
@@ -122,8 +137,7 @@ describe('BuiltinCommandLoader', () => {
|
||||
it('should handle a null config gracefully when calling factories', async () => {
|
||||
const loader = new BuiltinCommandLoader(null);
|
||||
await loader.loadCommands(new AbortController().signal);
|
||||
expect(ideCommandMock).toHaveBeenCalledTimes(1);
|
||||
expect(ideCommandMock).toHaveBeenCalledWith(null);
|
||||
// ideCommand is now a constant, no longer needs config
|
||||
expect(restoreCommandMock).toHaveBeenCalledTimes(1);
|
||||
expect(restoreCommandMock).toHaveBeenCalledWith(null);
|
||||
});
|
||||
@@ -149,4 +163,27 @@ describe('BuiltinCommandLoader', () => {
|
||||
const modelCmd = commands.find((c) => c.name === 'model');
|
||||
expect(modelCmd).toBeDefined();
|
||||
});
|
||||
|
||||
it('should include permissions command when folder trust is enabled', async () => {
|
||||
const loader = new BuiltinCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(new AbortController().signal);
|
||||
const permissionsCmd = commands.find((c) => c.name === 'permissions');
|
||||
expect(permissionsCmd).toBeDefined();
|
||||
});
|
||||
|
||||
it('should exclude permissions command when folder trust is disabled', async () => {
|
||||
(mockConfig.getFolderTrust as Mock).mockReturnValue(false);
|
||||
const loader = new BuiltinCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(new AbortController().signal);
|
||||
const permissionsCmd = commands.find((c) => c.name === 'permissions');
|
||||
expect(permissionsCmd).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should always include modelCommand', async () => {
|
||||
const loader = new BuiltinCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(new AbortController().signal);
|
||||
const modelCmd = commands.find((c) => c.name === 'model');
|
||||
expect(modelCmd).toBeDefined();
|
||||
expect(modelCmd?.name).toBe('model');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,7 +27,7 @@ import { initCommand } from '../ui/commands/initCommand.js';
|
||||
import { mcpCommand } from '../ui/commands/mcpCommand.js';
|
||||
import { memoryCommand } from '../ui/commands/memoryCommand.js';
|
||||
import { modelCommand } from '../ui/commands/modelCommand.js';
|
||||
import { privacyCommand } from '../ui/commands/privacyCommand.js';
|
||||
import { permissionsCommand } from '../ui/commands/permissionsCommand.js';
|
||||
import { quitCommand, quitConfirmCommand } from '../ui/commands/quitCommand.js';
|
||||
import { restoreCommand } from '../ui/commands/restoreCommand.js';
|
||||
import { settingsCommand } from '../ui/commands/settingsCommand.js';
|
||||
@@ -70,12 +70,12 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
editorCommand,
|
||||
extensionsCommand,
|
||||
helpCommand,
|
||||
ideCommand(this.config),
|
||||
await ideCommand(),
|
||||
initCommand,
|
||||
mcpCommand,
|
||||
memoryCommand,
|
||||
modelCommand,
|
||||
privacyCommand,
|
||||
...(this.config?.getFolderTrust() ? [permissionsCommand] : []),
|
||||
quitCommand,
|
||||
quitConfirmCommand,
|
||||
restoreCommand(this.config),
|
||||
|
||||
@@ -224,6 +224,8 @@ describe('FileCommandLoader', () => {
|
||||
const mockConfig = {
|
||||
getProjectRoot: vi.fn(() => '/path/to/project'),
|
||||
getExtensions: vi.fn(() => []),
|
||||
getFolderTrustFeature: vi.fn(() => false),
|
||||
getFolderTrust: vi.fn(() => false),
|
||||
} as unknown as Config;
|
||||
const loader = new FileCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
@@ -267,6 +269,8 @@ describe('FileCommandLoader', () => {
|
||||
const mockConfig = {
|
||||
getProjectRoot: vi.fn(() => process.cwd()),
|
||||
getExtensions: vi.fn(() => []),
|
||||
getFolderTrustFeature: vi.fn(() => false),
|
||||
getFolderTrust: vi.fn(() => false),
|
||||
} as unknown as Config;
|
||||
const loader = new FileCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
@@ -556,6 +560,8 @@ describe('FileCommandLoader', () => {
|
||||
path: extensionDir,
|
||||
},
|
||||
]),
|
||||
getFolderTrustFeature: vi.fn(() => false),
|
||||
getFolderTrust: vi.fn(() => false),
|
||||
} as unknown as Config;
|
||||
const loader = new FileCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
@@ -607,6 +613,8 @@ describe('FileCommandLoader', () => {
|
||||
path: extensionDir,
|
||||
},
|
||||
]),
|
||||
getFolderTrustFeature: vi.fn(() => false),
|
||||
getFolderTrust: vi.fn(() => false),
|
||||
} as unknown as Config;
|
||||
const loader = new FileCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
@@ -714,6 +722,8 @@ describe('FileCommandLoader', () => {
|
||||
path: extensionDir2,
|
||||
},
|
||||
]),
|
||||
getFolderTrustFeature: vi.fn(() => false),
|
||||
getFolderTrust: vi.fn(() => false),
|
||||
} as unknown as Config;
|
||||
const loader = new FileCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
@@ -750,6 +760,8 @@ describe('FileCommandLoader', () => {
|
||||
path: extensionDir,
|
||||
},
|
||||
]),
|
||||
getFolderTrustFeature: vi.fn(() => false),
|
||||
getFolderTrust: vi.fn(() => false),
|
||||
} as unknown as Config;
|
||||
const loader = new FileCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
@@ -782,6 +794,8 @@ describe('FileCommandLoader', () => {
|
||||
getExtensions: vi.fn(() => [
|
||||
{ name: 'a', version: '1.0.0', isActive: true, path: extensionDir },
|
||||
]),
|
||||
getFolderTrustFeature: vi.fn(() => false),
|
||||
getFolderTrust: vi.fn(() => false),
|
||||
} as unknown as Config;
|
||||
const loader = new FileCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
@@ -1169,4 +1183,48 @@ describe('FileCommandLoader', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('with folder trust enabled', () => {
|
||||
it('loads multiple commands', async () => {
|
||||
const mockConfig = {
|
||||
getProjectRoot: vi.fn(() => '/path/to/project'),
|
||||
getExtensions: vi.fn(() => []),
|
||||
getFolderTrustFeature: vi.fn(() => true),
|
||||
getFolderTrust: vi.fn(() => true),
|
||||
} as unknown as Config;
|
||||
const userCommandsDir = Storage.getUserCommandsDir();
|
||||
mock({
|
||||
[userCommandsDir]: {
|
||||
'test1.toml': 'prompt = "Prompt 1"',
|
||||
'test2.toml': 'prompt = "Prompt 2"',
|
||||
},
|
||||
});
|
||||
|
||||
const loader = new FileCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
|
||||
expect(commands).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('does not load when folder is not trusted', async () => {
|
||||
const mockConfig = {
|
||||
getProjectRoot: vi.fn(() => '/path/to/project'),
|
||||
getExtensions: vi.fn(() => []),
|
||||
getFolderTrustFeature: vi.fn(() => true),
|
||||
getFolderTrust: vi.fn(() => false),
|
||||
} as unknown as Config;
|
||||
const userCommandsDir = Storage.getUserCommandsDir();
|
||||
mock({
|
||||
[userCommandsDir]: {
|
||||
'test1.toml': 'prompt = "Prompt 1"',
|
||||
'test2.toml': 'prompt = "Prompt 2"',
|
||||
},
|
||||
});
|
||||
|
||||
const loader = new FileCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
|
||||
expect(commands).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,8 +63,12 @@ const TomlCommandDefSchema = z.object({
|
||||
*/
|
||||
export class FileCommandLoader implements ICommandLoader {
|
||||
private readonly projectRoot: string;
|
||||
private readonly folderTrustEnabled: boolean;
|
||||
private readonly folderTrust: boolean;
|
||||
|
||||
constructor(private readonly config: Config | null) {
|
||||
this.folderTrustEnabled = !!config?.getFolderTrustFeature();
|
||||
this.folderTrust = !!config?.getFolderTrust();
|
||||
this.projectRoot = config?.getProjectRoot() || process.cwd();
|
||||
}
|
||||
|
||||
@@ -97,6 +101,10 @@ export class FileCommandLoader implements ICommandLoader {
|
||||
cwd: dirInfo.path,
|
||||
});
|
||||
|
||||
if (this.folderTrustEnabled && !this.folderTrust) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const commandPromises = files.map((file) =>
|
||||
this.parseAndAdaptFile(
|
||||
path.join(dirInfo.path, file),
|
||||
|
||||
@@ -7,11 +7,42 @@
|
||||
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';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { CommandKind, type CommandContext } from '../ui/commands/types.js';
|
||||
import * as cliCore from '@qwen-code/qwen-code-core';
|
||||
|
||||
// Define the mock prompt data at a higher scope
|
||||
const mockPrompt = {
|
||||
name: 'test-prompt',
|
||||
description: 'A test prompt.',
|
||||
serverName: 'test-server',
|
||||
arguments: [
|
||||
{ name: 'name', required: true, description: "The animal's name." },
|
||||
{ name: 'age', required: true, description: "The animal's age." },
|
||||
{ name: 'species', required: true, description: "The animal's species." },
|
||||
{
|
||||
name: 'enclosure',
|
||||
required: false,
|
||||
description: "The animal's enclosure.",
|
||||
},
|
||||
{ name: 'trail', required: false, description: "The animal's trail." },
|
||||
],
|
||||
invoke: vi.fn().mockResolvedValue({
|
||||
messages: [{ content: { text: 'Hello, world!' } }],
|
||||
}),
|
||||
};
|
||||
|
||||
describe('McpPromptLoader', () => {
|
||||
const mockConfig = {} as Config;
|
||||
|
||||
// Use a beforeEach to set up and clean a spy for each test
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(cliCore, 'getMCPServerPrompts').mockReturnValue([mockPrompt]);
|
||||
});
|
||||
|
||||
// --- `parseArgs` tests remain the same ---
|
||||
|
||||
describe('parseArgs', () => {
|
||||
it('should handle multi-word positional arguments', () => {
|
||||
const loader = new McpPromptLoader(mockConfig);
|
||||
@@ -125,4 +156,244 @@ describe('McpPromptLoader', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadCommands', () => {
|
||||
const mockConfigWithPrompts = {
|
||||
getMcpServers: () => ({
|
||||
'test-server': { httpUrl: 'https://test-server.com' },
|
||||
}),
|
||||
} as unknown as Config;
|
||||
|
||||
it('should load prompts as slash commands', async () => {
|
||||
const loader = new McpPromptLoader(mockConfigWithPrompts);
|
||||
const commands = await loader.loadCommands(new AbortController().signal);
|
||||
expect(commands).toHaveLength(1);
|
||||
expect(commands[0].name).toBe('test-prompt');
|
||||
expect(commands[0].description).toBe('A test prompt.');
|
||||
expect(commands[0].kind).toBe(CommandKind.MCP_PROMPT);
|
||||
});
|
||||
|
||||
it('should handle prompt invocation successfully', async () => {
|
||||
const loader = new McpPromptLoader(mockConfigWithPrompts);
|
||||
const commands = await loader.loadCommands(new AbortController().signal);
|
||||
const action = commands[0].action!;
|
||||
const context = {} as CommandContext;
|
||||
const result = await action(context, 'test-name 123 tiger');
|
||||
expect(mockPrompt.invoke).toHaveBeenCalledWith({
|
||||
name: 'test-name',
|
||||
age: '123',
|
||||
species: 'tiger',
|
||||
});
|
||||
expect(result).toEqual({
|
||||
type: 'submit_prompt',
|
||||
content: JSON.stringify('Hello, world!'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should return an error for missing required arguments', async () => {
|
||||
const loader = new McpPromptLoader(mockConfigWithPrompts);
|
||||
const commands = await loader.loadCommands(new AbortController().signal);
|
||||
const action = commands[0].action!;
|
||||
const context = {} as CommandContext;
|
||||
const result = await action(context, 'test-name');
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Missing required argument(s): --age, --species',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return an error message if prompt invocation fails', async () => {
|
||||
vi.spyOn(mockPrompt, 'invoke').mockRejectedValue(
|
||||
new Error('Invocation failed!'),
|
||||
);
|
||||
const loader = new McpPromptLoader(mockConfigWithPrompts);
|
||||
const commands = await loader.loadCommands(new AbortController().signal);
|
||||
const action = commands[0].action!;
|
||||
const context = {} as CommandContext;
|
||||
const result = await action(context, 'test-name 123 tiger');
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Error: Invocation failed!',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return an empty array if config is not available', async () => {
|
||||
const loader = new McpPromptLoader(null);
|
||||
const commands = await loader.loadCommands(new AbortController().signal);
|
||||
expect(commands).toEqual([]);
|
||||
});
|
||||
|
||||
describe('completion', () => {
|
||||
it('should suggest no arguments when using positional arguments', async () => {
|
||||
const loader = new McpPromptLoader(mockConfigWithPrompts);
|
||||
const commands = await loader.loadCommands(
|
||||
new AbortController().signal,
|
||||
);
|
||||
const completion = commands[0].completion!;
|
||||
const context = {} as CommandContext;
|
||||
const suggestions = await completion(context, 'test-name 6 tiger');
|
||||
expect(suggestions).toEqual([]);
|
||||
});
|
||||
|
||||
it('should suggest all arguments when none are present', async () => {
|
||||
const loader = new McpPromptLoader(mockConfigWithPrompts);
|
||||
const commands = await loader.loadCommands(
|
||||
new AbortController().signal,
|
||||
);
|
||||
const completion = commands[0].completion!;
|
||||
const context = {
|
||||
invocation: {
|
||||
raw: '/find ',
|
||||
name: 'find',
|
||||
args: '',
|
||||
},
|
||||
} as CommandContext;
|
||||
const suggestions = await completion(context, '');
|
||||
expect(suggestions).toEqual([
|
||||
'--name="',
|
||||
'--age="',
|
||||
'--species="',
|
||||
'--enclosure="',
|
||||
'--trail="',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should suggest remaining arguments when some are present', async () => {
|
||||
const loader = new McpPromptLoader(mockConfigWithPrompts);
|
||||
const commands = await loader.loadCommands(
|
||||
new AbortController().signal,
|
||||
);
|
||||
const completion = commands[0].completion!;
|
||||
const context = {
|
||||
invocation: {
|
||||
raw: '/find --name="test-name" --age="6" ',
|
||||
name: 'find',
|
||||
args: '--name="test-name" --age="6"',
|
||||
},
|
||||
} as CommandContext;
|
||||
const suggestions = await completion(context, '');
|
||||
expect(suggestions).toEqual([
|
||||
'--species="',
|
||||
'--enclosure="',
|
||||
'--trail="',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should suggest no arguments when all are present', async () => {
|
||||
const loader = new McpPromptLoader(mockConfigWithPrompts);
|
||||
const commands = await loader.loadCommands(
|
||||
new AbortController().signal,
|
||||
);
|
||||
const completion = commands[0].completion!;
|
||||
const context = {} as CommandContext;
|
||||
const suggestions = await completion(
|
||||
context,
|
||||
'--name="test-name" --age="6" --species="tiger" --enclosure="Tiger Den" --trail="Jungle"',
|
||||
);
|
||||
expect(suggestions).toEqual([]);
|
||||
});
|
||||
|
||||
it('should suggest nothing for prompts with no arguments', async () => {
|
||||
// Temporarily override the mock to return a prompt with no args
|
||||
vi.spyOn(cliCore, 'getMCPServerPrompts').mockReturnValue([
|
||||
{ ...mockPrompt, arguments: [] },
|
||||
]);
|
||||
const loader = new McpPromptLoader(mockConfigWithPrompts);
|
||||
const commands = await loader.loadCommands(
|
||||
new AbortController().signal,
|
||||
);
|
||||
const completion = commands[0].completion!;
|
||||
const context = {} as CommandContext;
|
||||
const suggestions = await completion(context, '');
|
||||
expect(suggestions).toEqual([]);
|
||||
});
|
||||
|
||||
it('should suggest arguments matching a partial argument', async () => {
|
||||
const loader = new McpPromptLoader(mockConfigWithPrompts);
|
||||
const commands = await loader.loadCommands(
|
||||
new AbortController().signal,
|
||||
);
|
||||
const completion = commands[0].completion!;
|
||||
const context = {
|
||||
invocation: {
|
||||
raw: '/find --s',
|
||||
name: 'find',
|
||||
args: '--s',
|
||||
},
|
||||
} as CommandContext;
|
||||
const suggestions = await completion(context, '--s');
|
||||
expect(suggestions).toEqual(['--species="']);
|
||||
});
|
||||
|
||||
it('should suggest arguments even when a partial argument is parsed as a value', async () => {
|
||||
const loader = new McpPromptLoader(mockConfigWithPrompts);
|
||||
const commands = await loader.loadCommands(
|
||||
new AbortController().signal,
|
||||
);
|
||||
const completion = commands[0].completion!;
|
||||
const context = {
|
||||
invocation: {
|
||||
raw: '/find --name="test" --a',
|
||||
name: 'find',
|
||||
args: '--name="test" --a',
|
||||
},
|
||||
} as CommandContext;
|
||||
const suggestions = await completion(context, '--a');
|
||||
expect(suggestions).toEqual(['--age="']);
|
||||
});
|
||||
|
||||
it('should auto-close the quote for a named argument value', async () => {
|
||||
const loader = new McpPromptLoader(mockConfigWithPrompts);
|
||||
const commands = await loader.loadCommands(
|
||||
new AbortController().signal,
|
||||
);
|
||||
const completion = commands[0].completion!;
|
||||
const context = {
|
||||
invocation: {
|
||||
raw: '/find --name="test',
|
||||
name: 'find',
|
||||
args: '--name="test',
|
||||
},
|
||||
} as CommandContext;
|
||||
const suggestions = await completion(context, '--name="test');
|
||||
expect(suggestions).toEqual(['--name="test"']);
|
||||
});
|
||||
|
||||
it('should auto-close the quote for an empty named argument value', async () => {
|
||||
const loader = new McpPromptLoader(mockConfigWithPrompts);
|
||||
const commands = await loader.loadCommands(
|
||||
new AbortController().signal,
|
||||
);
|
||||
const completion = commands[0].completion!;
|
||||
const context = {
|
||||
invocation: {
|
||||
raw: '/find --name="',
|
||||
name: 'find',
|
||||
args: '--name="',
|
||||
},
|
||||
} as CommandContext;
|
||||
const suggestions = await completion(context, '--name="');
|
||||
expect(suggestions).toEqual(['--name=""']);
|
||||
});
|
||||
|
||||
it('should not add a quote if already present', async () => {
|
||||
const loader = new McpPromptLoader(mockConfigWithPrompts);
|
||||
const commands = await loader.loadCommands(
|
||||
new AbortController().signal,
|
||||
);
|
||||
const completion = commands[0].completion!;
|
||||
const context = {
|
||||
invocation: {
|
||||
raw: '/find --name="test"',
|
||||
name: 'find',
|
||||
args: '--name="test"',
|
||||
},
|
||||
} as CommandContext;
|
||||
const suggestions = await completion(context, '--name="test"');
|
||||
expect(suggestions).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -144,23 +144,69 @@ export class McpPromptLoader implements ICommandLoader {
|
||||
};
|
||||
}
|
||||
},
|
||||
completion: async (_: CommandContext, partialArg: string) => {
|
||||
if (!prompt || !prompt.arguments) {
|
||||
completion: async (
|
||||
commandContext: CommandContext,
|
||||
partialArg: string,
|
||||
) => {
|
||||
const invocation = commandContext.invocation;
|
||||
if (!prompt || !prompt.arguments || !invocation) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const suggestions: string[] = [];
|
||||
const usedArgNames = new Set(
|
||||
(partialArg.match(/--([^=]+)/g) || []).map((s) => s.substring(2)),
|
||||
);
|
||||
|
||||
for (const arg of prompt.arguments) {
|
||||
if (!usedArgNames.has(arg.name)) {
|
||||
suggestions.push(`--${arg.name}=""`);
|
||||
}
|
||||
const indexOfFirstSpace = invocation.raw.indexOf(' ') + 1;
|
||||
let promptInputs =
|
||||
indexOfFirstSpace === 0
|
||||
? {}
|
||||
: this.parseArgs(
|
||||
invocation.raw.substring(indexOfFirstSpace),
|
||||
prompt.arguments,
|
||||
);
|
||||
if (promptInputs instanceof Error) {
|
||||
promptInputs = {};
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
const providedArgNames = Object.keys(promptInputs);
|
||||
const unusedArguments =
|
||||
prompt.arguments
|
||||
.filter((arg) => {
|
||||
// If this arguments is not in the prompt inputs
|
||||
// add it to unusedArguments
|
||||
if (!providedArgNames.includes(arg.name)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// The parseArgs method assigns the value
|
||||
// at the end of the prompt as a final value
|
||||
// The argument should still be suggested
|
||||
// Example /add --numberOne="34" --num
|
||||
// numberTwo would be assigned a value of --num
|
||||
// numberTwo should still be considered unused
|
||||
const argValue = promptInputs[arg.name];
|
||||
return argValue === partialArg;
|
||||
})
|
||||
.map((argument) => `--${argument.name}="`) || [];
|
||||
|
||||
const exactlyMatchingArgumentAtTheEnd = prompt.arguments
|
||||
.map((argument) => `--${argument.name}="`)
|
||||
.filter((flagArgument) => {
|
||||
const regex = new RegExp(`${flagArgument}[^"]*$`);
|
||||
return regex.test(invocation.raw);
|
||||
});
|
||||
|
||||
if (exactlyMatchingArgumentAtTheEnd.length === 1) {
|
||||
if (exactlyMatchingArgumentAtTheEnd[0] === partialArg) {
|
||||
return [`${partialArg}"`];
|
||||
}
|
||||
if (partialArg.endsWith('"')) {
|
||||
return [partialArg];
|
||||
}
|
||||
return [`${partialArg}"`];
|
||||
}
|
||||
|
||||
const matchingArguments = unusedArguments.filter((flagArgument) =>
|
||||
flagArgument.startsWith(partialArg),
|
||||
);
|
||||
|
||||
return matchingArguments;
|
||||
},
|
||||
};
|
||||
promptCommands.push(newPromptCommand);
|
||||
|
||||
@@ -71,6 +71,7 @@ describe('ShellProcessor', () => {
|
||||
getTargetDir: vi.fn().mockReturnValue('/test/dir'),
|
||||
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
|
||||
getShouldUseNodePtyShell: vi.fn().mockReturnValue(false),
|
||||
getShellExecutionConfig: vi.fn().mockReturnValue({}),
|
||||
};
|
||||
|
||||
context = createMockCommandContext({
|
||||
@@ -147,6 +148,7 @@ describe('ShellProcessor', () => {
|
||||
expect.any(Function),
|
||||
expect.any(Object),
|
||||
false,
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(result).toEqual([{ text: 'The current status is: On branch main' }]);
|
||||
});
|
||||
@@ -218,6 +220,7 @@ describe('ShellProcessor', () => {
|
||||
expect.any(Function),
|
||||
expect.any(Object),
|
||||
false,
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(result).toEqual([{ text: 'Do something dangerous: deleted' }]);
|
||||
});
|
||||
@@ -410,6 +413,7 @@ describe('ShellProcessor', () => {
|
||||
expect.any(Function),
|
||||
expect.any(Object),
|
||||
false,
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -574,6 +578,7 @@ describe('ShellProcessor', () => {
|
||||
expect.any(Function),
|
||||
expect.any(Object),
|
||||
false,
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
expect(result).toEqual([{ text: 'Command: match found' }]);
|
||||
@@ -598,6 +603,7 @@ describe('ShellProcessor', () => {
|
||||
expect.any(Function),
|
||||
expect.any(Object),
|
||||
false,
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
@@ -668,6 +674,7 @@ describe('ShellProcessor', () => {
|
||||
expect.any(Function),
|
||||
expect.any(Object),
|
||||
false,
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -697,6 +704,7 @@ describe('ShellProcessor', () => {
|
||||
expect.any(Function),
|
||||
expect.any(Object),
|
||||
false,
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
SHORTHAND_ARGS_PLACEHOLDER,
|
||||
} from './types.js';
|
||||
import { extractInjections, type Injection } from './injectionParser.js';
|
||||
import { themeManager } from '../../ui/themes/theme-manager.js';
|
||||
|
||||
export class ConfirmationRequiredError extends Error {
|
||||
constructor(
|
||||
@@ -159,12 +160,19 @@ export class ShellProcessor implements IPromptProcessor {
|
||||
|
||||
// Execute the resolved command (which already has ESCAPED input).
|
||||
if (injection.resolvedCommand) {
|
||||
const activeTheme = themeManager.getActiveTheme();
|
||||
const shellExecutionConfig = {
|
||||
...config.getShellExecutionConfig(),
|
||||
defaultFg: activeTheme.colors.Foreground,
|
||||
defaultBg: activeTheme.colors.Background,
|
||||
};
|
||||
const { result } = await ShellExecutionService.execute(
|
||||
injection.resolvedCommand,
|
||||
config.getTargetDir(),
|
||||
() => {},
|
||||
new AbortController().signal,
|
||||
config.getShouldUseNodePtyShell(),
|
||||
shellExecutionConfig,
|
||||
);
|
||||
|
||||
const executionResult = await result;
|
||||
|
||||
Reference in New Issue
Block a user