mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-21 09:17:53 +00:00
prefactor(commands): Command Service Prefactor for Extensible Commands (#4511)
This commit is contained in:
118
packages/cli/src/services/BuiltinCommandLoader.test.ts
Normal file
118
packages/cli/src/services/BuiltinCommandLoader.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
vi.mock('../ui/commands/aboutCommand.js', async () => {
|
||||
const { CommandKind } = await import('../ui/commands/types.js');
|
||||
return {
|
||||
aboutCommand: {
|
||||
name: 'about',
|
||||
description: 'About the CLI',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../ui/commands/ideCommand.js', () => ({ ideCommand: vi.fn() }));
|
||||
vi.mock('../ui/commands/restoreCommand.js', () => ({
|
||||
restoreCommand: vi.fn(),
|
||||
}));
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import { BuiltinCommandLoader } from './BuiltinCommandLoader.js';
|
||||
import { Config } from '@google/gemini-cli-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: {} }));
|
||||
vi.mock('../ui/commands/bugCommand.js', () => ({ bugCommand: {} }));
|
||||
vi.mock('../ui/commands/chatCommand.js', () => ({ chatCommand: {} }));
|
||||
vi.mock('../ui/commands/clearCommand.js', () => ({ clearCommand: {} }));
|
||||
vi.mock('../ui/commands/compressCommand.js', () => ({ compressCommand: {} }));
|
||||
vi.mock('../ui/commands/corgiCommand.js', () => ({ corgiCommand: {} }));
|
||||
vi.mock('../ui/commands/docsCommand.js', () => ({ docsCommand: {} }));
|
||||
vi.mock('../ui/commands/editorCommand.js', () => ({ editorCommand: {} }));
|
||||
vi.mock('../ui/commands/extensionsCommand.js', () => ({
|
||||
extensionsCommand: {},
|
||||
}));
|
||||
vi.mock('../ui/commands/helpCommand.js', () => ({ helpCommand: {} }));
|
||||
vi.mock('../ui/commands/mcpCommand.js', () => ({ mcpCommand: {} }));
|
||||
vi.mock('../ui/commands/memoryCommand.js', () => ({ memoryCommand: {} }));
|
||||
vi.mock('../ui/commands/privacyCommand.js', () => ({ privacyCommand: {} }));
|
||||
vi.mock('../ui/commands/quitCommand.js', () => ({ quitCommand: {} }));
|
||||
vi.mock('../ui/commands/statsCommand.js', () => ({ statsCommand: {} }));
|
||||
vi.mock('../ui/commands/themeCommand.js', () => ({ themeCommand: {} }));
|
||||
vi.mock('../ui/commands/toolsCommand.js', () => ({ toolsCommand: {} }));
|
||||
|
||||
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;
|
||||
|
||||
ideCommandMock.mockReturnValue({
|
||||
name: 'ide',
|
||||
description: 'IDE command',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
});
|
||||
restoreCommandMock.mockReturnValue({
|
||||
name: 'restore',
|
||||
description: 'Restore command',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
});
|
||||
});
|
||||
|
||||
it('should correctly pass the config object to command factory functions', async () => {
|
||||
const loader = new BuiltinCommandLoader(mockConfig);
|
||||
await loader.loadCommands();
|
||||
|
||||
expect(ideCommandMock).toHaveBeenCalledTimes(1);
|
||||
expect(ideCommandMock).toHaveBeenCalledWith(mockConfig);
|
||||
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);
|
||||
const loader = new BuiltinCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands();
|
||||
|
||||
// The 'ide' command should be filtered out.
|
||||
const ideCmd = commands.find((c) => c.name === 'ide');
|
||||
expect(ideCmd).toBeUndefined();
|
||||
|
||||
// Other commands should still be present.
|
||||
const aboutCmd = commands.find((c) => c.name === 'about');
|
||||
expect(aboutCmd).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle a null config gracefully when calling factories', async () => {
|
||||
const loader = new BuiltinCommandLoader(null);
|
||||
await loader.loadCommands();
|
||||
expect(ideCommandMock).toHaveBeenCalledTimes(1);
|
||||
expect(ideCommandMock).toHaveBeenCalledWith(null);
|
||||
expect(restoreCommandMock).toHaveBeenCalledTimes(1);
|
||||
expect(restoreCommandMock).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('should return a list of all loaded commands', async () => {
|
||||
const loader = new BuiltinCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands();
|
||||
|
||||
const aboutCmd = commands.find((c) => c.name === 'about');
|
||||
expect(aboutCmd).toBeDefined();
|
||||
expect(aboutCmd?.kind).toBe(CommandKind.BUILT_IN);
|
||||
|
||||
const ideCmd = commands.find((c) => c.name === 'ide');
|
||||
expect(ideCmd).toBeDefined();
|
||||
});
|
||||
});
|
||||
73
packages/cli/src/services/BuiltinCommandLoader.ts
Normal file
73
packages/cli/src/services/BuiltinCommandLoader.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { ICommandLoader } from './types.js';
|
||||
import { SlashCommand } from '../ui/commands/types.js';
|
||||
import { Config } from '@google/gemini-cli-core';
|
||||
import { aboutCommand } from '../ui/commands/aboutCommand.js';
|
||||
import { authCommand } from '../ui/commands/authCommand.js';
|
||||
import { bugCommand } from '../ui/commands/bugCommand.js';
|
||||
import { chatCommand } from '../ui/commands/chatCommand.js';
|
||||
import { clearCommand } from '../ui/commands/clearCommand.js';
|
||||
import { compressCommand } from '../ui/commands/compressCommand.js';
|
||||
import { copyCommand } from '../ui/commands/copyCommand.js';
|
||||
import { corgiCommand } from '../ui/commands/corgiCommand.js';
|
||||
import { docsCommand } from '../ui/commands/docsCommand.js';
|
||||
import { editorCommand } from '../ui/commands/editorCommand.js';
|
||||
import { extensionsCommand } from '../ui/commands/extensionsCommand.js';
|
||||
import { helpCommand } from '../ui/commands/helpCommand.js';
|
||||
import { ideCommand } from '../ui/commands/ideCommand.js';
|
||||
import { mcpCommand } from '../ui/commands/mcpCommand.js';
|
||||
import { memoryCommand } from '../ui/commands/memoryCommand.js';
|
||||
import { privacyCommand } from '../ui/commands/privacyCommand.js';
|
||||
import { quitCommand } from '../ui/commands/quitCommand.js';
|
||||
import { restoreCommand } from '../ui/commands/restoreCommand.js';
|
||||
import { statsCommand } from '../ui/commands/statsCommand.js';
|
||||
import { themeCommand } from '../ui/commands/themeCommand.js';
|
||||
import { toolsCommand } from '../ui/commands/toolsCommand.js';
|
||||
|
||||
/**
|
||||
* Loads the core, hard-coded slash commands that are an integral part
|
||||
* of the Gemini CLI application.
|
||||
*/
|
||||
export class BuiltinCommandLoader implements ICommandLoader {
|
||||
constructor(private config: Config | null) {}
|
||||
|
||||
/**
|
||||
* Gathers all raw built-in command definitions, injects dependencies where
|
||||
* needed (e.g., config) and filters out any that are not available.
|
||||
*
|
||||
* @param _signal An AbortSignal (unused for this synchronous loader).
|
||||
* @returns A promise that resolves to an array of `SlashCommand` objects.
|
||||
*/
|
||||
async loadCommands(_signal: AbortSignal): Promise<SlashCommand[]> {
|
||||
const allDefinitions: Array<SlashCommand | null> = [
|
||||
aboutCommand,
|
||||
authCommand,
|
||||
bugCommand,
|
||||
chatCommand,
|
||||
clearCommand,
|
||||
compressCommand,
|
||||
copyCommand,
|
||||
corgiCommand,
|
||||
docsCommand,
|
||||
editorCommand,
|
||||
extensionsCommand,
|
||||
helpCommand,
|
||||
ideCommand(this.config),
|
||||
mcpCommand,
|
||||
memoryCommand,
|
||||
privacyCommand,
|
||||
quitCommand,
|
||||
restoreCommand(this.config),
|
||||
statsCommand,
|
||||
themeCommand,
|
||||
toolsCommand,
|
||||
];
|
||||
|
||||
return allDefinitions.filter((cmd): cmd is SlashCommand => cmd !== null);
|
||||
}
|
||||
}
|
||||
@@ -4,254 +4,177 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach, type Mocked } from 'vitest';
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { CommandService } from './CommandService.js';
|
||||
import { type Config } from '@google/gemini-cli-core';
|
||||
import { type SlashCommand } from '../ui/commands/types.js';
|
||||
import { memoryCommand } from '../ui/commands/memoryCommand.js';
|
||||
import { helpCommand } from '../ui/commands/helpCommand.js';
|
||||
import { clearCommand } from '../ui/commands/clearCommand.js';
|
||||
import { copyCommand } from '../ui/commands/copyCommand.js';
|
||||
import { corgiCommand } from '../ui/commands/corgiCommand.js';
|
||||
import { docsCommand } from '../ui/commands/docsCommand.js';
|
||||
import { chatCommand } from '../ui/commands/chatCommand.js';
|
||||
import { authCommand } from '../ui/commands/authCommand.js';
|
||||
import { themeCommand } from '../ui/commands/themeCommand.js';
|
||||
import { statsCommand } from '../ui/commands/statsCommand.js';
|
||||
import { privacyCommand } from '../ui/commands/privacyCommand.js';
|
||||
import { aboutCommand } from '../ui/commands/aboutCommand.js';
|
||||
import { ideCommand } from '../ui/commands/ideCommand.js';
|
||||
import { extensionsCommand } from '../ui/commands/extensionsCommand.js';
|
||||
import { toolsCommand } from '../ui/commands/toolsCommand.js';
|
||||
import { compressCommand } from '../ui/commands/compressCommand.js';
|
||||
import { mcpCommand } from '../ui/commands/mcpCommand.js';
|
||||
import { editorCommand } from '../ui/commands/editorCommand.js';
|
||||
import { bugCommand } from '../ui/commands/bugCommand.js';
|
||||
import { quitCommand } from '../ui/commands/quitCommand.js';
|
||||
import { restoreCommand } from '../ui/commands/restoreCommand.js';
|
||||
import { type ICommandLoader } from './types.js';
|
||||
import { CommandKind, type SlashCommand } from '../ui/commands/types.js';
|
||||
|
||||
// Mock the command modules to isolate the service from the command implementations.
|
||||
vi.mock('../ui/commands/memoryCommand.js', () => ({
|
||||
memoryCommand: { name: 'memory', description: 'Mock Memory' },
|
||||
}));
|
||||
vi.mock('../ui/commands/helpCommand.js', () => ({
|
||||
helpCommand: { name: 'help', description: 'Mock Help' },
|
||||
}));
|
||||
vi.mock('../ui/commands/clearCommand.js', () => ({
|
||||
clearCommand: { name: 'clear', description: 'Mock Clear' },
|
||||
}));
|
||||
vi.mock('../ui/commands/corgiCommand.js', () => ({
|
||||
corgiCommand: { name: 'corgi', description: 'Mock Corgi' },
|
||||
}));
|
||||
vi.mock('../ui/commands/docsCommand.js', () => ({
|
||||
docsCommand: { name: 'docs', description: 'Mock Docs' },
|
||||
}));
|
||||
vi.mock('../ui/commands/authCommand.js', () => ({
|
||||
authCommand: { name: 'auth', description: 'Mock Auth' },
|
||||
}));
|
||||
vi.mock('../ui/commands/themeCommand.js', () => ({
|
||||
themeCommand: { name: 'theme', description: 'Mock Theme' },
|
||||
}));
|
||||
vi.mock('../ui/commands/copyCommand.js', () => ({
|
||||
copyCommand: { name: 'copy', description: 'Mock Copy' },
|
||||
}));
|
||||
vi.mock('../ui/commands/privacyCommand.js', () => ({
|
||||
privacyCommand: { name: 'privacy', description: 'Mock Privacy' },
|
||||
}));
|
||||
vi.mock('../ui/commands/statsCommand.js', () => ({
|
||||
statsCommand: { name: 'stats', description: 'Mock Stats' },
|
||||
}));
|
||||
vi.mock('../ui/commands/aboutCommand.js', () => ({
|
||||
aboutCommand: { name: 'about', description: 'Mock About' },
|
||||
}));
|
||||
vi.mock('../ui/commands/ideCommand.js', () => ({
|
||||
ideCommand: vi.fn(),
|
||||
}));
|
||||
vi.mock('../ui/commands/extensionsCommand.js', () => ({
|
||||
extensionsCommand: { name: 'extensions', description: 'Mock Extensions' },
|
||||
}));
|
||||
vi.mock('../ui/commands/toolsCommand.js', () => ({
|
||||
toolsCommand: { name: 'tools', description: 'Mock Tools' },
|
||||
}));
|
||||
vi.mock('../ui/commands/compressCommand.js', () => ({
|
||||
compressCommand: { name: 'compress', description: 'Mock Compress' },
|
||||
}));
|
||||
vi.mock('../ui/commands/mcpCommand.js', () => ({
|
||||
mcpCommand: { name: 'mcp', description: 'Mock MCP' },
|
||||
}));
|
||||
vi.mock('../ui/commands/editorCommand.js', () => ({
|
||||
editorCommand: { name: 'editor', description: 'Mock Editor' },
|
||||
}));
|
||||
vi.mock('../ui/commands/bugCommand.js', () => ({
|
||||
bugCommand: { name: 'bug', description: 'Mock Bug' },
|
||||
}));
|
||||
vi.mock('../ui/commands/quitCommand.js', () => ({
|
||||
quitCommand: { name: 'quit', description: 'Mock Quit' },
|
||||
}));
|
||||
vi.mock('../ui/commands/restoreCommand.js', () => ({
|
||||
restoreCommand: vi.fn(),
|
||||
}));
|
||||
const createMockCommand = (name: string, kind: CommandKind): SlashCommand => ({
|
||||
name,
|
||||
description: `Description for ${name}`,
|
||||
kind,
|
||||
action: vi.fn(),
|
||||
});
|
||||
|
||||
const mockCommandA = createMockCommand('command-a', CommandKind.BUILT_IN);
|
||||
const mockCommandB = createMockCommand('command-b', CommandKind.BUILT_IN);
|
||||
const mockCommandC = createMockCommand('command-c', CommandKind.FILE);
|
||||
const mockCommandB_Override = createMockCommand('command-b', CommandKind.FILE);
|
||||
|
||||
class MockCommandLoader implements ICommandLoader {
|
||||
private commandsToLoad: SlashCommand[];
|
||||
|
||||
constructor(commandsToLoad: SlashCommand[]) {
|
||||
this.commandsToLoad = commandsToLoad;
|
||||
}
|
||||
|
||||
loadCommands = vi.fn(
|
||||
async (): Promise<SlashCommand[]> => Promise.resolve(this.commandsToLoad),
|
||||
);
|
||||
}
|
||||
|
||||
describe('CommandService', () => {
|
||||
const subCommandLen = 19;
|
||||
let mockConfig: Mocked<Config>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig = {
|
||||
getIdeMode: vi.fn(),
|
||||
getCheckpointingEnabled: vi.fn(),
|
||||
} as unknown as Mocked<Config>;
|
||||
vi.mocked(ideCommand).mockReturnValue(null);
|
||||
vi.mocked(restoreCommand).mockReturnValue(null);
|
||||
vi.spyOn(console, 'debug').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
describe('when using default production loader', () => {
|
||||
let commandService: CommandService;
|
||||
|
||||
beforeEach(() => {
|
||||
commandService = new CommandService(mockConfig);
|
||||
});
|
||||
|
||||
it('should initialize with an empty command tree', () => {
|
||||
const tree = commandService.getCommands();
|
||||
expect(tree).toBeInstanceOf(Array);
|
||||
expect(tree.length).toBe(0);
|
||||
});
|
||||
|
||||
describe('loadCommands', () => {
|
||||
it('should load the built-in commands into the command tree', async () => {
|
||||
// Pre-condition check
|
||||
expect(commandService.getCommands().length).toBe(0);
|
||||
|
||||
// Action
|
||||
await commandService.loadCommands();
|
||||
const tree = commandService.getCommands();
|
||||
|
||||
// Post-condition assertions
|
||||
expect(tree.length).toBe(subCommandLen);
|
||||
|
||||
const commandNames = tree.map((cmd) => cmd.name);
|
||||
expect(commandNames).toContain('auth');
|
||||
expect(commandNames).toContain('bug');
|
||||
expect(commandNames).toContain('memory');
|
||||
expect(commandNames).toContain('help');
|
||||
expect(commandNames).toContain('clear');
|
||||
expect(commandNames).toContain('copy');
|
||||
expect(commandNames).toContain('compress');
|
||||
expect(commandNames).toContain('corgi');
|
||||
expect(commandNames).toContain('docs');
|
||||
expect(commandNames).toContain('chat');
|
||||
expect(commandNames).toContain('theme');
|
||||
expect(commandNames).toContain('stats');
|
||||
expect(commandNames).toContain('privacy');
|
||||
expect(commandNames).toContain('about');
|
||||
expect(commandNames).toContain('extensions');
|
||||
expect(commandNames).toContain('tools');
|
||||
expect(commandNames).toContain('mcp');
|
||||
expect(commandNames).not.toContain('ide');
|
||||
});
|
||||
|
||||
it('should include ide command when ideMode is on', async () => {
|
||||
mockConfig.getIdeMode.mockReturnValue(true);
|
||||
vi.mocked(ideCommand).mockReturnValue({
|
||||
name: 'ide',
|
||||
description: 'Mock IDE',
|
||||
});
|
||||
await commandService.loadCommands();
|
||||
const tree = commandService.getCommands();
|
||||
|
||||
expect(tree.length).toBe(subCommandLen + 1);
|
||||
const commandNames = tree.map((cmd) => cmd.name);
|
||||
expect(commandNames).toContain('ide');
|
||||
expect(commandNames).toContain('editor');
|
||||
expect(commandNames).toContain('quit');
|
||||
});
|
||||
|
||||
it('should include restore command when checkpointing is on', async () => {
|
||||
mockConfig.getCheckpointingEnabled.mockReturnValue(true);
|
||||
vi.mocked(restoreCommand).mockReturnValue({
|
||||
name: 'restore',
|
||||
description: 'Mock Restore',
|
||||
});
|
||||
await commandService.loadCommands();
|
||||
const tree = commandService.getCommands();
|
||||
|
||||
expect(tree.length).toBe(subCommandLen + 1);
|
||||
const commandNames = tree.map((cmd) => cmd.name);
|
||||
expect(commandNames).toContain('restore');
|
||||
});
|
||||
|
||||
it('should overwrite any existing commands when called again', async () => {
|
||||
// Load once
|
||||
await commandService.loadCommands();
|
||||
expect(commandService.getCommands().length).toBe(subCommandLen);
|
||||
|
||||
// Load again
|
||||
await commandService.loadCommands();
|
||||
const tree = commandService.getCommands();
|
||||
|
||||
// Should not append, but overwrite
|
||||
expect(tree.length).toBe(subCommandLen);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCommandTree', () => {
|
||||
it('should return the current command tree', async () => {
|
||||
const initialTree = commandService.getCommands();
|
||||
expect(initialTree).toEqual([]);
|
||||
|
||||
await commandService.loadCommands();
|
||||
|
||||
const loadedTree = commandService.getCommands();
|
||||
expect(loadedTree.length).toBe(subCommandLen);
|
||||
expect(loadedTree).toEqual([
|
||||
aboutCommand,
|
||||
authCommand,
|
||||
bugCommand,
|
||||
chatCommand,
|
||||
clearCommand,
|
||||
copyCommand,
|
||||
compressCommand,
|
||||
corgiCommand,
|
||||
docsCommand,
|
||||
editorCommand,
|
||||
extensionsCommand,
|
||||
helpCommand,
|
||||
mcpCommand,
|
||||
memoryCommand,
|
||||
privacyCommand,
|
||||
quitCommand,
|
||||
statsCommand,
|
||||
themeCommand,
|
||||
toolsCommand,
|
||||
]);
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('when initialized with an injected loader function', () => {
|
||||
it('should use the provided loader instead of the built-in one', async () => {
|
||||
// Arrange: Create a set of mock commands.
|
||||
const mockCommands: SlashCommand[] = [
|
||||
{ name: 'injected-test-1', description: 'injected 1' },
|
||||
{ name: 'injected-test-2', description: 'injected 2' },
|
||||
];
|
||||
it('should load commands from a single loader', async () => {
|
||||
const mockLoader = new MockCommandLoader([mockCommandA, mockCommandB]);
|
||||
const service = await CommandService.create(
|
||||
[mockLoader],
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
// Arrange: Create a mock loader FUNCTION that resolves with our mock commands.
|
||||
const mockLoader = vi.fn().mockResolvedValue(mockCommands);
|
||||
const commands = service.getCommands();
|
||||
|
||||
// Act: Instantiate the service WITH the injected loader function.
|
||||
const commandService = new CommandService(mockConfig, mockLoader);
|
||||
await commandService.loadCommands();
|
||||
const tree = commandService.getCommands();
|
||||
expect(mockLoader.loadCommands).toHaveBeenCalledTimes(1);
|
||||
expect(commands).toHaveLength(2);
|
||||
expect(commands).toEqual(
|
||||
expect.arrayContaining([mockCommandA, mockCommandB]),
|
||||
);
|
||||
});
|
||||
|
||||
// Assert: The tree should contain ONLY our injected commands.
|
||||
expect(mockLoader).toHaveBeenCalled(); // Verify our mock loader was actually called.
|
||||
expect(tree.length).toBe(2);
|
||||
expect(tree).toEqual(mockCommands);
|
||||
it('should aggregate commands from multiple loaders', async () => {
|
||||
const loader1 = new MockCommandLoader([mockCommandA]);
|
||||
const loader2 = new MockCommandLoader([mockCommandC]);
|
||||
const service = await CommandService.create(
|
||||
[loader1, loader2],
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
const commandNames = tree.map((cmd) => cmd.name);
|
||||
expect(commandNames).not.toContain('memory'); // Verify it didn't load production commands.
|
||||
});
|
||||
const commands = service.getCommands();
|
||||
|
||||
expect(loader1.loadCommands).toHaveBeenCalledTimes(1);
|
||||
expect(loader2.loadCommands).toHaveBeenCalledTimes(1);
|
||||
expect(commands).toHaveLength(2);
|
||||
expect(commands).toEqual(
|
||||
expect.arrayContaining([mockCommandA, mockCommandC]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should override commands from earlier loaders with those from later loaders', async () => {
|
||||
const loader1 = new MockCommandLoader([mockCommandA, mockCommandB]);
|
||||
const loader2 = new MockCommandLoader([
|
||||
mockCommandB_Override,
|
||||
mockCommandC,
|
||||
]);
|
||||
const service = await CommandService.create(
|
||||
[loader1, loader2],
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
const commands = service.getCommands();
|
||||
|
||||
expect(commands).toHaveLength(3); // Should be A, C, and the overridden B.
|
||||
|
||||
// The final list should contain the override from the *last* loader.
|
||||
const commandB = commands.find((cmd) => cmd.name === 'command-b');
|
||||
expect(commandB).toBeDefined();
|
||||
expect(commandB?.kind).toBe(CommandKind.FILE); // Verify it's the overridden version.
|
||||
expect(commandB).toEqual(mockCommandB_Override);
|
||||
|
||||
// Ensure the other commands are still present.
|
||||
expect(commands).toEqual(
|
||||
expect.arrayContaining([
|
||||
mockCommandA,
|
||||
mockCommandC,
|
||||
mockCommandB_Override,
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle loaders that return an empty array of commands gracefully', async () => {
|
||||
const loader1 = new MockCommandLoader([mockCommandA]);
|
||||
const emptyLoader = new MockCommandLoader([]);
|
||||
const loader3 = new MockCommandLoader([mockCommandB]);
|
||||
const service = await CommandService.create(
|
||||
[loader1, emptyLoader, loader3],
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
const commands = service.getCommands();
|
||||
|
||||
expect(emptyLoader.loadCommands).toHaveBeenCalledTimes(1);
|
||||
expect(commands).toHaveLength(2);
|
||||
expect(commands).toEqual(
|
||||
expect.arrayContaining([mockCommandA, mockCommandB]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should load commands from successful loaders even if one fails', async () => {
|
||||
const successfulLoader = new MockCommandLoader([mockCommandA]);
|
||||
const failingLoader = new MockCommandLoader([]);
|
||||
const error = new Error('Loader failed');
|
||||
vi.spyOn(failingLoader, 'loadCommands').mockRejectedValue(error);
|
||||
|
||||
const service = await CommandService.create(
|
||||
[successfulLoader, failingLoader],
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
const commands = service.getCommands();
|
||||
expect(commands).toHaveLength(1);
|
||||
expect(commands).toEqual([mockCommandA]);
|
||||
expect(console.debug).toHaveBeenCalledWith(
|
||||
'A command loader failed:',
|
||||
error,
|
||||
);
|
||||
});
|
||||
|
||||
it('getCommands should return a readonly array that cannot be mutated', async () => {
|
||||
const service = await CommandService.create(
|
||||
[new MockCommandLoader([mockCommandA])],
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
const commands = service.getCommands();
|
||||
|
||||
// Expect it to throw a TypeError at runtime because the array is frozen.
|
||||
expect(() => {
|
||||
// @ts-expect-error - Testing immutability is intentional here.
|
||||
commands.push(mockCommandB);
|
||||
}).toThrow();
|
||||
|
||||
// Verify the original array was not mutated.
|
||||
expect(service.getCommands()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should pass the abort signal to all loaders', async () => {
|
||||
const controller = new AbortController();
|
||||
const signal = controller.signal;
|
||||
|
||||
const loader1 = new MockCommandLoader([mockCommandA]);
|
||||
const loader2 = new MockCommandLoader([mockCommandB]);
|
||||
|
||||
await CommandService.create([loader1, loader2], signal);
|
||||
|
||||
expect(loader1.loadCommands).toHaveBeenCalledTimes(1);
|
||||
expect(loader1.loadCommands).toHaveBeenCalledWith(signal);
|
||||
expect(loader2.loadCommands).toHaveBeenCalledTimes(1);
|
||||
expect(loader2.loadCommands).toHaveBeenCalledWith(signal);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,81 +4,80 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Config } from '@google/gemini-cli-core';
|
||||
import { SlashCommand } from '../ui/commands/types.js';
|
||||
import { memoryCommand } from '../ui/commands/memoryCommand.js';
|
||||
import { helpCommand } from '../ui/commands/helpCommand.js';
|
||||
import { clearCommand } from '../ui/commands/clearCommand.js';
|
||||
import { copyCommand } from '../ui/commands/copyCommand.js';
|
||||
import { corgiCommand } from '../ui/commands/corgiCommand.js';
|
||||
import { docsCommand } from '../ui/commands/docsCommand.js';
|
||||
import { mcpCommand } from '../ui/commands/mcpCommand.js';
|
||||
import { authCommand } from '../ui/commands/authCommand.js';
|
||||
import { themeCommand } from '../ui/commands/themeCommand.js';
|
||||
import { editorCommand } from '../ui/commands/editorCommand.js';
|
||||
import { chatCommand } from '../ui/commands/chatCommand.js';
|
||||
import { statsCommand } from '../ui/commands/statsCommand.js';
|
||||
import { privacyCommand } from '../ui/commands/privacyCommand.js';
|
||||
import { aboutCommand } from '../ui/commands/aboutCommand.js';
|
||||
import { extensionsCommand } from '../ui/commands/extensionsCommand.js';
|
||||
import { toolsCommand } from '../ui/commands/toolsCommand.js';
|
||||
import { compressCommand } from '../ui/commands/compressCommand.js';
|
||||
import { ideCommand } from '../ui/commands/ideCommand.js';
|
||||
import { bugCommand } from '../ui/commands/bugCommand.js';
|
||||
import { quitCommand } from '../ui/commands/quitCommand.js';
|
||||
import { restoreCommand } from '../ui/commands/restoreCommand.js';
|
||||
|
||||
const loadBuiltInCommands = async (
|
||||
config: Config | null,
|
||||
): Promise<SlashCommand[]> => {
|
||||
const allCommands = [
|
||||
aboutCommand,
|
||||
authCommand,
|
||||
bugCommand,
|
||||
chatCommand,
|
||||
clearCommand,
|
||||
copyCommand,
|
||||
compressCommand,
|
||||
corgiCommand,
|
||||
docsCommand,
|
||||
editorCommand,
|
||||
extensionsCommand,
|
||||
helpCommand,
|
||||
ideCommand(config),
|
||||
mcpCommand,
|
||||
memoryCommand,
|
||||
privacyCommand,
|
||||
quitCommand,
|
||||
restoreCommand(config),
|
||||
statsCommand,
|
||||
themeCommand,
|
||||
toolsCommand,
|
||||
];
|
||||
|
||||
return allCommands.filter(
|
||||
(command): command is SlashCommand => command !== null,
|
||||
);
|
||||
};
|
||||
import { ICommandLoader } from './types.js';
|
||||
|
||||
/**
|
||||
* Orchestrates the discovery and loading of all slash commands for the CLI.
|
||||
*
|
||||
* This service operates on a provider-based loader pattern. It is initialized
|
||||
* with an array of `ICommandLoader` instances, each responsible for fetching
|
||||
* commands from a specific source (e.g., built-in code, local files).
|
||||
*
|
||||
* The CommandService is responsible for invoking these loaders, aggregating their
|
||||
* results, and resolving any name conflicts. This architecture allows the command
|
||||
* system to be extended with new sources without modifying the service itself.
|
||||
*/
|
||||
export class CommandService {
|
||||
private commands: SlashCommand[] = [];
|
||||
/**
|
||||
* Private constructor to enforce the use of the async factory.
|
||||
* @param commands A readonly array of the fully loaded and de-duplicated commands.
|
||||
*/
|
||||
private constructor(private readonly commands: readonly SlashCommand[]) {}
|
||||
|
||||
constructor(
|
||||
private config: Config | null,
|
||||
private commandLoader: (
|
||||
config: Config | null,
|
||||
) => Promise<SlashCommand[]> = loadBuiltInCommands,
|
||||
) {
|
||||
// The constructor can be used for dependency injection in the future.
|
||||
/**
|
||||
* Asynchronously creates and initializes a new CommandService instance.
|
||||
*
|
||||
* This factory method orchestrates the entire command loading process. It
|
||||
* runs all provided loaders in parallel, aggregates their results, handles
|
||||
* name conflicts by letting the last-loaded command win, and then returns a
|
||||
* fully constructed `CommandService` instance.
|
||||
*
|
||||
* @param loaders An array of objects that conform to the `ICommandLoader`
|
||||
* interface. The order of loaders is significant: if multiple loaders
|
||||
* provide a command with the same name, the command from the loader that
|
||||
* appears later in the array will take precedence.
|
||||
* @param signal An AbortSignal to cancel the loading process.
|
||||
* @returns A promise that resolves to a new, fully initialized `CommandService` instance.
|
||||
*/
|
||||
static async create(
|
||||
loaders: ICommandLoader[],
|
||||
signal: AbortSignal,
|
||||
): Promise<CommandService> {
|
||||
const results = await Promise.allSettled(
|
||||
loaders.map((loader) => loader.loadCommands(signal)),
|
||||
);
|
||||
|
||||
const allCommands: SlashCommand[] = [];
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled') {
|
||||
allCommands.push(...result.value);
|
||||
} else {
|
||||
console.debug('A command loader failed:', result.reason);
|
||||
}
|
||||
}
|
||||
|
||||
// De-duplicate commands using a Map. The last one found with a given name wins.
|
||||
// This creates a natural override system based on the order of the loaders
|
||||
// passed to the constructor.
|
||||
const commandMap = new Map<string, SlashCommand>();
|
||||
for (const cmd of allCommands) {
|
||||
commandMap.set(cmd.name, cmd);
|
||||
}
|
||||
|
||||
const finalCommands = Object.freeze(Array.from(commandMap.values()));
|
||||
return new CommandService(finalCommands);
|
||||
}
|
||||
|
||||
async loadCommands(): Promise<void> {
|
||||
// For now, we only load the built-in commands.
|
||||
// File-based and remote commands will be added later.
|
||||
this.commands = await this.commandLoader(this.config);
|
||||
}
|
||||
|
||||
getCommands(): SlashCommand[] {
|
||||
/**
|
||||
* Retrieves the currently loaded and de-duplicated list of slash commands.
|
||||
*
|
||||
* This method is a safe accessor for the service's state. It returns a
|
||||
* readonly array, preventing consumers from modifying the service's internal state.
|
||||
*
|
||||
* @returns A readonly, unified array of available `SlashCommand` objects.
|
||||
*/
|
||||
getCommands(): readonly SlashCommand[] {
|
||||
return this.commands;
|
||||
}
|
||||
}
|
||||
|
||||
24
packages/cli/src/services/types.ts
Normal file
24
packages/cli/src/services/types.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { SlashCommand } from '../ui/commands/types.js';
|
||||
|
||||
/**
|
||||
* Defines the contract for any class that can load and provide slash commands.
|
||||
* This allows the CommandService to be extended with new command sources
|
||||
* (e.g., file-based, remote APIs) without modification.
|
||||
*
|
||||
* Loaders should receive any necessary dependencies (like Config) via their
|
||||
* constructor.
|
||||
*/
|
||||
export interface ICommandLoader {
|
||||
/**
|
||||
* Discovers and returns a list of slash commands from the loader's source.
|
||||
* @param signal An AbortSignal to allow cancellation.
|
||||
* @returns A promise that resolves to an array of SlashCommand objects.
|
||||
*/
|
||||
loadCommands(signal: AbortSignal): Promise<SlashCommand[]>;
|
||||
}
|
||||
Reference in New Issue
Block a user