summaryrefslogtreecommitdiff
path: root/packages/cli/src/services
diff options
context:
space:
mode:
Diffstat (limited to 'packages/cli/src/services')
-rw-r--r--packages/cli/src/services/BuiltinCommandLoader.test.ts118
-rw-r--r--packages/cli/src/services/BuiltinCommandLoader.ts73
-rw-r--r--packages/cli/src/services/CommandService.test.ts363
-rw-r--r--packages/cli/src/services/CommandService.ts133
-rw-r--r--packages/cli/src/services/types.ts24
5 files changed, 424 insertions, 287 deletions
diff --git a/packages/cli/src/services/BuiltinCommandLoader.test.ts b/packages/cli/src/services/BuiltinCommandLoader.test.ts
new file mode 100644
index 00000000..642309dc
--- /dev/null
+++ b/packages/cli/src/services/BuiltinCommandLoader.test.ts
@@ -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();
+ });
+});
diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts
new file mode 100644
index 00000000..259c6013
--- /dev/null
+++ b/packages/cli/src/services/BuiltinCommandLoader.ts
@@ -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);
+ }
+}
diff --git a/packages/cli/src/services/CommandService.test.ts b/packages/cli/src/services/CommandService.test.ts
index de4ff2ea..28731f81 100644
--- a/packages/cli/src/services/CommandService.test.ts
+++ b/packages/cli/src/services/CommandService.test.ts
@@ -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(),
+});
-describe('CommandService', () => {
- const subCommandLen = 19;
- let mockConfig: Mocked<Config>;
+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', () => {
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(() => {});
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
});
- describe('when using default production loader', () => {
- let commandService: CommandService;
+ it('should load commands from a single loader', async () => {
+ const mockLoader = new MockCommandLoader([mockCommandA, mockCommandB]);
+ const service = await CommandService.create(
+ [mockLoader],
+ new AbortController().signal,
+ );
- beforeEach(() => {
- commandService = new CommandService(mockConfig);
- });
+ const commands = service.getCommands();
- it('should initialize with an empty command tree', () => {
- const tree = commandService.getCommands();
- expect(tree).toBeInstanceOf(Array);
- expect(tree.length).toBe(0);
- });
+ expect(mockLoader.loadCommands).toHaveBeenCalledTimes(1);
+ expect(commands).toHaveLength(2);
+ expect(commands).toEqual(
+ expect.arrayContaining([mockCommandA, mockCommandB]),
+ );
+ });
- describe('loadCommands', () => {
- it('should load the built-in commands into the command tree', async () => {
- // Pre-condition check
- expect(commandService.getCommands().length).toBe(0);
+ 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,
+ );
- // Action
- await commandService.loadCommands();
- const tree = commandService.getCommands();
+ const commands = service.getCommands();
- // Post-condition assertions
- expect(tree.length).toBe(subCommandLen);
+ expect(loader1.loadCommands).toHaveBeenCalledTimes(1);
+ expect(loader2.loadCommands).toHaveBeenCalledTimes(1);
+ expect(commands).toHaveLength(2);
+ expect(commands).toEqual(
+ expect.arrayContaining([mockCommandA, mockCommandC]),
+ );
+ });
- 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 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,
+ );
- 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();
+ const commands = service.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');
- });
+ expect(commands).toHaveLength(3); // Should be A, C, and the overridden B.
- 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();
+ // 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);
- expect(tree.length).toBe(subCommandLen + 1);
- const commandNames = tree.map((cmd) => cmd.name);
- expect(commandNames).toContain('restore');
- });
+ // Ensure the other commands are still present.
+ expect(commands).toEqual(
+ expect.arrayContaining([
+ mockCommandA,
+ mockCommandC,
+ mockCommandB_Override,
+ ]),
+ );
+ });
- it('should overwrite any existing commands when called again', async () => {
- // Load once
- await commandService.loadCommands();
- expect(commandService.getCommands().length).toBe(subCommandLen);
+ 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,
+ );
- // Load again
- await commandService.loadCommands();
- const tree = commandService.getCommands();
+ const commands = service.getCommands();
- // Should not append, but overwrite
- expect(tree.length).toBe(subCommandLen);
- });
- });
+ expect(emptyLoader.loadCommands).toHaveBeenCalledTimes(1);
+ expect(commands).toHaveLength(2);
+ expect(commands).toEqual(
+ expect.arrayContaining([mockCommandA, mockCommandB]),
+ );
+ });
- describe('getCommandTree', () => {
- it('should return the current command tree', async () => {
- const initialTree = commandService.getCommands();
- expect(initialTree).toEqual([]);
+ 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);
- await commandService.loadCommands();
+ const service = await CommandService.create(
+ [successfulLoader, failingLoader],
+ new AbortController().signal,
+ );
- 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,
- ]);
- });
- });
+ const commands = service.getCommands();
+ expect(commands).toHaveLength(1);
+ expect(commands).toEqual([mockCommandA]);
+ expect(console.debug).toHaveBeenCalledWith(
+ 'A command loader failed:',
+ error,
+ );
});
- 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('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);
+ });
- // Arrange: Create a mock loader FUNCTION that resolves with our mock commands.
- const mockLoader = vi.fn().mockResolvedValue(mockCommands);
+ it('should pass the abort signal to all loaders', async () => {
+ const controller = new AbortController();
+ const signal = controller.signal;
- // Act: Instantiate the service WITH the injected loader function.
- const commandService = new CommandService(mockConfig, mockLoader);
- await commandService.loadCommands();
- const tree = commandService.getCommands();
+ const loader1 = new MockCommandLoader([mockCommandA]);
+ const loader2 = new MockCommandLoader([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);
+ await CommandService.create([loader1, loader2], signal);
- const commandNames = tree.map((cmd) => cmd.name);
- expect(commandNames).not.toContain('memory'); // Verify it didn't load production commands.
- });
+ expect(loader1.loadCommands).toHaveBeenCalledTimes(1);
+ expect(loader1.loadCommands).toHaveBeenCalledWith(signal);
+ expect(loader2.loadCommands).toHaveBeenCalledTimes(1);
+ expect(loader2.loadCommands).toHaveBeenCalledWith(signal);
});
});
diff --git a/packages/cli/src/services/CommandService.ts b/packages/cli/src/services/CommandService.ts
index 99eccbf2..ef4f4d14 100644
--- a/packages/cli/src/services/CommandService.ts
+++ b/packages/cli/src/services/CommandService.ts
@@ -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';
+import { ICommandLoader } from './types.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,
- ];
+/**
+ * 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 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[]) {}
- return allCommands.filter(
- (command): command is SlashCommand => command !== null,
- );
-};
+ /**
+ * 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)),
+ );
-export class CommandService {
- private commands: SlashCommand[] = [];
+ 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);
+ }
+ }
- constructor(
- private config: Config | null,
- private commandLoader: (
- config: Config | null,
- ) => Promise<SlashCommand[]> = loadBuiltInCommands,
- ) {
- // The constructor can be used for dependency injection in the future.
- }
+ // 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);
+ }
- 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);
+ const finalCommands = Object.freeze(Array.from(commandMap.values()));
+ return new CommandService(finalCommands);
}
- 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;
}
}
diff --git a/packages/cli/src/services/types.ts b/packages/cli/src/services/types.ts
new file mode 100644
index 00000000..9d30e791
--- /dev/null
+++ b/packages/cli/src/services/types.ts
@@ -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[]>;
+}