diff options
Diffstat (limited to 'packages/cli/src/services')
| -rw-r--r-- | packages/cli/src/services/BuiltinCommandLoader.test.ts | 118 | ||||
| -rw-r--r-- | packages/cli/src/services/BuiltinCommandLoader.ts | 73 | ||||
| -rw-r--r-- | packages/cli/src/services/CommandService.test.ts | 363 | ||||
| -rw-r--r-- | packages/cli/src/services/CommandService.ts | 133 | ||||
| -rw-r--r-- | packages/cli/src/services/types.ts | 24 |
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[]>; +} |
